import crypto from 'crypto'; import axios from 'axios'; import { PrismaClient } from '@prisma/client'; import FormData from 'form-data'; const prisma = new PrismaClient(); const ETSY_KEY_STRING = process.env.ETSY_KEY_STRING || ''; // Shared secret is usually not needed for v3 PKCE flows unless specific scopes/endpoints require signing, // but we keep it in env just in case. const ETSY_REDIRECT_URI = 'http://localhost:3001/api/etsy/callback'; export class EtsyAuthService { // Generate PKCE Challenge static generateChallenge() { const codeVerifier = crypto.randomBytes(32).toString('base64') .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64') .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); const state = crypto.randomBytes(16).toString('hex'); return { codeVerifier, codeChallenge, state }; } // Get Auth URL static getAuthUrl(codeChallenge: string, state: string, scopes: string[]) { const params = new URLSearchParams({ response_type: 'code', client_id: ETSY_KEY_STRING, redirect_uri: ETSY_REDIRECT_URI, scope: scopes.join(' '), state: state, code_challenge: codeChallenge, code_challenge_method: 'S256', }); return `https://www.etsy.com/oauth/connect?${params.toString()}`; } // Exchange Code for Token static async getAccessToken(code: string, codeVerifier: string) { try { const response = await axios.post('https://api.etsy.com/v3/public/oauth/token', { grant_type: 'authorization_code', client_id: ETSY_KEY_STRING, redirect_uri: ETSY_REDIRECT_URI, code: code, code_verifier: codeVerifier, }); return response.data; // { access_token, refresh_token, expires_in, etc } } catch (error: any) { console.error('Etsy Token Exchange Error:', error.response?.data || error.message); throw new Error('Failed to exchange code for token'); } } // Refresh Token static async refreshToken(refreshToken: string) { try { const response = await axios.post('https://api.etsy.com/v3/public/oauth/token', { grant_type: 'refresh_token', client_id: ETSY_KEY_STRING, refresh_token: refreshToken, }); return response.data; } catch (error: any) { console.error('Etsy Token Refresh Error:', error.response?.data || error.message); throw new Error('Failed to refresh token'); } } // Get User/Shop ID static async getSelf(accessToken: string) { try { const response = await axios.get('https://api.etsy.com/v3/application/users/me', { headers: { 'x-api-key': ETSY_KEY_STRING, 'Authorization': `Bearer ${accessToken}` } }); // Response usually contains user_id. From there we can get the shop. return response.data; } catch (error: any) { console.error('getSelf Error:', error.response?.data || error.message); throw error; } } static async getShop(userId: string | number, accessToken: string) { try { const response = await axios.get(`https://api.etsy.com/v3/application/users/${userId}/shops`, { headers: { 'x-api-key': ETSY_KEY_STRING, 'Authorization': `Bearer ${accessToken}` } }); return response.data; // Returns list of shops (usually 1) } catch (error: any) { console.error('getShop Error:', error.response?.data || error.message); throw error; } } // --- LISTING MANAGEMENT (V3) --- static async createDraftListing(shopId: string, accessToken: string, data: { title: string, description: string, price: number, quantity: number, who_made: 'i_did' | 'collective' | 'someone_else', when_made: 'made_to_order' | '2020_2025' | '2010_2019' | string, is_supply: boolean, taxonomy_id: number }) { try { const response = await axios.post(`https://api.etsy.com/v3/application/shops/${shopId}/listings`, data, { headers: { 'x-api-key': ETSY_KEY_STRING, 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' } }); return response.data; // { listing_id, ... } } catch (error: any) { console.error('createDraftListing Error:', error.response?.data || error.message); throw error; } } static async uploadListingImage(shopId: string, listingId: number | string, accessToken: string, imageBuffer: Buffer, filename: string) { try { const formData = new FormData(); formData.append('image', imageBuffer, { filename }); const response = await axios.post(`https://api.etsy.com/v3/application/shops/${shopId}/listings/${listingId}/images`, formData, { headers: { 'x-api-key': ETSY_KEY_STRING, 'Authorization': `Bearer ${accessToken}`, ...formData.getHeaders() } }); return response.data; } catch (error: any) { console.error('uploadListingImage Error:', error.response?.data || error.message); throw error; } } // Helper to check and refresh token if needed static async ensureValidToken(shopRecord: any) { const now = BigInt(Math.floor(Date.now() / 1000)); if (shopRecord.expiresAt > now + BigInt(60)) { return shopRecord.accessToken; } console.log(`[Etsy] Refreshing token for shop ${shopRecord.shopName}`); const tokenData = await this.refreshToken(shopRecord.refreshToken); const updated = await prisma.etsyShop.update({ where: { id: shopRecord.id }, data: { accessToken: tokenData.access_token, refreshToken: tokenData.refresh_token, expiresAt: BigInt(Math.floor(Date.now() / 1000) + tokenData.expires_in) } }); return updated.accessToken; } }