Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions functions/api/submit-guide.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
export async function onRequestPost(context) {
const { request, env } = context;

const cors = {
"Access-Control-Allow-Origin": "https://stackarchitect.xyz",
"Content-Type": "application/json",
};

let body;
try {
body = await request.json();
} catch {
return new Response(JSON.stringify({ error: "Invalid JSON" }), { status: 400, headers: cors });
}

const { email, tags } = body;
if (!email || typeof email !== 'string' || !email.includes('@')) {
return new Response(JSON.stringify({ error: "Valid email is required" }), { status: 400, headers: cors });
}

const apiKey = env.SYSTEME_IO_API_KEY;
if (!apiKey) {
return new Response(JSON.stringify({ error: "Server misconfiguration" }), { status: 500, headers: cors });
}

try {
const res = await fetch('https://api.systeme.io/api/contacts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': apiKey,
},
body: JSON.stringify({ email, tags: tags || [{ name: 'guides-capture' }] }),
});

if (!res.ok) {
// Return a generic error to avoid leaking systeme.io specifics, but forward status if helpful
return new Response(JSON.stringify({ error: "Failed to submit" }), { status: 500, headers: cors });
}

return new Response(JSON.stringify({ ok: true }), { status: 200, headers: cors });
} catch (err) {
return new Response(JSON.stringify({ error: "Internal server error" }), { status: 500, headers: cors });
}
}

export async function onRequestOptions() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "https://stackarchitect.xyz",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
});
}
95 changes: 95 additions & 0 deletions functions/api/submit-guide.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { onRequestPost, onRequestOptions } from './submit-guide.js';

describe('submit-guide API endpoint', () => {
let mockEnv;

beforeEach(() => {
mockEnv = {
SYSTEME_IO_API_KEY: 'test-api-key',
};
global.fetch = vi.fn();
});

afterEach(() => {
vi.restoreAllMocks();
});

const createMockContext = (body) => ({
env: mockEnv,
request: {
json: vi.fn().mockResolvedValue(body),
},
});

it('should return 400 for missing or invalid email', async () => {
const context = createMockContext({});
const response = await onRequestPost(context);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toBe("Valid email is required");

const context2 = createMockContext({ email: "invalidemail" });
const response2 = await onRequestPost(context2);
expect(response2.status).toBe(400);
});

it('should return 500 if API key is missing', async () => {
mockEnv.SYSTEME_IO_API_KEY = undefined;
const context = createMockContext({ email: "test@example.com" });
const response = await onRequestPost(context);
expect(response.status).toBe(500);
const data = await response.json();
expect(data.error).toBe("Server misconfiguration");
});

it('should forward request to systeme.io and return 200 on success', async () => {
global.fetch.mockResolvedValueOnce({ ok: true });

const context = createMockContext({ email: "test@example.com" });
const response = await onRequestPost(context);

expect(global.fetch).toHaveBeenCalledWith('https://api.systeme.io/api/contacts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': 'test-api-key',
},
body: JSON.stringify({ email: "test@example.com", tags: [{ name: 'guides-capture' }] }),
});

expect(response.status).toBe(200);
const data = await response.json();
expect(data.ok).toBe(true);
});

it('should use custom tags if provided', async () => {
global.fetch.mockResolvedValueOnce({ ok: true });

const context = createMockContext({ email: "test@example.com", tags: [{ name: 'custom-tag' }] });
const response = await onRequestPost(context);

expect(global.fetch).toHaveBeenCalledWith('https://api.systeme.io/api/contacts', expect.objectContaining({
body: JSON.stringify({ email: "test@example.com", tags: [{ name: 'custom-tag' }] }),
}));

expect(response.status).toBe(200);
});

it('should return 500 if upstream fails', async () => {
global.fetch.mockResolvedValueOnce({ ok: false });

const context = createMockContext({ email: "test@example.com" });
const response = await onRequestPost(context);

expect(response.status).toBe(500);
const data = await response.json();
expect(data.error).toBe("Failed to submit");
});

it('OPTIONS should return CORS headers', async () => {
const response = await onRequestOptions();
expect(response.headers.get('Access-Control-Allow-Methods')).toContain('POST');
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://stackarchitect.xyz');
});
});
6 changes: 3 additions & 3 deletions src/pages/shopify-automation-guides.astro
Original file line number Diff line number Diff line change
Expand Up @@ -1588,10 +1588,10 @@ document.getElementById('guides-submit')?.addEventListener('click', function ()
return;
}
el.style.borderColor = '';
fetch('https://api.systeme.io/api/contacts', {
fetch('/api/submit-guide', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Key': 'kdsymoc8fwhwwc604vrofp4jt0rb5bjosmzs5dqd4r8suo4avx4xsik4ixqvwgo4' },
body: JSON.stringify({ email, tags: [{ name: 'guides-capture' }] })
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
}).catch(() => {});
if (typeof gtag !== 'undefined') {
gtag('event', 'guides_email_capture', { event_category: 'conversion', event_label: 'guides_hub' });
Expand Down