-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
209 lines (175 loc) · 7.43 KB
/
server.js
File metadata and controls
209 lines (175 loc) · 7.43 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
const express = require('express');
const path = require('path');
// Load .env for local development (no-op in production if file absent)
try { require('dotenv').config(); } catch (_) {}
const app = express();
const PORT = process.env.PORT || 3000;
// Airtable config — all values come from environment variables
const AT_TOKEN = process.env.AIRTABLE_TOKEN;
const AT_BASE = process.env.AIRTABLE_BASE_ID || 'applmUty36hRF6e5E';
const AT_TABLE = process.env.AIRTABLE_TABLE_ID || 'tbltATd6SGCEa4vtB';
const AT_VIEW = process.env.AIRTABLE_VIEW_ID || 'viwo6NcgH3ZAeWh1P';
// Fields to request (keeps payload lean)
const AT_FIELDS = [
'Name', 'Cuisine', 'Tags', 'Instructions', 'Ingredients',
'Ingredient Count', 'Thumbnail URL', 'Source URL', 'YouTube URL',
'Source Database', 'Recipe Completeness', 'ID',
];
// ── Static files ──────────────────────────────────────────────────────────────
app.use(express.static(path.join(__dirname)));
// ── /api/recipes (server-side Airtable proxy) ───────────────────────────────
app.get('/api/recipes', async (req, res) => {
if (!AT_TOKEN) {
return res.status(500).json({
error: 'AIRTABLE_TOKEN environment variable is not set.',
});
}
try {
let records = [];
let offset = undefined;
// Airtable pages at 100 records — loop until exhausted
do {
const params = new URLSearchParams({ view: AT_VIEW, pageSize: '100' });
if (offset) params.set('offset', offset);
AT_FIELDS.forEach(f => params.append('fields[]', f));
const upstream = await fetch(
`https://api.airtable.com/v0/${AT_BASE}/${AT_TABLE}?${params}`,
{ headers: { Authorization: `Bearer ${AT_TOKEN}` } }
);
if (!upstream.ok) {
const body = await upstream.json().catch(() => ({}));
return res.status(upstream.status).json({
error: body?.error?.message || `Airtable ${upstream.status}`,
});
}
const data = await upstream.json();
records = records.concat(data.records || []);
offset = data.offset; // undefined when last page
} while (offset);
// Set a short cache header so Railway doesn't hammer Airtable
res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
res.json({ records, total: records.length });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ── /api/ai-recipe (Claude-powered fridge-to-recipe suggestion) ─────────────
app.post('/api/ai-recipe', express.json(), async (req, res) => {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
return res.status(503).json({ error: 'AI suggestions are not configured on this server.' });
}
const { ingredients } = req.body || {};
if (!Array.isArray(ingredients) || ingredients.length === 0) {
return res.status(400).json({ error: 'Please provide a list of ingredients.' });
}
const ingList = ingredients.slice(0, 15).map(i => String(i).trim()).filter(Boolean);
try {
const upstream = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-haiku-4-5',
max_tokens: 1024,
messages: [{
role: 'user',
content: `You are a creative chef helping home cooks use up fridge leftovers.
Available ingredients: ${ingList.join(', ')}
Assume basic pantry staples are available (salt, pepper, olive oil, butter, water) unless they conflict.
Suggest ONE practical, delicious recipe that uses most of these ingredients.
Respond with valid JSON ONLY — no markdown fences, no explanation, just the JSON:
{
"name": "Recipe Name",
"description": "One appetising sentence describing this dish.",
"cuisine": "Cuisine type",
"usedIngredients": ["ingredient1", "ingredient2"],
"extraIngredients": ["any extra ingredient needed beyond pantry staples"],
"steps": ["Step one.", "Step two.", "Step three.", "Step four."],
"tip": "One practical cooking tip for this dish."
}`,
}],
}),
});
if (!upstream.ok) {
const errBody = await upstream.json().catch(() => ({}));
throw new Error(errBody?.error?.message || `Anthropic API ${upstream.status}`);
}
const anthropicData = await upstream.json();
const raw = (anthropicData.content[0].text || '').trim();
const cleaned = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim();
const recipe = JSON.parse(cleaned);
res.json({ recipe });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ── /api/feeling-like (mood → search filters + bespoke recipe) ──────────────
app.post('/api/feeling-like', express.json(), async (req, res) => {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
return res.status(503).json({ error: 'AI suggestions are not configured on this server.' });
}
const { craving } = req.body || {};
if (!craving || !craving.trim()) {
return res.status(400).json({ error: 'Please describe what you feel like eating.' });
}
try {
const upstream = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-haiku-4-5',
max_tokens: 1024,
messages: [{
role: 'user',
content: `You are a food-savvy recipe assistant for Grocer, a premium New Zealand grocery store.
A customer wrote: "${craving.trim().slice(0, 300)}"
Your two tasks:
1. Extract structured search filters to find matching recipes from a database.
The database has: Cuisine (e.g. "Italian"), Tags (e.g. "Pasta", "Vegetarian", "Chicken"), Name (text).
2. Suggest ONE bespoke recipe that perfectly captures this craving.
Respond with valid JSON ONLY — no markdown, no explanation, just the JSON:
{
"cuisines": ["Italian"],
"tags": ["Pasta", "Comfort"],
"keywords": ["warm", "creamy", "hearty"],
"vibe": "Warming & Comforting",
"recipe": {
"name": "Recipe Name",
"description": "One appetising sentence about the dish.",
"cuisine": "Cuisine type",
"ingredients": ["ingredient with quantity", "ingredient"],
"steps": ["Step 1.", "Step 2.", "Step 3.", "Step 4."],
"tip": "One practical cooking tip."
}
}`,
}],
}),
});
if (!upstream.ok) {
const errBody = await upstream.json().catch(() => ({}));
throw new Error(errBody?.error?.message || `Anthropic API ${upstream.status}`);
}
const anthropicData = await upstream.json();
const raw = (anthropicData.content[0].text || '').trim();
const cleaned = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim();
res.json(JSON.parse(cleaned));
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ── Catch-all (lets HTML files serve directly; SPA fallback) ─────────────────
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'index.html'));
});
app.listen(PORT, () => {
console.log(`Grocer running on port ${PORT}`);
});