Files
digicraft-be/services/etsyAuth.ts
Fahri Can Seçer 80dcf4d04a
Some checks failed
Deploy Backend / deploy (push) Has been cancelled
main
2026-02-05 01:29:22 +03:00

176 lines
6.5 KiB
TypeScript

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;
}
}