Files
route-commerce/src/actions/square-inventory.ts
T
openclaw c434015829
Deploy to route.crispygoat.com / deploy (push) Failing after 17s
feat(storage): MinIO object storage, Neon Auth, Supabase removal
- Add MinIO/S3-compatible storage client (src/lib/storage.ts) with uploadObject,
  deleteObject, presigned URL helpers, and BUCKETS constant
- Wire product images, brand logos, and water log photos to MinIO via the
  new storage client
- Migrate forgot-password to Neon Auth (remove Supabase /auth/v1/recover call)
- Migrate send-scheduled cron to direct Postgres + Resend (remove Supabase Edge
  Function proxy)
- Add logoUrl to email types (OrderReceipt, Welcome, PasswordReset) and pass
  brand_settings.logo_url from all call sites
- Update email templates to use dynamic logoUrl instead of hardcoded Supabase
  bucket URLs
- Remove hardcoded Supabase URLs from TuxedoVideoHero, TuxedoAboutPage,
  TimeTrackingFieldClient; use brand_settings props + local public/ fallback
- Download brand logos (3) and tuxedo-hero.mp4 (36MB) from Supabase bucket to
  public/ for local development
- Add MinIO env vars to .env.example (endpoint, access key, secret, buckets)
- Fix TimeTrackingFieldClient to destructure logoUrl and brandAccent props
- Fix admin/users.ts logoUrl type (null → undefined for optional string)
- Remove stale sb- cookie from wholesale-auth
- Migrate tuxedo/about page to remove supabase import and use pool query for
  wholesale_settings lookup
2026-06-09 11:51:32 -06:00

213 lines
6.9 KiB
TypeScript

"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getPaymentSettings } from "@/actions/payments";
import { withBrand } from "@/db/client";
import { products } from "@/db/schema";
import { inArray } from "drizzle-orm";
function getSquareBaseUrl() {
return process.env.SQUARE_ENVIRONMENT === "production"
? "https://connect.squareup.com"
: "https://connect.squareupsandbox.com";
}
async function getSquareCatalogItemVariation(
accessToken: string,
catalogObjectId: string
) {
const baseUrl = getSquareBaseUrl();
const response = await fetch(
`${baseUrl}/v2/catalog/object/${catalogObjectId}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"Square-Version": "2025-01-16",
},
}
);
if (!response.ok) throw new Error(`Catalog object fetch failed: ${await response.text()}`);
return response.json();
}
async function batchUpdateSquareInventory(
accessToken: string,
locationId: string,
updates: Array<{
catalogObjectId: string;
quantity: number;
type: "AVAILABLE" | "ON_HAND" | "SOLD_OUT";
}>
) {
const baseUrl = getSquareBaseUrl();
const response = await fetch(
`${baseUrl}/v2/inventory/changes/batch-create`,
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"Square-Version": "2025-01-16",
},
body: JSON.stringify({
idempotency_key: crypto.randomUUID(),
changes: updates.map((u) => ({
type: "ADJUSTMENT",
physical_count: {
catalog_object_id: u.catalogObjectId,
location_id: locationId,
quantity: String(u.quantity),
measurement_unit: { quantity_unit: u.type },
},
occurred_at: new Date().toISOString(),
})),
}),
}
);
if (!response.ok) {
const err = await response.text();
throw new Error(`Batch inventory update failed: ${err}`);
}
return response.json();
}
export type SyncResult = {
success: boolean;
synced: number;
errors: string[];
};
/**
* Sync inventory from Route Commerce products TO Square.
* Reduces Square inventory for each product sold.
* Called after an RC order is placed or updated.
*
* @param brandId - brand to sync for
* @param items - array of { productId, quantity } representing sold items
*/
export async function syncInventoryToSquare(
brandId: string,
items: Array<{ productId: string; quantity: number }>
): Promise<SyncResult> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] };
const settingsResult = await getPaymentSettings(brandId);
if (!settingsResult.success || !settingsResult.settings) {
return { success: false, synced: 0, errors: ["Could not load payment settings"] };
}
const settings = settingsResult.settings;
if (
!settings.square_access_token ||
!settings.square_location_id ||
!settings.square_sync_enabled ||
!["rc_to_square", "bidirectional"].includes(settings.square_inventory_mode)
) {
return { success: true, synced: 0, errors: [] }; // Not enabled, no-op
}
const errors: string[] = [];
const synced = 0;
// Build product name → quantity map
const itemQtyMap = new Map(items.map((i) => [i.productId, i.quantity]));
try {
// Fetch product details from RC
const productIds = items.map((i) => i.productId);
const rows = await withBrand(brandId, (db) =>
db
.select({ id: products.id, name: products.name })
.from(products)
.where(inArray(products.id, productIds))
);
if (rows.length === 0 && productIds.length > 0) {
return { success: false, synced: 0, errors: ["Failed to fetch products from RC"] };
}
// Find Square catalog items matching product names and reduce quantity
const updates: Array<{ catalogObjectId: string; quantity: number; type: "AVAILABLE" | "ON_HAND" | "SOLD_OUT" }> = [];
for (const product of rows) {
const qty = itemQtyMap.get(product.id) ?? 0;
if (qty <= 0) continue;
// In a real implementation, we'd maintain a mapping of RC product ID → Square catalog object ID
// For now, we skip this without the mapping (requires manual catalog linking)
// A future improvement would store square_catalog_object_id on RC products table
errors.push(`Product "${product.name}": inventory sync requires Square catalog object ID mapping (not yet implemented)`);
}
} catch (err) {
errors.push(`Inventory sync error: ${String(err)}`);
}
return { success: errors.length === 0, synced, errors };
}
/**
* Sync inventory from Square TO Route Commerce (polling).
* Fetches Square inventory counts and updates RC product inventory.
*/
export async function syncInventoryFromSquare(brandId: string): Promise<SyncResult> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, synced: 0, errors: ["Not authenticated"] };
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return { success: false, synced: 0, errors: ["Not authorized"] };
}
const settingsResult = await getPaymentSettings(brandId);
if (!settingsResult.success || !settingsResult.settings) {
return { success: false, synced: 0, errors: ["Could not load payment settings"] };
}
const settings = settingsResult.settings;
if (
!settings.square_access_token ||
!settings.square_location_id ||
!settings.square_sync_enabled ||
!["square_to_rc", "bidirectional"].includes(settings.square_inventory_mode)
) {
return { success: true, synced: 0, errors: [] }; // Not enabled, no-op
}
const errors: string[] = [];
const synced = 0;
try {
// Fetch Square inventory counts for the location
const baseUrl = getSquareBaseUrl();
const response = await fetch(
`${baseUrl}/v2/inventory/${settings.square_location_id}/counts`,
{
method: "GET",
headers: {
Authorization: `Bearer ${settings.square_access_token}`,
"Content-Type": "application/json",
"Square-Version": "2025-01-16",
},
}
);
if (!response.ok) {
return { success: false, synced: 0, errors: [`Square inventory fetch failed: ${await response.text()}`] };
}
const data = await response.json();
// For each inventory count, update RC product if we have a mapping
for (const count of data.counts ?? []) {
// count.catalog_object_id, count.quantity, count.calculated_at
// Would need a mapping table to update correct RC product
// This is noted as a future enhancement
errors.push(`Inventory item ${count.catalog_object_id}: requires Square catalog object ID mapping`);
}
} catch (err) {
errors.push(`Inventory sync error: ${String(err)}`);
}
return { success: errors.length === 0, synced, errors };
}