@fayz-ai/shop 0.2.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/LICENSE +21 -0
- package/README.md +36 -0
- package/dist/catalog.cjs +176 -0
- package/dist/catalog.cjs.map +1 -0
- package/dist/catalog.d.ts +4 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +170 -0
- package/dist/catalog.js.map +1 -0
- package/dist/commerce.d.ts +28 -0
- package/dist/commerce.d.ts.map +1 -0
- package/dist/index.cjs +1047 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1027 -0
- package/dist/index.js.map +1 -0
- package/dist/mock-catalog.d.ts +42 -0
- package/dist/mock-catalog.d.ts.map +1 -0
- package/dist/mock-provider.d.ts +48 -0
- package/dist/mock-provider.d.ts.map +1 -0
- package/dist/mock.cjs +430 -0
- package/dist/mock.cjs.map +1 -0
- package/dist/mock.d.ts +3 -0
- package/dist/mock.d.ts.map +1 -0
- package/dist/mock.js +427 -0
- package/dist/mock.js.map +1 -0
- package/dist/provider.cjs +4 -0
- package/dist/provider.cjs.map +1 -0
- package/dist/provider.d.ts +42 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +3 -0
- package/dist/provider.js.map +1 -0
- package/dist/runtime.cjs +447 -0
- package/dist/runtime.cjs.map +1 -0
- package/dist/runtime.d.ts +5 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +443 -0
- package/dist/runtime.js.map +1 -0
- package/dist/supabase-provider.d.ts +33 -0
- package/dist/supabase-provider.d.ts.map +1 -0
- package/dist/tenant.d.ts +4 -0
- package/dist/tenant.d.ts.map +1 -0
- package/dist/types.cjs +4 -0
- package/dist/types.cjs.map +1 -0
- package/dist/types.d.ts +345 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +99 -0
- package/src/catalog.ts +3 -0
- package/src/commerce.ts +120 -0
- package/src/index.ts +34 -0
- package/src/mock-catalog.ts +258 -0
- package/src/mock-provider.ts +372 -0
- package/src/mock.ts +2 -0
- package/src/provider.ts +59 -0
- package/src/runtime.ts +20 -0
- package/src/supabase-provider.ts +499 -0
- package/src/tenant.ts +21 -0
- package/src/types.ts +401 -0
package/src/provider.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Product, ProductImage, Category,
|
|
3
|
+
Order, ShopCustomer, Discount,
|
|
4
|
+
CreateProductInput, UpdateProductInput, ListProductsOptions,
|
|
5
|
+
CreateCategoryInput, UpdateCategoryInput,
|
|
6
|
+
CreateOrderInput, UpdateOrderInput, ListOrdersOptions, PlaceOrderInput,
|
|
7
|
+
CreateCustomerInput, UpdateCustomerInput, ListCustomersOptions, ResolveCustomerInput,
|
|
8
|
+
CreateDiscountInput, UpdateDiscountInput, ListDiscountsOptions,
|
|
9
|
+
} from './types'
|
|
10
|
+
|
|
11
|
+
export interface ShopProvider {
|
|
12
|
+
// Products
|
|
13
|
+
listProducts(options?: ListProductsOptions): Promise<Product[]>
|
|
14
|
+
getProduct(id: string): Promise<Product | null>
|
|
15
|
+
createProduct(input: CreateProductInput): Promise<Product>
|
|
16
|
+
updateProduct(id: string, input: UpdateProductInput): Promise<Product>
|
|
17
|
+
deleteProduct(id: string): Promise<void>
|
|
18
|
+
uploadProductImage(productId: string, file: File): Promise<ProductImage>
|
|
19
|
+
deleteProductImage(imageId: string): Promise<void>
|
|
20
|
+
|
|
21
|
+
// Categories
|
|
22
|
+
listCategories(): Promise<Category[]>
|
|
23
|
+
createCategory(input: CreateCategoryInput): Promise<Category>
|
|
24
|
+
updateCategory(id: string, input: UpdateCategoryInput): Promise<Category>
|
|
25
|
+
deleteCategory(id: string): Promise<void>
|
|
26
|
+
|
|
27
|
+
// Orders
|
|
28
|
+
listOrders(options?: ListOrdersOptions): Promise<Order[]>
|
|
29
|
+
getOrder(id: string): Promise<Order | null>
|
|
30
|
+
createOrder(input: CreateOrderInput): Promise<Order>
|
|
31
|
+
updateOrder(id: string, input: UpdateOrderInput): Promise<Order>
|
|
32
|
+
/**
|
|
33
|
+
* Trusted order placement: the server re-reads product prices + stock,
|
|
34
|
+
* validates the discount, computes totals, and decrements inventory
|
|
35
|
+
* atomically. Storefront checkout MUST use this instead of createOrder so the
|
|
36
|
+
* browser cannot tamper with prices. createOrder remains for trusted admin entry.
|
|
37
|
+
*/
|
|
38
|
+
placeOrder(input: PlaceOrderInput): Promise<Order>
|
|
39
|
+
|
|
40
|
+
// Customers
|
|
41
|
+
listCustomers(options?: ListCustomersOptions): Promise<ShopCustomer[]>
|
|
42
|
+
getCustomer(id: string): Promise<ShopCustomer | null>
|
|
43
|
+
createCustomer(input: CreateCustomerInput): Promise<ShopCustomer>
|
|
44
|
+
updateCustomer(id: string, input: UpdateCustomerInput): Promise<ShopCustomer>
|
|
45
|
+
deleteCustomer(id: string): Promise<void>
|
|
46
|
+
/**
|
|
47
|
+
* Find-or-create the customer for a storefront shopper and (on Supabase) link
|
|
48
|
+
* it to auth.uid server-side, so order reads can be scoped to the owner by RLS.
|
|
49
|
+
* Optional: providers without it fall back to listCustomers+createCustomer.
|
|
50
|
+
*/
|
|
51
|
+
resolveCustomer?(input: ResolveCustomerInput): Promise<ShopCustomer>
|
|
52
|
+
|
|
53
|
+
// Discounts
|
|
54
|
+
listDiscounts(options?: ListDiscountsOptions): Promise<Discount[]>
|
|
55
|
+
getDiscount(id: string): Promise<Discount | null>
|
|
56
|
+
createDiscount(input: CreateDiscountInput): Promise<Discount>
|
|
57
|
+
updateDiscount(id: string, input: UpdateDiscountInput): Promise<Discount>
|
|
58
|
+
deleteDiscount(id: string): Promise<void>
|
|
59
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ShopProvider } from './provider'
|
|
2
|
+
import { createMockShopProvider } from './mock-provider'
|
|
3
|
+
|
|
4
|
+
let provider: ShopProvider | null = null
|
|
5
|
+
let mockFallback: ShopProvider | null = null
|
|
6
|
+
|
|
7
|
+
export function getShopProvider(): ShopProvider {
|
|
8
|
+
if (provider) return provider
|
|
9
|
+
if (!mockFallback) mockFallback = createMockShopProvider()
|
|
10
|
+
return mockFallback
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function setShopProvider(nextProvider: ShopProvider): void {
|
|
14
|
+
provider = nextProvider
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resetShopProvider(): void {
|
|
18
|
+
provider = null
|
|
19
|
+
mockFallback = null
|
|
20
|
+
}
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import { getSupabaseClientOptional } from '@fayz-ai/core'
|
|
2
|
+
import type { ShopProvider } from './provider'
|
|
3
|
+
import { getShopTenantId } from './tenant'
|
|
4
|
+
import type {
|
|
5
|
+
Product, ProductImage, Category,
|
|
6
|
+
Order, ShopCustomer, Discount,
|
|
7
|
+
CreateProductInput, UpdateProductInput, ListProductsOptions,
|
|
8
|
+
CreateCategoryInput, UpdateCategoryInput,
|
|
9
|
+
CreateOrderInput, UpdateOrderInput, ListOrdersOptions, PlaceOrderInput,
|
|
10
|
+
CreateCustomerInput, UpdateCustomerInput, ListCustomersOptions, ResolveCustomerInput,
|
|
11
|
+
CreateDiscountInput, UpdateDiscountInput, ListDiscountsOptions,
|
|
12
|
+
} from './types'
|
|
13
|
+
|
|
14
|
+
function getDb(): any {
|
|
15
|
+
const supabase = getSupabaseClientOptional()
|
|
16
|
+
if (!supabase) throw new Error('@fayz-ai/shop: Supabase client not initialized. Call setGlobalSupabaseClient() first.')
|
|
17
|
+
return supabase
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const getTenantId = getShopTenantId
|
|
21
|
+
|
|
22
|
+
function slugify(name: string): string {
|
|
23
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function asUuid(value: string | null | undefined): string | null {
|
|
27
|
+
if (!value) return null
|
|
28
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
|
|
29
|
+
? value
|
|
30
|
+
: null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function rowToProduct(row: any): Product {
|
|
34
|
+
return {
|
|
35
|
+
id: row.id,
|
|
36
|
+
tenantId: row.tenant_id,
|
|
37
|
+
name: row.name,
|
|
38
|
+
slug: row.slug ?? slugify(row.name),
|
|
39
|
+
description: row.description,
|
|
40
|
+
price: row.price ?? 0,
|
|
41
|
+
compareAtPrice: row.compare_at_price,
|
|
42
|
+
currency: row.currency ?? 'BRL',
|
|
43
|
+
status: row.status ?? 'active',
|
|
44
|
+
inventoryCount: row.inventory_count ?? 0,
|
|
45
|
+
sku: row.sku,
|
|
46
|
+
sortOrder: row.sort_order ?? 0,
|
|
47
|
+
metadata: row.metadata ?? {},
|
|
48
|
+
images: Array.isArray(row.images) ? row.images.map(rowToImage) : [],
|
|
49
|
+
categoryId: row.category_id,
|
|
50
|
+
categoryName: row.category_name ?? null,
|
|
51
|
+
isPhysical: row.is_physical ?? true,
|
|
52
|
+
weight: row.weight,
|
|
53
|
+
weightUnit: row.weight_unit ?? 'kg',
|
|
54
|
+
createdAt: row.created_at,
|
|
55
|
+
updatedAt: row.updated_at,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function rowToImage(row: any): ProductImage {
|
|
60
|
+
return {
|
|
61
|
+
id: row.id,
|
|
62
|
+
productId: row.product_id,
|
|
63
|
+
url: row.url,
|
|
64
|
+
altText: row.alt_text,
|
|
65
|
+
sortOrder: row.sort_order ?? 0,
|
|
66
|
+
isPrimary: row.is_primary ?? false,
|
|
67
|
+
createdAt: row.created_at,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function rowToCategory(row: any): Category {
|
|
72
|
+
return {
|
|
73
|
+
id: row.id,
|
|
74
|
+
tenantId: row.tenant_id,
|
|
75
|
+
name: row.name,
|
|
76
|
+
slug: row.slug ?? slugify(row.name),
|
|
77
|
+
description: row.description,
|
|
78
|
+
parentId: row.parent_id,
|
|
79
|
+
sortOrder: row.sort_order ?? 0,
|
|
80
|
+
createdAt: row.created_at,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function rowToOrder(row: any): Order {
|
|
85
|
+
return {
|
|
86
|
+
id: row.id,
|
|
87
|
+
tenantId: row.tenant_id,
|
|
88
|
+
orderNumber: row.order_number,
|
|
89
|
+
status: row.status ?? 'open',
|
|
90
|
+
financialStatus: row.financial_status ?? 'pending',
|
|
91
|
+
fulfillmentStatus: row.fulfillment_status ?? 'unfulfilled',
|
|
92
|
+
currency: row.currency ?? 'BRL',
|
|
93
|
+
subtotal: row.subtotal ?? 0,
|
|
94
|
+
taxTotal: row.tax_total ?? 0,
|
|
95
|
+
discountTotal: row.discount_total ?? 0,
|
|
96
|
+
shippingTotal: row.shipping_total ?? 0,
|
|
97
|
+
total: row.total ?? 0,
|
|
98
|
+
customerId: row.customer_id,
|
|
99
|
+
customerName: row.customer_name,
|
|
100
|
+
customerEmail: row.customer_email,
|
|
101
|
+
discountCode: row.discount_code,
|
|
102
|
+
notes: row.notes,
|
|
103
|
+
items: Array.isArray(row.items) ? row.items : [],
|
|
104
|
+
createdAt: row.created_at,
|
|
105
|
+
updatedAt: row.updated_at,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function rowToCustomer(row: any): ShopCustomer {
|
|
110
|
+
return {
|
|
111
|
+
id: row.id,
|
|
112
|
+
tenantId: row.tenant_id,
|
|
113
|
+
firstName: row.first_name,
|
|
114
|
+
lastName: row.last_name ?? '',
|
|
115
|
+
email: row.email,
|
|
116
|
+
phone: row.phone,
|
|
117
|
+
notes: row.notes,
|
|
118
|
+
ordersCount: row.orders_count ?? 0,
|
|
119
|
+
totalSpent: row.total_spent ?? 0,
|
|
120
|
+
createdAt: row.created_at,
|
|
121
|
+
updatedAt: row.updated_at,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function rowToDiscount(row: any): Discount {
|
|
126
|
+
return {
|
|
127
|
+
id: row.id,
|
|
128
|
+
tenantId: row.tenant_id,
|
|
129
|
+
title: row.title,
|
|
130
|
+
code: row.code,
|
|
131
|
+
type: row.type,
|
|
132
|
+
method: row.method ?? 'code',
|
|
133
|
+
value: row.value ?? 0,
|
|
134
|
+
usageLimit: row.usage_limit,
|
|
135
|
+
oncePerCustomer: row.once_per_customer ?? false,
|
|
136
|
+
startsAt: row.starts_at,
|
|
137
|
+
endsAt: row.ends_at,
|
|
138
|
+
status: row.status ?? 'active',
|
|
139
|
+
timesUsed: row.times_used ?? 0,
|
|
140
|
+
createdAt: row.created_at,
|
|
141
|
+
updatedAt: row.updated_at,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export class SupabaseShopProvider implements ShopProvider {
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Products
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
async listProducts(options?: ListProductsOptions): Promise<Product[]> {
|
|
151
|
+
const db = getDb()
|
|
152
|
+
const tenantId = getTenantId()
|
|
153
|
+
let q = db.from('shop_products').select('*, images:shop_product_images(*)')
|
|
154
|
+
if (tenantId) q = q.eq('tenant_id', tenantId)
|
|
155
|
+
if (options?.status) q = q.eq('status', options.status)
|
|
156
|
+
if (options?.categoryId) q = q.eq('category_id', options.categoryId)
|
|
157
|
+
if (options?.slug) q = q.eq('slug', options.slug)
|
|
158
|
+
if (options?.search) q = q.ilike('name', `%${options.search}%`)
|
|
159
|
+
q = q.order(options?.orderBy ?? 'sort_order', { ascending: options?.order !== 'desc' })
|
|
160
|
+
if (options?.limit) q = q.limit(options.limit)
|
|
161
|
+
if (options?.offset) q = q.range(options.offset, (options.offset ?? 0) + (options.limit ?? 50) - 1)
|
|
162
|
+
const { data, error } = await q
|
|
163
|
+
if (error) throw error
|
|
164
|
+
return (data ?? []).map(rowToProduct)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async getProduct(id: string): Promise<Product | null> {
|
|
168
|
+
const db = getDb()
|
|
169
|
+
const { data, error } = await db
|
|
170
|
+
.from('shop_products')
|
|
171
|
+
.select('*, images:shop_product_images(*)')
|
|
172
|
+
.eq('id', id)
|
|
173
|
+
.maybeSingle()
|
|
174
|
+
if (error) throw error
|
|
175
|
+
return data ? rowToProduct(data) : null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async createProduct(input: CreateProductInput): Promise<Product> {
|
|
179
|
+
const db = getDb()
|
|
180
|
+
const tenantId = getTenantId()
|
|
181
|
+
const { data, error } = await db
|
|
182
|
+
.from('shop_products')
|
|
183
|
+
.insert({
|
|
184
|
+
tenant_id: tenantId,
|
|
185
|
+
name: input.name,
|
|
186
|
+
slug: input.slug ?? slugify(input.name),
|
|
187
|
+
description: input.description ?? null,
|
|
188
|
+
price: input.price,
|
|
189
|
+
compare_at_price: input.compareAtPrice ?? null,
|
|
190
|
+
currency: input.currency ?? 'BRL',
|
|
191
|
+
status: input.status ?? 'active',
|
|
192
|
+
inventory_count: input.inventoryCount ?? 0,
|
|
193
|
+
sku: input.sku ?? null,
|
|
194
|
+
sort_order: input.sortOrder ?? 0,
|
|
195
|
+
metadata: input.metadata ?? {},
|
|
196
|
+
category_id: input.categoryId ?? null,
|
|
197
|
+
is_physical: input.isPhysical ?? true,
|
|
198
|
+
weight: input.weight ?? null,
|
|
199
|
+
weight_unit: input.weightUnit ?? 'kg',
|
|
200
|
+
})
|
|
201
|
+
.select('*, images:shop_product_images(*)')
|
|
202
|
+
.single()
|
|
203
|
+
if (error) throw error
|
|
204
|
+
return rowToProduct(data)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async updateProduct(id: string, input: UpdateProductInput): Promise<Product> {
|
|
208
|
+
const db = getDb()
|
|
209
|
+
const updates: Record<string, unknown> = {}
|
|
210
|
+
if (input.name !== undefined) { updates.name = input.name; updates.slug = input.slug ?? slugify(input.name) }
|
|
211
|
+
if (input.slug !== undefined) updates.slug = input.slug
|
|
212
|
+
if (input.description !== undefined) updates.description = input.description
|
|
213
|
+
if (input.price !== undefined) updates.price = input.price
|
|
214
|
+
if (input.compareAtPrice !== undefined) updates.compare_at_price = input.compareAtPrice
|
|
215
|
+
if (input.currency !== undefined) updates.currency = input.currency
|
|
216
|
+
if (input.status !== undefined) updates.status = input.status
|
|
217
|
+
if (input.inventoryCount !== undefined) updates.inventory_count = input.inventoryCount
|
|
218
|
+
if (input.sku !== undefined) updates.sku = input.sku
|
|
219
|
+
if (input.categoryId !== undefined) updates.category_id = input.categoryId
|
|
220
|
+
if (input.isPhysical !== undefined) updates.is_physical = input.isPhysical
|
|
221
|
+
if (input.weight !== undefined) updates.weight = input.weight
|
|
222
|
+
const { data, error } = await db
|
|
223
|
+
.from('shop_products').update(updates).eq('id', id)
|
|
224
|
+
.select('*, images:shop_product_images(*)')
|
|
225
|
+
.single()
|
|
226
|
+
if (error) throw error
|
|
227
|
+
return rowToProduct(data)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async deleteProduct(id: string): Promise<void> {
|
|
231
|
+
const { error } = await getDb().from('shop_products').delete().eq('id', id)
|
|
232
|
+
if (error) throw error
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async uploadProductImage(productId: string, file: File): Promise<ProductImage> {
|
|
236
|
+
const db = getDb()
|
|
237
|
+
const tenantId = getTenantId()
|
|
238
|
+
const path = `${tenantId}/products/${productId}/${Date.now()}-${file.name}`
|
|
239
|
+
const { error: uploadErr } = await db.storage.from('shop-images').upload(path, file)
|
|
240
|
+
if (uploadErr) throw uploadErr
|
|
241
|
+
const { data: { publicUrl } } = db.storage.from('shop-images').getPublicUrl(path)
|
|
242
|
+
const { data, error } = await db
|
|
243
|
+
.from('shop_product_images')
|
|
244
|
+
.insert({ product_id: productId, tenant_id: tenantId, url: publicUrl, sort_order: 0, is_primary: false })
|
|
245
|
+
.select().single()
|
|
246
|
+
if (error) throw error
|
|
247
|
+
return rowToImage(data)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async deleteProductImage(imageId: string): Promise<void> {
|
|
251
|
+
const { error } = await getDb().from('shop_product_images').delete().eq('id', imageId)
|
|
252
|
+
if (error) throw error
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// Categories
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
async listCategories(): Promise<Category[]> {
|
|
260
|
+
const db = getDb()
|
|
261
|
+
const tenantId = getTenantId()
|
|
262
|
+
let q = db.from('shop_categories').select('*').order('sort_order', { ascending: true })
|
|
263
|
+
if (tenantId) q = q.eq('tenant_id', tenantId)
|
|
264
|
+
const { data, error } = await q
|
|
265
|
+
if (error) throw error
|
|
266
|
+
return (data ?? []).map(rowToCategory)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async createCategory(input: CreateCategoryInput): Promise<Category> {
|
|
270
|
+
const db = getDb()
|
|
271
|
+
const { data, error } = await db
|
|
272
|
+
.from('shop_categories')
|
|
273
|
+
.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
|
+
.select().single()
|
|
275
|
+
if (error) throw error
|
|
276
|
+
return rowToCategory(data)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async updateCategory(id: string, input: UpdateCategoryInput): Promise<Category> {
|
|
280
|
+
const db = getDb()
|
|
281
|
+
const updates: Record<string, unknown> = {}
|
|
282
|
+
if (input.name !== undefined) updates.name = input.name
|
|
283
|
+
if (input.slug !== undefined) updates.slug = input.slug
|
|
284
|
+
if (input.description !== undefined) updates.description = input.description
|
|
285
|
+
if (input.parentId !== undefined) updates.parent_id = input.parentId
|
|
286
|
+
if (input.sortOrder !== undefined) updates.sort_order = input.sortOrder
|
|
287
|
+
const { data, error } = await db.from('shop_categories').update(updates).eq('id', id).select().single()
|
|
288
|
+
if (error) throw error
|
|
289
|
+
return rowToCategory(data)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async deleteCategory(id: string): Promise<void> {
|
|
293
|
+
const { error } = await getDb().from('shop_categories').delete().eq('id', id)
|
|
294
|
+
if (error) throw error
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// Orders
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
async listOrders(options?: ListOrdersOptions): Promise<Order[]> {
|
|
302
|
+
const db = getDb()
|
|
303
|
+
const tenantId = getTenantId()
|
|
304
|
+
let q = db.from('shop_orders').select('*, items:shop_order_items(*)')
|
|
305
|
+
if (tenantId) q = q.eq('tenant_id', tenantId)
|
|
306
|
+
if (options?.status) q = q.eq('status', options.status)
|
|
307
|
+
if (options?.financialStatus) q = q.eq('financial_status', options.financialStatus)
|
|
308
|
+
if (options?.fulfillmentStatus) q = q.eq('fulfillment_status', options.fulfillmentStatus)
|
|
309
|
+
if (options?.customerId) q = q.eq('customer_id', options.customerId)
|
|
310
|
+
if (options?.customerEmail) q = q.eq('customer_email', options.customerEmail)
|
|
311
|
+
if (options?.search) q = q.or(`customer_name.ilike.%${options.search}%,customer_email.ilike.%${options.search}%`)
|
|
312
|
+
q = q.order('created_at', { ascending: false })
|
|
313
|
+
if (options?.limit) q = q.limit(options.limit)
|
|
314
|
+
const { data, error } = await q
|
|
315
|
+
if (error) throw error
|
|
316
|
+
return (data ?? []).map(rowToOrder)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async getOrder(id: string): Promise<Order | null> {
|
|
320
|
+
const { data, error } = await getDb()
|
|
321
|
+
.from('shop_orders').select('*, items:shop_order_items(*)')
|
|
322
|
+
.eq('id', id).maybeSingle()
|
|
323
|
+
if (error) throw error
|
|
324
|
+
return data ? rowToOrder(data) : null
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async createOrder(input: CreateOrderInput): Promise<Order> {
|
|
328
|
+
const db = getDb()
|
|
329
|
+
const tenantId = getTenantId()
|
|
330
|
+
const subtotal = input.items.reduce((s, i) => s + i.quantity * i.unitPrice, 0)
|
|
331
|
+
const discountTotal = input.discountTotal ?? 0
|
|
332
|
+
const shippingTotal = input.shippingTotal ?? 0
|
|
333
|
+
const total = Math.round((subtotal - discountTotal + shippingTotal) * 100) / 100
|
|
334
|
+
const { data: order, error: orderErr } = await db
|
|
335
|
+
.from('shop_orders')
|
|
336
|
+
.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
|
+
.select().single()
|
|
338
|
+
if (orderErr) throw orderErr
|
|
339
|
+
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('shop_order_items').insert(items)
|
|
341
|
+
return this.getOrder(order.id) as Promise<Order>
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async updateOrder(id: string, input: UpdateOrderInput): Promise<Order> {
|
|
345
|
+
const updates: Record<string, unknown> = {}
|
|
346
|
+
if (input.status !== undefined) updates.status = input.status
|
|
347
|
+
if (input.financialStatus !== undefined) updates.financial_status = input.financialStatus
|
|
348
|
+
if (input.fulfillmentStatus !== undefined) updates.fulfillment_status = input.fulfillmentStatus
|
|
349
|
+
if (input.notes !== undefined) updates.notes = input.notes
|
|
350
|
+
const { error } = await getDb().from('shop_orders').update(updates).eq('id', id)
|
|
351
|
+
if (error) throw error
|
|
352
|
+
return this.getOrder(id) as Promise<Order>
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Trusted placement — delegates all price/discount/inventory authority to the
|
|
356
|
+
// shop_place_order RPC (SECURITY DEFINER). The browser never sets a price.
|
|
357
|
+
async placeOrder(input: PlaceOrderInput): Promise<Order> {
|
|
358
|
+
const db = getDb()
|
|
359
|
+
const tenantId = input.tenantId ?? getTenantId()
|
|
360
|
+
if (!tenantId) throw new Error('@fayz-ai/shop: placeOrder requires a tenant id (configure the shop tenant resolver).')
|
|
361
|
+
const { data, error } = await db.rpc('shop_place_order', {
|
|
362
|
+
p_tenant_id: tenantId,
|
|
363
|
+
p_items: input.items.map(i => ({ product_id: i.productId, quantity: i.quantity, options_label: i.optionsLabel ?? null })),
|
|
364
|
+
p_customer_id: asUuid(input.customerId),
|
|
365
|
+
p_customer_name: input.customer?.name ?? null,
|
|
366
|
+
p_customer_email: input.customer?.email ?? null,
|
|
367
|
+
p_currency: input.currency ?? 'BRL',
|
|
368
|
+
p_discount_code: input.discountCode ?? null,
|
|
369
|
+
p_shipping_total: input.shippingTotal ?? 0,
|
|
370
|
+
p_notes: input.notes ?? null,
|
|
371
|
+
})
|
|
372
|
+
if (error) throw error
|
|
373
|
+
const orderId = typeof data === 'string' ? data : (data?.id ?? data?.order_id ?? data)
|
|
374
|
+
const order = await this.getOrder(orderId)
|
|
375
|
+
if (!order) throw new Error('@fayz-ai/shop: placeOrder did not return a persisted order.')
|
|
376
|
+
return order
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
// Customers
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
async listCustomers(options?: ListCustomersOptions): Promise<ShopCustomer[]> {
|
|
384
|
+
const db = getDb()
|
|
385
|
+
const tenantId = getTenantId()
|
|
386
|
+
let q = db.from('shop_customers').select('*').order('first_name', { ascending: true })
|
|
387
|
+
if (tenantId) q = q.eq('tenant_id', tenantId)
|
|
388
|
+
if (options?.search) q = q.or(`first_name.ilike.%${options.search}%,last_name.ilike.%${options.search}%,email.ilike.%${options.search}%`)
|
|
389
|
+
if (options?.limit) q = q.limit(options.limit)
|
|
390
|
+
const { data, error } = await q
|
|
391
|
+
if (error) throw error
|
|
392
|
+
return (data ?? []).map(rowToCustomer)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async getCustomer(id: string): Promise<ShopCustomer | null> {
|
|
396
|
+
const { data, error } = await getDb().from('shop_customers').select('*').eq('id', id).maybeSingle()
|
|
397
|
+
if (error) throw error
|
|
398
|
+
return data ? rowToCustomer(data) : null
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async createCustomer(input: CreateCustomerInput): Promise<ShopCustomer> {
|
|
402
|
+
const { data, error } = await getDb()
|
|
403
|
+
.from('shop_customers')
|
|
404
|
+
.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
|
+
.select().single()
|
|
406
|
+
if (error) throw error
|
|
407
|
+
return rowToCustomer(data)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async updateCustomer(id: string, input: UpdateCustomerInput): Promise<ShopCustomer> {
|
|
411
|
+
const updates: Record<string, unknown> = {}
|
|
412
|
+
if (input.firstName !== undefined) updates.first_name = input.firstName
|
|
413
|
+
if (input.lastName !== undefined) updates.last_name = input.lastName
|
|
414
|
+
if (input.email !== undefined) updates.email = input.email
|
|
415
|
+
if (input.phone !== undefined) updates.phone = input.phone
|
|
416
|
+
if (input.notes !== undefined) updates.notes = input.notes
|
|
417
|
+
const { data, error } = await getDb().from('shop_customers').update(updates).eq('id', id).select().single()
|
|
418
|
+
if (error) throw error
|
|
419
|
+
return rowToCustomer(data)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async deleteCustomer(id: string): Promise<void> {
|
|
423
|
+
const { error } = await getDb().from('shop_customers').delete().eq('id', id)
|
|
424
|
+
if (error) throw error
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Find-or-create + link to auth.uid server-side (SECURITY DEFINER RPC), so the
|
|
428
|
+
// browser never supplies the auth id and "my orders" is RLS-scoped to the owner.
|
|
429
|
+
async resolveCustomer(input: ResolveCustomerInput): Promise<ShopCustomer> {
|
|
430
|
+
const db = getDb()
|
|
431
|
+
const tenantId = input.tenantId ?? getTenantId()
|
|
432
|
+
if (!tenantId) throw new Error('@fayz-ai/shop: resolveCustomer requires a tenant id.')
|
|
433
|
+
const { data, error } = await db.rpc('shop_resolve_customer', {
|
|
434
|
+
p_tenant_id: tenantId,
|
|
435
|
+
p_email: input.email,
|
|
436
|
+
p_name: input.name ?? null,
|
|
437
|
+
})
|
|
438
|
+
if (error) throw error
|
|
439
|
+
const row = Array.isArray(data) ? data[0] : data
|
|
440
|
+
if (!row) throw new Error('@fayz-ai/shop: resolveCustomer returned no row.')
|
|
441
|
+
return rowToCustomer(row)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
// Discounts
|
|
446
|
+
// ---------------------------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
async listDiscounts(options?: ListDiscountsOptions): Promise<Discount[]> {
|
|
449
|
+
const db = getDb()
|
|
450
|
+
const tenantId = getTenantId()
|
|
451
|
+
let q = db.from('shop_discounts').select('*').order('created_at', { ascending: false })
|
|
452
|
+
if (tenantId) q = q.eq('tenant_id', tenantId)
|
|
453
|
+
if (options?.status) q = q.eq('status', options.status)
|
|
454
|
+
if (options?.type) q = q.eq('type', options.type)
|
|
455
|
+
if (options?.search) q = q.ilike('title', `%${options.search}%`)
|
|
456
|
+
if (options?.limit) q = q.limit(options.limit)
|
|
457
|
+
const { data, error } = await q
|
|
458
|
+
if (error) throw error
|
|
459
|
+
return (data ?? []).map(rowToDiscount)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async getDiscount(id: string): Promise<Discount | null> {
|
|
463
|
+
const { data, error } = await getDb().from('shop_discounts').select('*').eq('id', id).maybeSingle()
|
|
464
|
+
if (error) throw error
|
|
465
|
+
return data ? rowToDiscount(data) : null
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async createDiscount(input: CreateDiscountInput): Promise<Discount> {
|
|
469
|
+
const { data, error } = await getDb()
|
|
470
|
+
.from('shop_discounts')
|
|
471
|
+
.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
|
+
.select().single()
|
|
473
|
+
if (error) throw error
|
|
474
|
+
return rowToDiscount(data)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async updateDiscount(id: string, input: UpdateDiscountInput): Promise<Discount> {
|
|
478
|
+
const updates: Record<string, unknown> = {}
|
|
479
|
+
if (input.title !== undefined) updates.title = input.title
|
|
480
|
+
if (input.code !== undefined) updates.code = input.code
|
|
481
|
+
if (input.type !== undefined) updates.type = input.type
|
|
482
|
+
if (input.value !== undefined) updates.value = input.value
|
|
483
|
+
if (input.usageLimit !== undefined) updates.usage_limit = input.usageLimit
|
|
484
|
+
if (input.status !== undefined) updates.status = input.status
|
|
485
|
+
if (input.endsAt !== undefined) updates.ends_at = input.endsAt
|
|
486
|
+
const { data, error } = await getDb().from('shop_discounts').update(updates).eq('id', id).select().single()
|
|
487
|
+
if (error) throw error
|
|
488
|
+
return rowToDiscount(data)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async deleteDiscount(id: string): Promise<void> {
|
|
492
|
+
const { error } = await getDb().from('shop_discounts').delete().eq('id', id)
|
|
493
|
+
if (error) throw error
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export function createSupabaseShopProvider(): ShopProvider {
|
|
498
|
+
return new SupabaseShopProvider()
|
|
499
|
+
}
|
package/src/tenant.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Tenant scoping for shop queries. @fayz-ai/shop has no dependency on @fayz-ai/saas —
|
|
2
|
+
// the host app (or @fayz-ai/plugin-shop) registers a resolver that returns the
|
|
3
|
+
// current tenant id. When no resolver is set, queries are unscoped (RLS still
|
|
4
|
+
// applies server-side).
|
|
5
|
+
|
|
6
|
+
export type TenantResolver = () => string | undefined
|
|
7
|
+
|
|
8
|
+
let _resolver: TenantResolver | null = null
|
|
9
|
+
|
|
10
|
+
export function setShopTenantResolver(resolver: TenantResolver): void {
|
|
11
|
+
_resolver = resolver
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getShopTenantId(): string | undefined {
|
|
15
|
+
if (!_resolver) return undefined
|
|
16
|
+
try {
|
|
17
|
+
return _resolver()
|
|
18
|
+
} catch {
|
|
19
|
+
return undefined
|
|
20
|
+
}
|
|
21
|
+
}
|