Remove all build-time variables and secure Wren API
Some checks failed
Build and Push Docker Images / docker (push) Failing after 2m20s
Some checks failed
Build and Push Docker Images / docker (push) Failing after 2m20s
BREAKING CHANGE: All environment variables are now runtime-configurable Changes: - Removed ALL build-time NEXT_PUBLIC_* variables from Dockerfile and CI/CD - Created server-side proxy routes for Wren API (/api/wren/*) - Refactored wrenClient.ts to use proxy endpoints (reduced from 400+ to 200 lines) - Updated checkoutClient.ts and emailClient.ts to remove NEXT_PUBLIC_ fallbacks - Hardcoded metadataBase in layout.tsx (no longer depends on env var) - Updated .env.local to use runtime-only variables (WREN_API_TOKEN, NocoDB config) Security improvements: - Wren API token never exposed to browser - All secrets stay server-side - No sensitive data baked into build Configuration: - Wren API: Set WREN_API_TOKEN in docker-compose or .env - NocoDB: Set NOCODB_* variables in docker-compose or .env - No Gitea secrets/variables needed for build (only registry credentials) Docker build is now truly environment-agnostic - same image works in any environment with different runtime configuration.
This commit is contained in:
parent
c08c46aa6c
commit
cfa7e88ed2
@ -30,9 +30,6 @@ jobs:
|
|||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
push: true
|
push: true
|
||||||
build-args: |
|
|
||||||
NEXT_PUBLIC_API_BASE_URL=${{ vars.NEXT_PUBLIC_API_BASE_URL }}
|
|
||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }}
|
|
||||||
tags: |
|
tags: |
|
||||||
${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_USERNAME }}/${{ vars.IMAGE_NAME }}:frontend-latest
|
${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_USERNAME }}/${{ vars.IMAGE_NAME }}:frontend-latest
|
||||||
${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_USERNAME }}/${{ vars.IMAGE_NAME }}:frontend-main-${{ github.sha }}
|
${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_USERNAME }}/${{ vars.IMAGE_NAME }}:frontend-main-${{ github.sha }}
|
||||||
|
|||||||
11
Dockerfile
11
Dockerfile
@ -10,17 +10,8 @@ RUN npm ci
|
|||||||
# Copy the rest of the app
|
# Copy the rest of the app
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Accept build arguments for NEXT_PUBLIC_ variables
|
|
||||||
# These MUST be provided at build time
|
|
||||||
ARG NEXT_PUBLIC_API_BASE_URL
|
|
||||||
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
|
||||||
|
|
||||||
# Set as environment variables so Next.js can bake them into the build
|
|
||||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
|
||||||
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
|
||||||
|
|
||||||
# Build Next.js app (standalone mode)
|
# Build Next.js app (standalone mode)
|
||||||
# NEXT_PUBLIC_ variables are now baked in at build time
|
# All environment variables are runtime-configurable via .env or docker-compose
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Production Stage - Next.js standalone server
|
# Production Stage - Next.js standalone server
|
||||||
|
|||||||
79
app/api/wren/offset-orders/route.ts
Normal file
79
app/api/wren/offset-orders/route.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/wren/offset-orders
|
||||||
|
* Proxy endpoint to create offset orders with Wren API
|
||||||
|
* This keeps the WREN_API_TOKEN secure on the server
|
||||||
|
*
|
||||||
|
* Request body:
|
||||||
|
* {
|
||||||
|
* tons: number,
|
||||||
|
* portfolioId: number,
|
||||||
|
* dryRun?: boolean,
|
||||||
|
* source?: string,
|
||||||
|
* note?: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const apiToken = process.env.WREN_API_TOKEN;
|
||||||
|
|
||||||
|
if (!apiToken) {
|
||||||
|
console.error('WREN_API_TOKEN is not configured');
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Wren API is not configured' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
const body = await request.json();
|
||||||
|
const { tons, portfolioId, dryRun = false, source, note } = body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!tons || !portfolioId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing required fields: tons and portfolioId' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create offset order payload
|
||||||
|
const orderPayload: any = {
|
||||||
|
tons,
|
||||||
|
portfolio_id: portfolioId,
|
||||||
|
dry_run: dryRun,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (source) orderPayload.source = source;
|
||||||
|
if (note) orderPayload.note = note;
|
||||||
|
|
||||||
|
// Make request to Wren API
|
||||||
|
const response = await fetch('https://www.wren.co/api/offset_orders', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(orderPayload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('Wren API error:', response.status, errorText);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to create offset order with Wren' },
|
||||||
|
{ status: response.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating Wren offset order:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Failed to create offset order' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/api/wren/portfolios/route.ts
Normal file
45
app/api/wren/portfolios/route.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/wren/portfolios
|
||||||
|
* Proxy endpoint to fetch portfolios from Wren API
|
||||||
|
* This keeps the WREN_API_TOKEN secure on the server
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const apiToken = process.env.WREN_API_TOKEN;
|
||||||
|
|
||||||
|
if (!apiToken) {
|
||||||
|
console.error('WREN_API_TOKEN is not configured');
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Wren API is not configured' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('https://www.wren.co/api/portfolios', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('Wren API error:', response.status, errorText);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch portfolios from Wren' },
|
||||||
|
{ status: response.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching Wren portfolios:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error instanceof Error ? error.message : 'Failed to fetch portfolios' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,7 +16,7 @@ export const metadata: Metadata = {
|
|||||||
authors: [{ name: 'Puffin Offset' }],
|
authors: [{ name: 'Puffin Offset' }],
|
||||||
creator: 'Puffin Offset',
|
creator: 'Puffin Offset',
|
||||||
publisher: 'Puffin Offset',
|
publisher: 'Puffin Offset',
|
||||||
metadataBase: new URL(process.env.NEXT_PUBLIC_API_BASE_URL || 'https://puffinoffset.com'),
|
metadataBase: new URL('https://puffinoffset.com'),
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: '/',
|
canonical: '/',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,18 +1,18 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
// Get API base URL from runtime config (window.env) or build-time config
|
// Get API base URL from runtime config (window.env)
|
||||||
// IMPORTANT: Call this function at REQUEST TIME, not at module load time,
|
// IMPORTANT: Call this function at REQUEST TIME, not at module load time,
|
||||||
// to ensure window.env is populated by env-config.js
|
// to ensure window.env is populated by env-config.js
|
||||||
const getApiBaseUrl = (): string => {
|
const getApiBaseUrl = (): string => {
|
||||||
// Check window.env first (runtime config from env.sh)
|
// Check window.env first (runtime config from env.sh or docker-compose)
|
||||||
if (typeof window !== 'undefined' && window.env?.API_BASE_URL) {
|
if (typeof window !== 'undefined' && window.env?.API_BASE_URL) {
|
||||||
return window.env.API_BASE_URL;
|
return window.env.API_BASE_URL;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to build-time env or production default
|
// Fall back to production default if not configured
|
||||||
// Next.js requires direct static reference to NEXT_PUBLIC_ variables
|
// All configuration is now runtime-only via environment variables
|
||||||
return process.env.NEXT_PUBLIC_API_BASE_URL || 'https://puffinoffset.com/api';
|
return 'https://puffinoffset.com/api';
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface CreateCheckoutSessionParams {
|
export interface CreateCheckoutSessionParams {
|
||||||
|
|||||||
@ -1,18 +1,18 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
// Get API base URL from runtime config (window.env) or build-time config
|
// Get API base URL from runtime config (window.env)
|
||||||
// IMPORTANT: Call this function at REQUEST TIME, not at module load time,
|
// IMPORTANT: Call this function at REQUEST TIME, not at module load time,
|
||||||
// to ensure window.env is populated by env-config.js
|
// to ensure window.env is populated by env-config.js
|
||||||
const getApiBaseUrl = (): string => {
|
const getApiBaseUrl = (): string => {
|
||||||
// Check window.env first (runtime config from env.sh)
|
// Check window.env first (runtime config from env.sh or docker-compose)
|
||||||
if (typeof window !== 'undefined' && window.env?.API_BASE_URL) {
|
if (typeof window !== 'undefined' && window.env?.API_BASE_URL) {
|
||||||
return window.env.API_BASE_URL;
|
return window.env.API_BASE_URL;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to build-time env or production default
|
// Fall back to production default if not configured
|
||||||
// Next.js requires direct static reference to NEXT_PUBLIC_ variables
|
// All configuration is now runtime-only via environment variables
|
||||||
return process.env.NEXT_PUBLIC_API_BASE_URL || 'https://puffinoffset.com/api';
|
return 'https://puffinoffset.com/api';
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ContactEmailData {
|
export interface ContactEmailData {
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
import axios, { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
import axios from 'axios';
|
||||||
import type { OffsetOrder, Portfolio } from '../types';
|
import type { OffsetOrder, Portfolio } from '../types';
|
||||||
import { config } from '../utils/config';
|
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
// Default portfolio for fallback
|
// Default portfolio for fallback
|
||||||
const DEFAULT_PORTFOLIO: Portfolio = {
|
const DEFAULT_PORTFOLIO: Portfolio = {
|
||||||
id: 2, // Updated to use ID 2 as in the tutorial
|
id: 2,
|
||||||
name: "Community Tree Planting",
|
name: "Community Tree Planting",
|
||||||
description: "A curated selection of high-impact carbon removal projects focused on carbon sequestration through tree planting.",
|
description: "A curated selection of high-impact carbon removal projects focused on carbon sequestration through tree planting.",
|
||||||
projects: [
|
projects: [
|
||||||
@ -28,85 +27,10 @@ const DEFAULT_PORTFOLIO: Portfolio = {
|
|||||||
currency: 'USD'
|
currency: 'USD'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create API client with error handling, timeout, and retry logic
|
/**
|
||||||
const createApiClient = () => {
|
* Get portfolios from Wren API via server-side proxy
|
||||||
if (!config.wrenApiKey) {
|
* This keeps the Wren API token secure on the server
|
||||||
console.error('Wren API token is missing! Token:', config.wrenApiKey);
|
*/
|
||||||
console.error('Environment:', window?.env ? JSON.stringify(window.env) : 'No window.env available');
|
|
||||||
throw new Error('Wren API token is not configured');
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.log('[wrenClient] Creating API client with key:', config.wrenApiKey ? '********' + config.wrenApiKey.slice(-4) : 'MISSING');
|
|
||||||
|
|
||||||
const client = axios.create({
|
|
||||||
// Updated base URL to match the tutorial exactly
|
|
||||||
baseURL: 'https://www.wren.co/api',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${config.wrenApiKey}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
timeout: 10000, // 10 second timeout
|
|
||||||
validateStatus: (status: number) => status >= 200 && status < 500, // Handle 4xx errors gracefully
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add request interceptor for logging
|
|
||||||
client.interceptors.request.use(
|
|
||||||
(config: InternalAxiosRequestConfig) => {
|
|
||||||
if (!config.headers?.Authorization) {
|
|
||||||
throw new Error('API token is required');
|
|
||||||
}
|
|
||||||
logger.log('[wrenClient] Making API request to:', config.url);
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
(error: Error) => {
|
|
||||||
console.error('[wrenClient] Request configuration error:', error.message);
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add response interceptor for error handling
|
|
||||||
client.interceptors.response.use(
|
|
||||||
(response: AxiosResponse) => {
|
|
||||||
logger.log('[wrenClient] Received API response:', response.status);
|
|
||||||
return response;
|
|
||||||
},
|
|
||||||
(error: unknown) => {
|
|
||||||
if (axios.isAxiosError(error)) {
|
|
||||||
if (error.code === 'ECONNABORTED') {
|
|
||||||
logger.warn('[wrenClient] Request timeout, using fallback data');
|
|
||||||
return Promise.resolve({ data: { portfolios: [DEFAULT_PORTFOLIO] } });
|
|
||||||
}
|
|
||||||
if (!error.response) {
|
|
||||||
logger.warn('[wrenClient] Network error, using fallback data');
|
|
||||||
return Promise.resolve({ data: { portfolios: [DEFAULT_PORTFOLIO] } });
|
|
||||||
}
|
|
||||||
if (error.response.status === 401) {
|
|
||||||
logger.warn('[wrenClient] Authentication failed, using fallback data');
|
|
||||||
return Promise.resolve({ data: { portfolios: [DEFAULT_PORTFOLIO] } });
|
|
||||||
}
|
|
||||||
console.error('[wrenClient] API error:', error.response?.status, error.response?.data);
|
|
||||||
}
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return client;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Safe error logging function that handles non-serializable objects
|
|
||||||
const logError = (error: unknown) => {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
const errorInfo = {
|
|
||||||
name: error.name,
|
|
||||||
message: error.message,
|
|
||||||
stack: error.stack
|
|
||||||
};
|
|
||||||
console.error('[wrenClient] API Error:', JSON.stringify(errorInfo, null, 2));
|
|
||||||
} else {
|
|
||||||
console.error('[wrenClient] Unknown error:', String(error));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getPortfolios(): Promise<Portfolio[]> {
|
export async function getPortfolios(): Promise<Portfolio[]> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
console.log('🔵 [WREN API] ========================================');
|
console.log('🔵 [WREN API] ========================================');
|
||||||
@ -114,19 +38,9 @@ export async function getPortfolios(): Promise<Portfolio[]> {
|
|||||||
console.log('🔵 [WREN API] Timestamp:', new Date().toISOString());
|
console.log('🔵 [WREN API] Timestamp:', new Date().toISOString());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!config.wrenApiKey) {
|
console.log('🔵 [WREN API] Making request to proxy: /api/wren/portfolios');
|
||||||
console.warn('⚠️ [WREN API] No API token configured, using fallback portfolio');
|
|
||||||
logger.warn('[wrenClient] No Wren API token configured, using fallback portfolio');
|
|
||||||
return [DEFAULT_PORTFOLIO];
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('🔵 [WREN API] API Key:', config.wrenApiKey ? '********' + config.wrenApiKey.slice(-4) : 'MISSING');
|
const response = await axios.get('/api/wren/portfolios');
|
||||||
logger.log('[wrenClient] Getting portfolios with token:', config.wrenApiKey ? '********' + config.wrenApiKey.slice(-4) : 'MISSING');
|
|
||||||
|
|
||||||
const api = createApiClient();
|
|
||||||
console.log('🔵 [WREN API] Making request to: https://www.wren.co/api/portfolios');
|
|
||||||
|
|
||||||
const response = await api.get('/portfolios');
|
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
console.log('✅ [WREN API] GET /portfolios - Success');
|
console.log('✅ [WREN API] GET /portfolios - Success');
|
||||||
@ -147,7 +61,7 @@ export async function getPortfolios(): Promise<Portfolio[]> {
|
|||||||
|
|
||||||
return response.data.portfolios.map((portfolio: any) => {
|
return response.data.portfolios.map((portfolio: any) => {
|
||||||
let pricePerTon = 18; // Default price based on the Wren Climate Fund average
|
let pricePerTon = 18; // Default price based on the Wren Climate Fund average
|
||||||
|
|
||||||
// The API returns cost_per_ton in snake_case
|
// The API returns cost_per_ton in snake_case
|
||||||
if (portfolio.cost_per_ton !== undefined && portfolio.cost_per_ton !== null) {
|
if (portfolio.cost_per_ton !== undefined && portfolio.cost_per_ton !== null) {
|
||||||
pricePerTon = typeof portfolio.cost_per_ton === 'number' ? portfolio.cost_per_ton : parseFloat(portfolio.cost_per_ton) || 18;
|
pricePerTon = typeof portfolio.cost_per_ton === 'number' ? portfolio.cost_per_ton : parseFloat(portfolio.cost_per_ton) || 18;
|
||||||
@ -159,12 +73,10 @@ export async function getPortfolios(): Promise<Portfolio[]> {
|
|||||||
|
|
||||||
// Convert from snake_case to camelCase for projects
|
// Convert from snake_case to camelCase for projects
|
||||||
const projects = portfolio.projects?.map((project: any) => {
|
const projects = portfolio.projects?.map((project: any) => {
|
||||||
// Ensure cost_per_ton is properly mapped
|
|
||||||
const projectPricePerTon = project.cost_per_ton !== undefined && project.cost_per_ton !== null
|
const projectPricePerTon = project.cost_per_ton !== undefined && project.cost_per_ton !== null
|
||||||
? (typeof project.cost_per_ton === 'number' ? project.cost_per_ton : parseFloat(project.cost_per_ton))
|
? (typeof project.cost_per_ton === 'number' ? project.cost_per_ton : parseFloat(project.cost_per_ton))
|
||||||
: pricePerTon;
|
: pricePerTon;
|
||||||
|
|
||||||
// Ensure percentage is properly captured
|
|
||||||
const projectPercentage = project.percentage !== undefined && project.percentage !== null
|
const projectPercentage = project.percentage !== undefined && project.percentage !== null
|
||||||
? (typeof project.percentage === 'number' ? project.percentage : parseFloat(project.percentage))
|
? (typeof project.percentage === 'number' ? project.percentage : parseFloat(project.percentage))
|
||||||
: undefined;
|
: undefined;
|
||||||
@ -174,13 +86,10 @@ export async function getPortfolios(): Promise<Portfolio[]> {
|
|||||||
name: project.name,
|
name: project.name,
|
||||||
description: project.description || '',
|
description: project.description || '',
|
||||||
shortDescription: project.short_description || project.description || '',
|
shortDescription: project.short_description || project.description || '',
|
||||||
imageUrl: project.image_url, // Map from snake_case API response
|
imageUrl: project.image_url,
|
||||||
pricePerTon: projectPricePerTon,
|
pricePerTon: projectPricePerTon,
|
||||||
percentage: projectPercentage, // Include percentage field
|
percentage: projectPercentage,
|
||||||
certificationStatus: project.certification_status, // Map certification status from API
|
certificationStatus: project.certification_status,
|
||||||
// Remove fields that aren't in the API
|
|
||||||
// The required type fields are still in the type definition for compatibility
|
|
||||||
// but we no longer populate them with default values
|
|
||||||
location: '',
|
location: '',
|
||||||
type: '',
|
type: '',
|
||||||
verificationStandard: '',
|
verificationStandard: '',
|
||||||
@ -189,7 +98,7 @@ export async function getPortfolios(): Promise<Portfolio[]> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}) || [];
|
}) || [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: portfolio.id,
|
id: portfolio.id,
|
||||||
name: portfolio.name,
|
name: portfolio.name,
|
||||||
@ -206,51 +115,44 @@ export async function getPortfolios(): Promise<Portfolio[]> {
|
|||||||
|
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
console.error('❌ [WREN API] Status:', error.response?.status || 'No response');
|
console.error('❌ [WREN API] Status:', error.response?.status || 'No response');
|
||||||
console.error('❌ [WREN API] Status Text:', error.response?.statusText || 'N/A');
|
|
||||||
console.error('❌ [WREN API] Error Data:', JSON.stringify(error.response?.data, null, 2));
|
console.error('❌ [WREN API] Error Data:', JSON.stringify(error.response?.data, null, 2));
|
||||||
console.error('❌ [WREN API] Request URL:', error.config?.url);
|
|
||||||
} else {
|
} else {
|
||||||
console.error('❌ [WREN API] Error:', error instanceof Error ? error.message : String(error));
|
console.error('❌ [WREN API] Error:', error instanceof Error ? error.message : String(error));
|
||||||
}
|
}
|
||||||
console.log('🔵 [WREN API] ========================================');
|
console.log('🔵 [WREN API] ========================================');
|
||||||
|
|
||||||
logError(error);
|
|
||||||
logger.warn('[wrenClient] Failed to fetch portfolios from API, using fallback');
|
logger.warn('[wrenClient] Failed to fetch portfolios from API, using fallback');
|
||||||
return [DEFAULT_PORTFOLIO];
|
return [DEFAULT_PORTFOLIO];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create offset order with Wren API via server-side proxy
|
||||||
|
* This keeps the Wren API token secure on the server
|
||||||
|
*/
|
||||||
export async function createOffsetOrder(
|
export async function createOffsetOrder(
|
||||||
portfolioId: number,
|
portfolioId: number,
|
||||||
tons: number,
|
tons: number,
|
||||||
dryRun: boolean = false
|
dryRun: boolean = false,
|
||||||
|
source?: string,
|
||||||
|
note?: string
|
||||||
): Promise<OffsetOrder> {
|
): Promise<OffsetOrder> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
console.log('🔵 [WREN API] ========================================');
|
console.log('🔵 [WREN API] ========================================');
|
||||||
console.log('🔵 [WREN API] POST /offset-orders - Request initiated');
|
console.log('🔵 [WREN API] POST /offset-orders - Request initiated');
|
||||||
console.log('🔵 [WREN API] Timestamp:', new Date().toISOString());
|
console.log('🔵 [WREN API] Timestamp:', new Date().toISOString());
|
||||||
console.log('🔵 [WREN API] Parameters:', JSON.stringify({ portfolioId, tons, dryRun }, null, 2));
|
console.log('🔵 [WREN API] Parameters:', JSON.stringify({ portfolioId, tons, dryRun, source, note }, null, 2));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!config.wrenApiKey) {
|
console.log('🔵 [WREN API] Making request to proxy: /api/wren/offset-orders');
|
||||||
console.error('❌ [WREN API] Cannot create order - missing API token');
|
logger.log(`[wrenClient] Creating offset order via proxy: portfolio=${portfolioId}, tons=${tons}, dryRun=${dryRun}`);
|
||||||
console.error('[wrenClient] Cannot create order - missing API token');
|
|
||||||
throw new Error('Carbon offset service is currently unavailable. Please contact support.');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('🔵 [WREN API] API Key:', config.wrenApiKey ? '********' + config.wrenApiKey.slice(-4) : 'MISSING');
|
const response = await axios.post('/api/wren/offset-orders', {
|
||||||
logger.log(`[wrenClient] Creating offset order: portfolio=${portfolioId}, tons=${tons}, dryRun=${dryRun}`);
|
|
||||||
|
|
||||||
const api = createApiClient();
|
|
||||||
console.log('🔵 [WREN API] Making request to: https://www.wren.co/api/offset-orders');
|
|
||||||
console.log('🔵 [WREN API] Request payload:', JSON.stringify({ portfolioId, tons, dryRun }, null, 2));
|
|
||||||
|
|
||||||
// Removed the /api prefix to match the working example
|
|
||||||
const response = await api.post('/offset-orders', {
|
|
||||||
// Using exactly the format shown in the API tutorial
|
|
||||||
portfolioId, // Use the provided portfolio ID instead of hardcoding
|
|
||||||
tons,
|
tons,
|
||||||
dryRun // Use the provided dryRun parameter
|
portfolioId,
|
||||||
|
dryRun,
|
||||||
|
source,
|
||||||
|
note
|
||||||
});
|
});
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
@ -258,127 +160,39 @@ export async function createOffsetOrder(
|
|||||||
console.log('✅ [WREN API] POST /offset-orders - Success');
|
console.log('✅ [WREN API] POST /offset-orders - Success');
|
||||||
console.log('✅ [WREN API] Status:', response.status);
|
console.log('✅ [WREN API] Status:', response.status);
|
||||||
console.log('✅ [WREN API] Duration:', duration + 'ms');
|
console.log('✅ [WREN API] Duration:', duration + 'ms');
|
||||||
|
console.log('✅ [WREN API] Order ID:', response.data.id);
|
||||||
|
console.log('✅ [WREN API] Amount Charged:', response.data.amount_paid_by_customer);
|
||||||
|
console.log('🔵 [WREN API] ========================================');
|
||||||
|
|
||||||
// Add detailed response logging
|
logger.log(`[wrenClient] Order created successfully: ${response.data.id}`);
|
||||||
logger.log('[wrenClient] Offset order response:',
|
|
||||||
response.status,
|
|
||||||
response.data ? 'has data' : 'no data');
|
|
||||||
|
|
||||||
if (response.status === 400) {
|
|
||||||
console.error('❌ [WREN API] Bad request - Status 400');
|
|
||||||
console.error('❌ [WREN API] Bad request details:', response.data);
|
|
||||||
console.error('[wrenClient] Bad request details:', response.data);
|
|
||||||
throw new Error(`Failed to create offset order: ${JSON.stringify(response.data)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const order = response.data;
|
|
||||||
if (!order) {
|
|
||||||
console.error('❌ [WREN API] Empty response received');
|
|
||||||
throw new Error('Empty response received from offset order API');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ [WREN API] Order ID:', order.id || 'N/A');
|
|
||||||
console.log('✅ [WREN API] Amount Charged:', order.amountCharged ? `$${order.amountCharged}` : 'N/A');
|
|
||||||
console.log('✅ [WREN API] Tons:', order.tons || 'N/A');
|
|
||||||
console.log('✅ [WREN API] Status:', order.status || 'N/A');
|
|
||||||
console.log('✅ [WREN API] Dry Run:', order.dryRun !== undefined ? order.dryRun : 'N/A');
|
|
||||||
console.log('🔵 [WREN API] ========================================')
|
|
||||||
|
|
||||||
// Log to help diagnose issues
|
|
||||||
logger.log('[wrenClient] Order data keys:', Object.keys(order).join(', '));
|
|
||||||
if (order.portfolio) {
|
|
||||||
logger.log('[wrenClient] Portfolio data keys:', Object.keys(order.portfolio).join(', '));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get price from API response which uses cost_per_ton
|
|
||||||
let pricePerTon = 18;
|
|
||||||
if (order.portfolio?.cost_per_ton !== undefined) {
|
|
||||||
pricePerTon = typeof order.portfolio.cost_per_ton === 'number' ? order.portfolio.cost_per_ton : parseFloat(order.portfolio.cost_per_ton) || 18;
|
|
||||||
} else if (order.portfolio?.costPerTon !== undefined) {
|
|
||||||
pricePerTon = typeof order.portfolio.costPerTon === 'number' ? order.portfolio.costPerTon : parseFloat(order.portfolio.costPerTon) || 18;
|
|
||||||
} else if (order.portfolio?.pricePerTon !== undefined) {
|
|
||||||
pricePerTon = typeof order.portfolio.pricePerTon === 'number' ? order.portfolio.pricePerTon : parseFloat(order.portfolio.pricePerTon) || 18;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a safe method to extract properties with fallbacks
|
|
||||||
const getSafeProp = (obj: any, prop: string, fallback: any) => {
|
|
||||||
if (!obj) return fallback;
|
|
||||||
return obj[prop] !== undefined ? obj[prop] : fallback;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Use safe accessor to avoid undefined errors
|
|
||||||
const portfolio = order.portfolio || {};
|
|
||||||
|
|
||||||
// Adjusted to use camelCase as per API docs response format
|
|
||||||
return {
|
return {
|
||||||
id: getSafeProp(order, 'id', ''),
|
id: response.data.id,
|
||||||
amountCharged: getSafeProp(order, 'amountCharged', 0),
|
amountCharged: response.data.amount_paid_by_customer,
|
||||||
currency: getSafeProp(order, 'currency', 'USD'),
|
currency: (response.data.currency?.toUpperCase() || 'USD') as 'USD' | 'EUR' | 'GBP' | 'CHF',
|
||||||
tons: getSafeProp(order, 'tons', 0),
|
tons: response.data.tons,
|
||||||
portfolio: {
|
portfolio: response.data.portfolio,
|
||||||
id: getSafeProp(portfolio, 'id', 2),
|
status: 'completed',
|
||||||
name: getSafeProp(portfolio, 'name', 'Community Tree Planting'),
|
createdAt: new Date().toISOString(),
|
||||||
description: getSafeProp(portfolio, 'description', ''),
|
dryRun,
|
||||||
projects: getSafeProp(portfolio, 'projects', []),
|
source,
|
||||||
pricePerTon,
|
note
|
||||||
currency: getSafeProp(order, 'currency', 'USD')
|
|
||||||
},
|
|
||||||
status: getSafeProp(order, 'status', ''),
|
|
||||||
createdAt: getSafeProp(order, 'createdAt', new Date().toISOString()),
|
|
||||||
dryRun: getSafeProp(order, 'dryRun', true)
|
|
||||||
};
|
};
|
||||||
} catch (error: unknown) {
|
} catch (error) {
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
console.error('❌ [WREN API] POST /offset-orders - Failed');
|
console.error('❌ [WREN API] POST /offset-orders - Failed');
|
||||||
console.error('❌ [WREN API] Duration:', duration + 'ms');
|
console.error('❌ [WREN API] Duration:', duration + 'ms');
|
||||||
|
|
||||||
logError(error);
|
|
||||||
|
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
const axiosError = error as AxiosError;
|
console.error('❌ [WREN API] Status:', error.response?.status || 'No response');
|
||||||
|
console.error('❌ [WREN API] Error Data:', JSON.stringify(error.response?.data, null, 2));
|
||||||
console.error('❌ [WREN API] Status:', axiosError.response?.status || 'No response');
|
console.error('❌ [WREN API] Request URL:', error.config?.url);
|
||||||
console.error('❌ [WREN API] Status Text:', axiosError.response?.statusText || 'N/A');
|
|
||||||
console.error('❌ [WREN API] Error Data:', JSON.stringify(axiosError.response?.data, null, 2));
|
|
||||||
console.error('❌ [WREN API] Request URL:', axiosError.config?.url);
|
|
||||||
console.error('❌ [WREN API] Request Method:', axiosError.config?.method?.toUpperCase());
|
|
||||||
console.error('❌ [WREN API] Request Data:', JSON.stringify(axiosError.config?.data, null, 2));
|
|
||||||
console.log('🔵 [WREN API] ========================================');
|
|
||||||
|
|
||||||
console.error('[wrenClient] Axios error details:', {
|
|
||||||
status: axiosError.response?.status,
|
|
||||||
statusText: axiosError.response?.statusText,
|
|
||||||
data: axiosError.response?.data,
|
|
||||||
config: {
|
|
||||||
url: axiosError.config?.url,
|
|
||||||
method: axiosError.config?.method,
|
|
||||||
headers: axiosError.config?.headers ? 'Headers present' : 'No headers',
|
|
||||||
baseURL: axiosError.config?.baseURL,
|
|
||||||
data: axiosError.config?.data
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (axiosError.response?.status === 400) {
|
|
||||||
// Provide more specific error for 400 Bad Request
|
|
||||||
const responseData = axiosError.response.data as any;
|
|
||||||
const errorMessage = responseData?.message || responseData?.error || 'Invalid request format';
|
|
||||||
throw new Error(`Bad request: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (axiosError.code === 'ECONNABORTED') {
|
|
||||||
throw new Error('Request timed out. Please try again.');
|
|
||||||
}
|
|
||||||
if (!axiosError.response) {
|
|
||||||
throw new Error('Network error. Please check your connection and try again.');
|
|
||||||
}
|
|
||||||
if (axiosError.response.status === 401) {
|
|
||||||
throw new Error('Carbon offset service authentication failed. Please check your API token.');
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
console.error('❌ [WREN API] Error:', error instanceof Error ? error.message : String(error));
|
console.error('❌ [WREN API] Error:', error instanceof Error ? error.message : String(error));
|
||||||
console.log('🔵 [WREN API] ========================================');
|
|
||||||
}
|
}
|
||||||
|
console.log('🔵 [WREN API] ========================================');
|
||||||
|
|
||||||
throw new Error('Failed to create offset order. Please try again.');
|
logger.error('[wrenClient] Failed to create offset order');
|
||||||
|
throw new Error('Failed to create carbon offset order. Please try again or contact support.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user