Store Etsy API credentials in the database instead of env vars
- New EtsySettings model holds the per-user API keystring and callback URL, managed via GET/PUT /api/etsy/config; env vars remain as optional fallback - Settings UI gains an API Configuration form (masked saved key, callback URL prefilled with this origin's /api/etsy/callback); Connect is enabled once configuration is saved - OAuth and sync resolve the key per user; post-callback redirect derives from the stored callback URL origin instead of CLIENT_URL Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
715562c96a
commit
4759db4c5b
5 changed files with 252 additions and 52 deletions
|
|
@ -13,6 +13,13 @@ interface EtsyStatus {
|
|||
lastSyncedAt?: string;
|
||||
}
|
||||
|
||||
interface EtsyConfig {
|
||||
configured: boolean;
|
||||
apiKeyMasked: string | null;
|
||||
redirectUri: string | null;
|
||||
source: 'database' | 'environment' | null;
|
||||
}
|
||||
|
||||
const Settings = () => {
|
||||
const dispatch = useDispatch();
|
||||
const [storageSummary, setStorageSummary] = useState({
|
||||
|
|
@ -24,6 +31,10 @@ const Settings = () => {
|
|||
});
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||
const [etsyStatus, setEtsyStatus] = useState<EtsyStatus | null>(null);
|
||||
const [etsyConfig, setEtsyConfig] = useState<EtsyConfig | null>(null);
|
||||
const [apiKeyInput, setApiKeyInput] = useState('');
|
||||
const [redirectUriInput, setRedirectUriInput] = useState('');
|
||||
const [isSavingConfig, setIsSavingConfig] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [unmatchedItems, setUnmatchedItems] = useState<string[]>([]);
|
||||
|
|
@ -31,6 +42,7 @@ const Settings = () => {
|
|||
useEffect(() => {
|
||||
updateStorageSummary();
|
||||
loadEtsyStatus();
|
||||
loadEtsyConfig();
|
||||
|
||||
// Handle the redirect back from the Etsy consent screen
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
|
@ -53,6 +65,39 @@ const Settings = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const loadEtsyConfig = async () => {
|
||||
try {
|
||||
const res = await api.get('/etsy/config');
|
||||
setEtsyConfig(res.data);
|
||||
// Prefill the callback URL: stored value, or this app's own callback route
|
||||
setRedirectUriInput(res.data.redirectUri || `${window.location.origin}/api/etsy/callback`);
|
||||
} catch {
|
||||
setEtsyConfig(null);
|
||||
setRedirectUriInput(`${window.location.origin}/api/etsy/callback`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEtsyConfig = async () => {
|
||||
if (!apiKeyInput.trim() && !etsyConfig?.configured) {
|
||||
toast.error('Enter your Etsy API keystring');
|
||||
return;
|
||||
}
|
||||
setIsSavingConfig(true);
|
||||
try {
|
||||
await api.put('/etsy/config', {
|
||||
apiKey: apiKeyInput.trim() || undefined,
|
||||
redirectUri: redirectUriInput.trim(),
|
||||
});
|
||||
toast.success('Etsy configuration saved');
|
||||
setApiKeyInput('');
|
||||
loadEtsyConfig();
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.message || 'Failed to save Etsy configuration');
|
||||
} finally {
|
||||
setIsSavingConfig(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEtsyConnect = async () => {
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
|
|
@ -193,19 +238,65 @@ const Settings = () => {
|
|||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-gray-600 text-sm max-w-md">
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 text-sm">
|
||||
Connect your Etsy shop to sync orders automatically — order details, items,
|
||||
variations, and tracking, with costs matched from your product catalog.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleEtsyConnect}
|
||||
disabled={isConnecting}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 disabled:bg-gray-400"
|
||||
>
|
||||
<Link2 className="w-4 h-4" />
|
||||
{isConnecting ? 'Redirecting…' : 'Connect Etsy Shop'}
|
||||
</button>
|
||||
|
||||
{/* API credentials, stored in the database */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 space-y-3">
|
||||
<h3 className="text-sm font-medium text-gray-900">API Configuration</h3>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Etsy API Keystring
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={etsyConfig?.apiKeyMasked
|
||||
? `Saved (${etsyConfig.apiKeyMasked}) — enter a new key to replace it`
|
||||
: 'Paste your keystring from etsy.com/developers'}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
value={apiKeyInput}
|
||||
onChange={(e) => setApiKeyInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Callback URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
value={redirectUriInput}
|
||||
onChange={(e) => setRedirectUriInput(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Register this exact URL as a callback URL in your Etsy app settings
|
||||
(etsy.com/developers) before connecting.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveEtsyConfig}
|
||||
disabled={isSavingConfig}
|
||||
className="px-4 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-800 disabled:bg-gray-400 text-sm"
|
||||
>
|
||||
{isSavingConfig ? 'Saving…' : 'Save Configuration'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleEtsyConnect}
|
||||
disabled={isConnecting || !etsyConfig?.configured}
|
||||
title={!etsyConfig?.configured ? 'Save your API configuration first' : undefined}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 disabled:bg-gray-400"
|
||||
>
|
||||
<Link2 className="w-4 h-4" />
|
||||
{isConnecting ? 'Redirecting…' : 'Connect Etsy Shop'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,8 +12,7 @@ JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
|||
# Session Secret (change in production)
|
||||
SESSION_SECRET=your-super-secret-session-key-change-this-in-production
|
||||
|
||||
# Etsy API (personal-use app from etsy.com/developers)
|
||||
# ETSY_API_KEY is the app keystring; ETSY_REDIRECT_URI must exactly match a
|
||||
# callback URL registered in the Etsy app settings
|
||||
ETSY_API_KEY=your-etsy-keystring
|
||||
ETSY_REDIRECT_URI=https://etsy.plexultra.com/api/etsy/callback
|
||||
# Etsy API (optional fallback — normally configured per user in the
|
||||
# Settings UI and stored in the database)
|
||||
# ETSY_API_KEY=your-etsy-keystring
|
||||
# ETSY_REDIRECT_URI=https://etsy.plexultra.com/api/etsy/callback
|
||||
24
server/src/models/EtsySettings.ts
Normal file
24
server/src/models/EtsySettings.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import mongoose, { Document, Schema } from 'mongoose';
|
||||
|
||||
// Per-user Etsy app credentials, entered in the Settings UI.
|
||||
// Falls back to ETSY_API_KEY / ETSY_REDIRECT_URI env vars when absent.
|
||||
export interface IEtsySettings extends Document {
|
||||
userId: mongoose.Types.ObjectId;
|
||||
apiKey: string;
|
||||
redirectUri: string;
|
||||
dateUpdated: Date;
|
||||
}
|
||||
|
||||
const EtsySettingsSchema: Schema = new Schema({
|
||||
userId: { type: Schema.Types.ObjectId, ref: 'User', required: true, unique: true, index: true },
|
||||
apiKey: { type: String, required: true, trim: true },
|
||||
redirectUri: { type: String, required: true, trim: true },
|
||||
dateUpdated: { type: Date, default: Date.now },
|
||||
});
|
||||
|
||||
EtsySettingsSchema.pre('save', function (next) {
|
||||
this.dateUpdated = new Date();
|
||||
next();
|
||||
});
|
||||
|
||||
export default mongoose.model<IEtsySettings>('EtsySettings', EtsySettingsSchema);
|
||||
|
|
@ -2,6 +2,7 @@ import { Router, Response, Request } from 'express';
|
|||
import crypto from 'crypto';
|
||||
import { authenticate, AuthRequest } from '../middleware/authenticate';
|
||||
import EtsyConnection from '../models/EtsyConnection';
|
||||
import EtsySettings from '../models/EtsySettings';
|
||||
import {
|
||||
buildAuthUrl,
|
||||
exchangeCode,
|
||||
|
|
@ -14,7 +15,10 @@ const router = Router();
|
|||
|
||||
// Pending OAuth attempts keyed by state; ties the browser callback (which has
|
||||
// no JWT) back to the user who initiated the connect. Single-process only.
|
||||
const pendingAuth = new Map<string, { userId: string; verifier: string; createdAt: number }>();
|
||||
const pendingAuth = new Map<
|
||||
string,
|
||||
{ userId: string; verifier: string; redirectUri: string; createdAt: number }
|
||||
>();
|
||||
const PENDING_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
const prunePending = () => {
|
||||
|
|
@ -24,23 +28,104 @@ const prunePending = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const redirectUri = (): string => {
|
||||
const uri = process.env.ETSY_REDIRECT_URI;
|
||||
if (!uri) throw new Error('ETSY_REDIRECT_URI is not configured on the server');
|
||||
return uri;
|
||||
interface EtsyConfig {
|
||||
apiKey: string;
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
// Per-user config from the database, falling back to env vars
|
||||
const loadConfig = async (userId: string): Promise<EtsyConfig | null> => {
|
||||
const settings = await EtsySettings.findOne({ userId });
|
||||
const apiKey = settings?.apiKey || process.env.ETSY_API_KEY;
|
||||
const redirectUri = settings?.redirectUri || process.env.ETSY_REDIRECT_URI;
|
||||
if (!apiKey || !redirectUri) return null;
|
||||
return { apiKey, redirectUri };
|
||||
};
|
||||
|
||||
const settingsUrl = (suffix: string): string =>
|
||||
`${process.env.CLIENT_URL || 'http://localhost:3000'}/settings?etsy=${suffix}`;
|
||||
// Where to send the browser after the OAuth callback: same origin as the
|
||||
// redirect URI (the app and API share one domain)
|
||||
const settingsUrl = (redirectUri: string | undefined, suffix: string): string => {
|
||||
let origin = process.env.CLIENT_URL || 'http://localhost:3000';
|
||||
try {
|
||||
if (redirectUri) origin = new URL(redirectUri).origin;
|
||||
} catch {
|
||||
// keep fallback
|
||||
}
|
||||
return `${origin}/settings?etsy=${suffix}`;
|
||||
};
|
||||
|
||||
const maskKey = (key: string): string =>
|
||||
key.length > 4 ? `${'•'.repeat(Math.max(4, key.length - 4))}${key.slice(-4)}` : '••••';
|
||||
|
||||
// ---------- Configuration (API key stored in the database) ----------
|
||||
|
||||
router.get('/config', authenticate, async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const settings = await EtsySettings.findOne({ userId: req.userId });
|
||||
const apiKey = settings?.apiKey || process.env.ETSY_API_KEY;
|
||||
res.json({
|
||||
configured: Boolean(apiKey && (settings?.redirectUri || process.env.ETSY_REDIRECT_URI)),
|
||||
apiKeyMasked: apiKey ? maskKey(apiKey) : null,
|
||||
redirectUri: settings?.redirectUri || process.env.ETSY_REDIRECT_URI || null,
|
||||
source: settings ? 'database' : apiKey ? 'environment' : null,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ message: 'Failed to fetch Etsy config', error: err });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/config', authenticate, async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { apiKey, redirectUri } = req.body || {};
|
||||
if (typeof redirectUri !== 'string' || !redirectUri.trim()) {
|
||||
return res.status(400).json({ message: 'redirectUri is required' });
|
||||
}
|
||||
try {
|
||||
new URL(redirectUri);
|
||||
} catch {
|
||||
return res.status(400).json({ message: 'redirectUri must be a valid URL' });
|
||||
}
|
||||
|
||||
const existing = await EtsySettings.findOne({ userId: req.userId });
|
||||
const newKey = typeof apiKey === 'string' && apiKey.trim() ? apiKey.trim() : existing?.apiKey;
|
||||
if (!newKey) {
|
||||
return res.status(400).json({ message: 'apiKey is required' });
|
||||
}
|
||||
|
||||
await EtsySettings.findOneAndUpdate(
|
||||
{ userId: req.userId },
|
||||
{ userId: req.userId, apiKey: newKey, redirectUri: redirectUri.trim() },
|
||||
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
||||
);
|
||||
|
||||
res.json({ message: 'Etsy configuration saved' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ message: 'Failed to save Etsy config', error: err });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- OAuth ----------
|
||||
|
||||
// Start the OAuth flow: returns the Etsy consent URL for the client to open
|
||||
router.get('/connect', authenticate, (req: AuthRequest, res: Response) => {
|
||||
router.get('/connect', authenticate, async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const config = await loadConfig(req.userId!);
|
||||
if (!config) {
|
||||
return res.status(400).json({
|
||||
message: 'Enter your Etsy API key and callback URL in Settings first',
|
||||
});
|
||||
}
|
||||
|
||||
prunePending();
|
||||
const state = crypto.randomBytes(16).toString('hex');
|
||||
const { verifier, challenge } = generatePkce();
|
||||
pendingAuth.set(state, { userId: req.userId!, verifier, createdAt: Date.now() });
|
||||
res.json({ url: buildAuthUrl(redirectUri(), state, challenge) });
|
||||
pendingAuth.set(state, {
|
||||
userId: req.userId!,
|
||||
verifier,
|
||||
redirectUri: config.redirectUri,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
res.json({ url: buildAuthUrl(config.apiKey, config.redirectUri, state, challenge) });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ message: err.message || 'Failed to start Etsy connection' });
|
||||
}
|
||||
|
|
@ -52,13 +137,16 @@ router.get('/callback', async (req: Request, res: Response) => {
|
|||
const pending = typeof state === 'string' ? pendingAuth.get(state) : undefined;
|
||||
|
||||
if (error || typeof code !== 'string' || !pending) {
|
||||
return res.redirect(settingsUrl('error'));
|
||||
return res.redirect(settingsUrl(pending?.redirectUri, 'error'));
|
||||
}
|
||||
pendingAuth.delete(String(state));
|
||||
|
||||
try {
|
||||
const tokens = await exchangeCode(code, pending.verifier, redirectUri());
|
||||
const identity = await fetchIdentity(tokens.access_token);
|
||||
const config = await loadConfig(pending.userId);
|
||||
if (!config) throw new Error('Etsy API key is no longer configured');
|
||||
|
||||
const tokens = await exchangeCode(config.apiKey, code, pending.verifier, pending.redirectUri);
|
||||
const identity = await fetchIdentity(config.apiKey, tokens.access_token);
|
||||
|
||||
await EtsyConnection.findOneAndUpdate(
|
||||
{ userId: pending.userId },
|
||||
|
|
@ -74,10 +162,10 @@ router.get('/callback', async (req: Request, res: Response) => {
|
|||
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
||||
);
|
||||
|
||||
res.redirect(settingsUrl('connected'));
|
||||
res.redirect(settingsUrl(pending.redirectUri, 'connected'));
|
||||
} catch (err) {
|
||||
console.error('Etsy OAuth callback failed:', err);
|
||||
res.redirect(settingsUrl('error'));
|
||||
res.redirect(settingsUrl(pending.redirectUri, 'error'));
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -101,7 +189,10 @@ router.post('/sync', authenticate, async (req: AuthRequest, res: Response) => {
|
|||
const connection = await EtsyConnection.findOne({ userId: req.userId });
|
||||
if (!connection) return res.status(400).json({ message: 'Etsy is not connected' });
|
||||
|
||||
const result = await syncReceipts(connection);
|
||||
const config = await loadConfig(req.userId!);
|
||||
if (!config) return res.status(400).json({ message: 'Etsy API key is not configured' });
|
||||
|
||||
const result = await syncReceipts(config.apiKey, connection);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
console.error('Etsy sync failed:', err);
|
||||
|
|
|
|||
|
|
@ -10,12 +10,6 @@ const ETSY_CONNECT_URL = 'https://www.etsy.com/oauth/connect';
|
|||
// Read scopes: receipts/transactions, listings, shop info
|
||||
export const ETSY_SCOPES = 'transactions_r listings_r shops_r';
|
||||
|
||||
const apiKey = (): string => {
|
||||
const key = process.env.ETSY_API_KEY;
|
||||
if (!key) throw new Error('ETSY_API_KEY is not configured on the server');
|
||||
return key;
|
||||
};
|
||||
|
||||
// ---------- OAuth (authorization code + PKCE) ----------
|
||||
|
||||
export const generatePkce = () => {
|
||||
|
|
@ -24,10 +18,10 @@ export const generatePkce = () => {
|
|||
return { verifier, challenge };
|
||||
};
|
||||
|
||||
export const buildAuthUrl = (redirectUri: string, state: string, codeChallenge: string): string => {
|
||||
export const buildAuthUrl = (apiKey: string, redirectUri: string, state: string, codeChallenge: string): string => {
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: apiKey(),
|
||||
client_id: apiKey,
|
||||
redirect_uri: redirectUri,
|
||||
scope: ETSY_SCOPES,
|
||||
state,
|
||||
|
|
@ -55,27 +49,27 @@ const requestToken = async (body: Record<string, string>): Promise<TokenResponse
|
|||
return res.json() as Promise<TokenResponse>;
|
||||
};
|
||||
|
||||
export const exchangeCode = (code: string, codeVerifier: string, redirectUri: string) =>
|
||||
export const exchangeCode = (apiKey: string, code: string, codeVerifier: string, redirectUri: string) =>
|
||||
requestToken({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: apiKey(),
|
||||
client_id: apiKey,
|
||||
redirect_uri: redirectUri,
|
||||
code,
|
||||
code_verifier: codeVerifier,
|
||||
});
|
||||
|
||||
const refreshTokens = (refreshToken: string) =>
|
||||
const refreshTokens = (apiKey: string, refreshToken: string) =>
|
||||
requestToken({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: apiKey(),
|
||||
client_id: apiKey,
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
// Refresh the connection's access token if it expires within the next minute
|
||||
const ensureFreshToken = async (connection: IEtsyConnection): Promise<void> => {
|
||||
const ensureFreshToken = async (apiKey: string, connection: IEtsyConnection): Promise<void> => {
|
||||
if (connection.tokenExpiresAt.getTime() > Date.now() + 60_000) return;
|
||||
|
||||
const tokens = await refreshTokens(connection.refreshToken);
|
||||
const tokens = await refreshTokens(apiKey, connection.refreshToken);
|
||||
connection.accessToken = tokens.access_token;
|
||||
connection.refreshToken = tokens.refresh_token;
|
||||
connection.tokenExpiresAt = new Date(Date.now() + tokens.expires_in * 1000);
|
||||
|
|
@ -84,11 +78,11 @@ const ensureFreshToken = async (connection: IEtsyConnection): Promise<void> => {
|
|||
|
||||
// ---------- API client ----------
|
||||
|
||||
const etsyGet = async (connection: IEtsyConnection, path: string): Promise<any> => {
|
||||
await ensureFreshToken(connection);
|
||||
const etsyGet = async (apiKey: string, connection: IEtsyConnection, path: string): Promise<any> => {
|
||||
await ensureFreshToken(apiKey, connection);
|
||||
const res = await fetch(`${ETSY_API_BASE}${path}`, {
|
||||
headers: {
|
||||
'x-api-key': apiKey(),
|
||||
'x-api-key': apiKey,
|
||||
Authorization: `Bearer ${connection.accessToken}`,
|
||||
},
|
||||
});
|
||||
|
|
@ -105,9 +99,9 @@ export interface EtsyIdentity {
|
|||
}
|
||||
|
||||
// Resolve the connected user's shop from a fresh access token
|
||||
export const fetchIdentity = async (accessToken: string): Promise<EtsyIdentity> => {
|
||||
export const fetchIdentity = async (apiKey: string, accessToken: string): Promise<EtsyIdentity> => {
|
||||
const res = await fetch(`${ETSY_API_BASE}/application/users/me`, {
|
||||
headers: { 'x-api-key': apiKey(), Authorization: `Bearer ${accessToken}` },
|
||||
headers: { 'x-api-key': apiKey, Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Etsy users/me failed (${res.status}): ${await res.text()}`);
|
||||
|
|
@ -120,7 +114,7 @@ export const fetchIdentity = async (accessToken: string): Promise<EtsyIdentity>
|
|||
let shopName: string | undefined;
|
||||
try {
|
||||
const shopRes = await fetch(`${ETSY_API_BASE}/application/shops/${me.shop_id}`, {
|
||||
headers: { 'x-api-key': apiKey(), Authorization: `Bearer ${accessToken}` },
|
||||
headers: { 'x-api-key': apiKey, Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (shopRes.ok) {
|
||||
const shop: any = await shopRes.json();
|
||||
|
|
@ -200,7 +194,7 @@ export interface SyncResult {
|
|||
receiptsSeen: number;
|
||||
}
|
||||
|
||||
export const syncReceipts = async (connection: IEtsyConnection): Promise<SyncResult> => {
|
||||
export const syncReceipts = async (apiKey: string, connection: IEtsyConnection): Promise<SyncResult> => {
|
||||
const products = await Product.find({ userId: connection.userId });
|
||||
const result: SyncResult = { created: 0, updated: 0, unmatchedItems: [], receiptsSeen: 0 };
|
||||
|
||||
|
|
@ -209,6 +203,7 @@ export const syncReceipts = async (connection: IEtsyConnection): Promise<SyncRes
|
|||
|
||||
while (true) {
|
||||
const page = await etsyGet(
|
||||
apiKey,
|
||||
connection,
|
||||
`/application/shops/${connection.shopId}/receipts?limit=${limit}&offset=${offset}`
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue