Phase 3: sync active Etsy listings into the product catalog
- syncListings pages active listings, fetches each listing's inventory, and upserts Products (one per size variation, collapsing colour). Title/description/price/tags/SKU/quantity and etsyListingId are updated; printingCost/costOfGoods are preserved (user-owned). Old titles are kept as aliases so historical order matching still works. - POST /api/etsy/sync-catalog endpoint; Settings gains a 'Sync Catalog' button that refreshes products after running. - Uses existing listings_r scope (no reconnect needed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
a470707fae
commit
2b15fd505e
3 changed files with 234 additions and 1 deletions
|
|
@ -2,8 +2,9 @@ import { useState, useEffect } from 'react';
|
|||
import { useDispatch } from 'react-redux';
|
||||
import { DataManager } from '../utils/dataManager';
|
||||
import { setOrders } from '../store/slices/orderSlice';
|
||||
import { setProducts } from '../store/slices/productSlice';
|
||||
import { MissingProductsModal } from '../components/MissingProductsModal';
|
||||
import { Trash2, Download, RefreshCw, AlertTriangle, Database, Store, Link2, Unlink, Wrench } from 'lucide-react';
|
||||
import { Trash2, Download, RefreshCw, AlertTriangle, Database, Store, Link2, Unlink, Wrench, Package } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import api from '../utils/api';
|
||||
|
||||
|
|
@ -40,6 +41,7 @@ const Settings = () => {
|
|||
const [isSavingConfig, setIsSavingConfig] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [isSyncingCatalog, setIsSyncingCatalog] = useState(false);
|
||||
const [unmatchedItems, setUnmatchedItems] = useState<string[]>([]);
|
||||
const [showResolveModal, setShowResolveModal] = useState(false);
|
||||
const [unknownDebits, setUnknownDebits] = useState<string[]>([]);
|
||||
|
|
@ -165,6 +167,21 @@ const Settings = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleSyncCatalog = async () => {
|
||||
setIsSyncingCatalog(true);
|
||||
try {
|
||||
const res = await api.post('/etsy/sync-catalog');
|
||||
const { listingsSeen, productsCreated, productsUpdated } = res.data;
|
||||
toast.success(`Catalog synced: ${listingsSeen} listings → ${productsCreated} new, ${productsUpdated} updated products`);
|
||||
const productsRes = await api.get('/products?limit=1000');
|
||||
dispatch(setProducts(productsRes.data.products));
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Catalog sync failed');
|
||||
} finally {
|
||||
setIsSyncingCatalog(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEtsyDisconnect = async () => {
|
||||
if (!window.confirm('Disconnect your Etsy shop? Synced data stays; only the connection is removed.')) return;
|
||||
try {
|
||||
|
|
@ -245,6 +262,15 @@ const Settings = () => {
|
|||
<RefreshCw className={`w-4 h-4 ${isSyncing ? 'animate-spin' : ''}`} />
|
||||
{isSyncing ? 'Syncing...' : 'Sync Orders Now'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSyncCatalog}
|
||||
disabled={isSyncingCatalog}
|
||||
title="Import active listings into your product catalog (one product per size; costs are kept)"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-orange-100 text-orange-700 rounded-lg hover:bg-orange-200 disabled:bg-gray-200"
|
||||
>
|
||||
<Package className={`w-4 h-4 ${isSyncingCatalog ? 'animate-spin' : ''}`} />
|
||||
{isSyncingCatalog ? 'Syncing...' : 'Sync Catalog'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEtsyDisconnect}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
syncReceipts,
|
||||
syncLedgerEntries,
|
||||
dedupeLedgerExpenses,
|
||||
syncListings,
|
||||
} from '../services/etsyApi';
|
||||
|
||||
const router = Router();
|
||||
|
|
@ -238,6 +239,24 @@ router.post('/sync', authenticate, async (req: AuthRequest, res: Response) => {
|
|||
}
|
||||
});
|
||||
|
||||
// Sync the active Etsy listings into the product catalog (one product per
|
||||
// size variation). Updates everything except costs, which stay user-owned.
|
||||
router.post('/sync-catalog', authenticate, async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const connection = await EtsyConnection.findOne({ userId: req.userId });
|
||||
if (!connection) return res.status(400).json({ message: 'Etsy is not connected' });
|
||||
|
||||
const config = await loadConfig(req.userId!);
|
||||
if (!config) return res.status(400).json({ message: 'Etsy API key is not configured' });
|
||||
|
||||
const result = await syncListings(toCredentials(config), connection);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
console.error('Etsy catalog sync failed:', err);
|
||||
res.status(500).json({ message: err.message || 'Etsy catalog sync failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Remove fee expenses imported from the statement CSV before ledger sync,
|
||||
// so the ledger becomes the single source of Etsy fees (no double counting)
|
||||
router.delete('/legacy-fees', authenticate, async (req: AuthRequest, res: Response) => {
|
||||
|
|
|
|||
|
|
@ -494,3 +494,191 @@ export const dedupeLedgerExpenses = async (userId: any): Promise<number> => {
|
|||
const res = await Expense.deleteMany({ _id: { $in: toDelete }, userId });
|
||||
return res.deletedCount || 0;
|
||||
};
|
||||
|
||||
// ---------- Active listings → Product catalog sync ----------
|
||||
|
||||
export interface CatalogSyncResult {
|
||||
listingsSeen: number;
|
||||
productsCreated: number;
|
||||
productsUpdated: number;
|
||||
}
|
||||
|
||||
const normTitle = (t: string): string => t.replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
|
||||
// Size token from a title ("... Size: Large" → "large"), with large/small fallback
|
||||
const titleSize = (text: string): string => {
|
||||
const lower = text.toLowerCase();
|
||||
const m = lower.match(/size:\s*([a-z0-9]+)/);
|
||||
if (m) return m[1];
|
||||
if (lower.includes('large') && !lower.includes('small')) return 'large';
|
||||
if (lower.includes('small') && !lower.includes('large')) return 'small';
|
||||
return '';
|
||||
};
|
||||
|
||||
// The size value of an inventory product, from its "Size" variation property
|
||||
const inventorySize = (invProduct: any): string => {
|
||||
const sizeProp = (invProduct.property_values || []).find((pv: any) =>
|
||||
/size/i.test(pv.property_name || '')
|
||||
);
|
||||
return sizeProp?.values?.[0] || '';
|
||||
};
|
||||
|
||||
interface ListingProductData {
|
||||
listingId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
price: number;
|
||||
quantity: number;
|
||||
sku?: string;
|
||||
}
|
||||
|
||||
const upsertCatalogProduct = async (
|
||||
connection: IEtsyConnection,
|
||||
data: ListingProductData,
|
||||
result: CatalogSyncResult
|
||||
): Promise<void> => {
|
||||
const candidates = await Product.find({
|
||||
userId: connection.userId,
|
||||
$or: [
|
||||
{ etsyListingId: data.listingId },
|
||||
{ title: data.title },
|
||||
{ aliases: data.title },
|
||||
],
|
||||
});
|
||||
|
||||
const wantedSize = titleSize(data.title);
|
||||
const existing =
|
||||
candidates.find(p => p.etsyListingId === data.listingId && titleSize(p.title) === wantedSize) ||
|
||||
candidates.find(p =>
|
||||
normTitle(p.title) === normTitle(data.title) ||
|
||||
(p.aliases || []).some((a: string) => normTitle(a) === normTitle(data.title))
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
// Update everything except costs; keep old title as an alias so historical
|
||||
// order matching by that title still resolves
|
||||
const aliases = new Set(existing.aliases || []);
|
||||
if (normTitle(existing.title) !== normTitle(data.title)) aliases.add(existing.title);
|
||||
|
||||
await Product.findByIdAndUpdate(existing._id, {
|
||||
$set: {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
price: data.price,
|
||||
tags: data.tags,
|
||||
sku: data.sku || existing.sku || '',
|
||||
etsyListingId: data.listingId,
|
||||
aliases: Array.from(aliases),
|
||||
'inventory.quantity': data.quantity,
|
||||
},
|
||||
});
|
||||
result.productsUpdated++;
|
||||
} else {
|
||||
await Product.create({
|
||||
userId: connection.userId,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
price: data.price,
|
||||
tags: data.tags,
|
||||
sku: data.sku || '',
|
||||
category: 'Other',
|
||||
etsyListingId: data.listingId,
|
||||
aliases: [],
|
||||
costOfGoods: 0,
|
||||
printingCost: 0,
|
||||
inventory: { quantity: data.quantity, lowStockAlert: 5 },
|
||||
isActive: true,
|
||||
});
|
||||
result.productsCreated++;
|
||||
}
|
||||
};
|
||||
|
||||
const upsertProductsFromListing = async (
|
||||
creds: EtsyCredentials,
|
||||
connection: IEtsyConnection,
|
||||
listing: any,
|
||||
result: CatalogSyncResult
|
||||
): Promise<void> => {
|
||||
const listingId = String(listing.listing_id);
|
||||
const baseTitle = listing.title || `Etsy listing ${listingId}`;
|
||||
const description = listing.description || '';
|
||||
const tags = listing.tags || [];
|
||||
const listingPrice = money(listing.price);
|
||||
|
||||
let inventory: any = null;
|
||||
try {
|
||||
inventory = await etsyGet(creds, connection, `/application/listings/${listingId}/inventory`);
|
||||
} catch {
|
||||
// Listing may have no inventory record; treated as a single product below
|
||||
}
|
||||
|
||||
const invProducts = (inventory?.products || []).filter((p: any) => !p.is_deleted);
|
||||
|
||||
// Group inventory by size (collapsing colour and other variations)
|
||||
const groups = new Map<string, { size: string; quantity: number; price: number; sku?: string }>();
|
||||
if (invProducts.length > 0) {
|
||||
for (const ip of invProducts) {
|
||||
const size = inventorySize(ip);
|
||||
const key = size.toLowerCase();
|
||||
const offerings = (ip.offerings || []).filter((o: any) => !o.is_deleted);
|
||||
const qty = offerings.reduce((s: number, o: any) => s + (o.quantity || 0), 0);
|
||||
const price = offerings.length ? money(offerings[0].price) : listingPrice;
|
||||
const g = groups.get(key);
|
||||
if (g) {
|
||||
g.quantity += qty;
|
||||
if (!g.sku && ip.sku) g.sku = ip.sku;
|
||||
} else {
|
||||
groups.set(key, { size, quantity: qty, price: price || listingPrice, sku: ip.sku || undefined });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
groups.set('', { size: '', quantity: listing.quantity || 0, price: listingPrice });
|
||||
}
|
||||
|
||||
for (const g of groups.values()) {
|
||||
await upsertCatalogProduct(
|
||||
connection,
|
||||
{
|
||||
listingId,
|
||||
title: g.size ? `${baseTitle} Size: ${g.size}` : baseTitle,
|
||||
description,
|
||||
tags,
|
||||
price: g.price,
|
||||
quantity: g.quantity,
|
||||
sku: g.sku,
|
||||
},
|
||||
result
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const syncListings = async (
|
||||
creds: EtsyCredentials,
|
||||
connection: IEtsyConnection
|
||||
): Promise<CatalogSyncResult> => {
|
||||
const result: CatalogSyncResult = { listingsSeen: 0, productsCreated: 0, productsUpdated: 0 };
|
||||
|
||||
const limit = 100;
|
||||
let offset = 0;
|
||||
|
||||
while (true) {
|
||||
const page = await etsyGet(
|
||||
creds,
|
||||
connection,
|
||||
`/application/shops/${connection.shopId}/listings/active?limit=${limit}&offset=${offset}`
|
||||
);
|
||||
const listings: any[] = page.results || [];
|
||||
if (listings.length === 0) break;
|
||||
|
||||
for (const listing of listings) {
|
||||
result.listingsSeen++;
|
||||
await upsertProductsFromListing(creds, connection, listing, result);
|
||||
}
|
||||
|
||||
offset += listings.length;
|
||||
if (offset >= (page.count || 0)) break;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue