-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
289 lines (244 loc) · 11.9 KB
/
server.js
File metadata and controls
289 lines (244 loc) · 11.9 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
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
const express = require("express");
const cors = require("cors");
const rateLimit = require("express-rate-limit");
const Airtable = require("airtable");
const Anthropic = require("@anthropic-ai/sdk");
const { Resend } = require("resend");
const path = require("path");
const app = express();
const PORT = process.env.PORT || 3000;
app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, "public")));
const growthLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 5,
message: { success: false, error: "Too many report requests. Please try again in an hour." },
});
const signupLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: { success: false, error: "Too many requests, please try again later." },
});
const base = process.env.AIRTABLE_API_KEY && process.env.AIRTABLE_BASE_ID
? new Airtable({ apiKey: process.env.AIRTABLE_API_KEY }).base(process.env.AIRTABLE_BASE_ID)
: null;
const anthropic = process.env.ANTHROPIC_API_KEY
? new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
: null;
const resend = process.env.RESEND_API_KEY
? new Resend(process.env.RESEND_API_KEY)
: null;
app.post("/api/signup", signupLimiter, async (req, res) => {
const { name, email, organisation, role } = req.body;
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return res.status(400).json({ success: false, error: "A valid email address is required." });
}
if (!base) {
console.warn("Airtable not configured — signup received:", { name, email, organisation, role });
return res.json({ success: true, message: "Kia ora! You're on the list." });
}
try {
await base("Waitlist").create([{
fields: {
Name: name || "",
Email: email,
Organisation: organisation || "",
"Interest Notes": role || "",
Signup_Date: new Date().toISOString().split("T")[0],
Source: "Landing Page",
"Signup Status": "Signed Up",
},
}]);
res.json({ success: true, message: "Kia ora! You're on the list." });
} catch (err) {
console.error("Airtable error:", err.message);
res.status(500).json({ success: false, error: "Something went wrong. Please try again." });
}
});
async function saveGrowthLead(business, targets, proc, bottlenecks) {
if (!base) return null;
try {
const records = await base("Growth Leads").create([{
fields: {
"Business Name": business.name || "",
"Email": business.email || "",
"Industry": business.industry || "",
"Revenue": business.revenue || "",
"Team Size": business.teamSize || "",
"Products Services": business.products || "",
"Target 1yr": targets.oneYear || "",
"Target 5yr": targets.fiveYear || "",
"Target 10yr": targets.tenYear || "",
"Production Goals": targets.productionGoals || "",
"Process": proc.description || "",
"Equipment": proc.workflow || "",
"Bottlenecks": bottlenecks.selected?.join("; ") || "",
"Bottleneck Details": bottlenecks.freeText || "",
"Report Date": new Date().toISOString().split("T")[0],
"Source": "Growth Report",
},
}]);
return records[0].id;
} catch (err) {
console.error("Airtable lead save error:", err.message);
return null;
}
}
async function updateLeadEmail(leadId, email) {
if (!base || !leadId) return;
try {
await base("Growth Leads").update(leadId, { Email: email });
} catch (err) {
console.error("Airtable email update error:", err.message);
}
}
// In-memory cache for approved hero images { slug: { url, expires } }
const heroCache = new Map();
const HERO_TTL = 30 * 60 * 1000; // 30 minutes
const MODULE_TITLES = {
market: "Market", lab: "Lab", care: "Care", safe: "Safe",
learn: "Learn", loop: "Loop", finance: "Finance", sure: "Sure",
};
app.get("/api/cms/hero/:module", async (req, res) => {
const slug = req.params.module.toLowerCase();
const titlePrefix = MODULE_TITLES[slug];
if (!titlePrefix) return res.status(400).json({ url: null });
const cached = heroCache.get(slug);
if (cached && cached.expires > Date.now()) return res.json({ url: cached.url });
if (!base) return res.json({ url: null });
try {
const records = await base("tblkeVxCi6PAsWipB").select({
filterByFormula: `AND({Image approved}="Yes", FIND("${titlePrefix}", {Image Title}))`,
maxRecords: 1,
fields: ["Image Title", "Image (Generated)"],
}).firstPage();
const attachments = records[0]?.get("Image (Generated)");
const url = attachments?.[0]?.url || null;
heroCache.set(slug, { url, expires: Date.now() + HERO_TTL });
res.json({ url });
} catch (err) {
console.error("CMS hero error:", err.message);
res.json({ url: null });
}
});
app.post("/api/growth-report", growthLimiter, async (req, res) => {
const { business, targets, process: proc, bottlenecks } = req.body;
if (!business?.industry || !business?.products) {
return res.status(400).json({ success: false, error: "Missing required business profile fields." });
}
if (!anthropic) {
return res.status(503).json({ success: false, error: "Report generation is not yet configured — please add ANTHROPIC_API_KEY." });
}
const prompt = `You are a senior business growth consultant specialising in NZ industrial, agricultural, food processing, and production businesses. A business owner has shared their profile, growth goals, and constraints. Generate a professional, personalised growth report in HTML.
Use only semantic HTML with inline styles. For h2 headings use: style="color:#0D2B4E;border-bottom:2px solid #00A99D;padding-bottom:8px;margin:28px 0 14px;font-size:1.2rem;font-family:Arial,sans-serif". For module recommendation cards use: style="border-left:4px solid #00A99D;padding:16px 20px;margin:12px 0;border-radius:6px;background:#f0f9f8;". Body text in #1a1a1a, font-family Arial.
---
BUSINESS PROFILE:
Name: ${business.name || "Not provided"}
Industry: ${business.industry}
Current revenue: ${business.revenue || "Not specified"}
Team size: ${business.teamSize || "Not specified"}
Products/services: ${business.products}
GROWTH TARGETS:
1-year: ${targets.oneYear || "Not specified"}
5-year: ${targets.fiveYear || "Not specified"}
10-year: ${targets.tenYear || "Not specified"}
Production/operational goals: ${targets.productionGoals || "Not specified"}
PROCESS:
${proc.description}
Equipment/infrastructure: ${proc.workflow || "Not specified"}
BOTTLENECKS:
Selected: ${bottlenecks.selected?.join("; ") || "None selected"}
Details: ${bottlenecks.freeText || "None"}
---
Generate these sections:
<h2 ...>Executive Summary</h2>
2-3 paragraphs. Acknowledge their current position, recognise their ambition, and frame how the right infrastructure unlocks their targets. Be specific to their industry and NZ context.
<h2 ...>Growth Gap Analysis</h2>
Analyse what needs to change at each time horizon (1yr, 5yr, 10yr) — operationally, in capacity, equipment, and capability. Be concrete and specific.
<h2 ...>Bottleneck Assessment</h2>
Assess each bottleneck they identified. Why does it exist in NZ's context? What is the downstream impact on their targets? What category of solution is required?
<h2 ...>Your equipA Roadmap</h2>
For each genuinely relevant module, create a recommendation card with the module name as a bold heading, a one-line relevance statement, and 2-3 specific actions. Only include modules that directly address their situation. Available modules:
- Market (Marketplace): source new, used, refurbished equipment from NZ dealers and fleets
- Lab (Shared Access): access R&D and testing equipment at CRIs and universities
- Care (Maintenance): lifecycle management, servicing, calibration, heavy rigging
- Safe (Compliance): WorkSafe NZ, LEENZ, custom SOPs and safety documentation
- Learn (Knowledge): operator training, knowledge transfer, machine-specific guides
- Loop (Circularity): trade-ins, refurbishment, parts salvaging, carbon tracking
- Finance (Finance): lease-to-own, fractional ownership, structured payment plans
- Sure (Insurance): transit, mechanical breakdown, liability for industrial equipment
<h2 ...>90-Day Action Plan</h2>
Three phases with specific, actionable steps tied to their situation:
- Days 1–30: Immediate assessment and quick wins
- Days 31–60: Procurement, process changes, and partnerships
- Days 61–90: Capability building and scaling
Address the owner directly as "you". Reference NZ context (WorkSafe, NZTE, Callaghan Innovation, etc.) where relevant. Total length: approximately 900–1,200 words.`;
try {
const message = await anthropic.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 3500,
messages: [{ role: "user", content: prompt }],
});
const leadId = await saveGrowthLead(business, targets, proc, bottlenecks);
res.json({ success: true, report: message.content[0].text, leadId });
} catch (err) {
console.error("Anthropic API error:", err.message);
res.status(500).json({ success: false, error: "Failed to generate report. Please try again." });
}
});
app.post("/api/send-report", async (req, res) => {
const { email, reportHtml, businessName, leadId } = req.body;
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return res.status(400).json({ success: false, error: "A valid email address is required." });
}
if (!resend) {
return res.status(503).json({ success: false, error: "Email sending is not yet configured." });
}
const date = new Date().toLocaleDateString("en-NZ", { day: "numeric", month: "long", year: "numeric" });
const fromAddress = process.env.RESEND_FROM || "equipA Growth Reports <reports@equipa.kiwi>";
const emailHtml = `<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Your equipA Growth Report</title></head>
<body style="font-family:Arial,sans-serif;background:#f4f4f4;margin:0;padding:24px 0;">
<div style="max-width:760px;margin:0 auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,0.08);">
<div style="background:#0D2B4E;padding:30px 40px;text-align:center;">
<div style="font-size:1.3rem;font-weight:800;letter-spacing:-0.5px;">
<span style="color:#00A99D;">equip</span><span style="color:#fff;">A</span><span style="color:#fff;">.kiwi</span>
</div>
<p style="color:rgba(255,255,255,0.55);margin:8px 0 0;font-size:0.88rem;">Your personalised business growth report</p>
</div>
<div style="padding:32px 40px 0;border-bottom:1px solid #eee;">
<h1 style="color:#0D2B4E;font-size:1.35rem;margin:0 0 6px;">Business Growth Report</h1>
<p style="color:#888;font-size:0.85rem;margin:0 0 24px;">${businessName ? businessName + " · " : ""}Generated ${date}</p>
</div>
<div style="padding:32px 40px;font-size:15px;line-height:1.72;color:#1a1a1a;">
${reportHtml}
</div>
<div style="background:#f8f8f8;padding:24px 40px;border-top:1px solid #eee;text-align:center;">
<p style="color:#888;font-size:0.8rem;margin:0;">Generated by <strong style="color:#0D2B4E;">equipA.kiwi</strong> — Aotearoa's industrial equipment ecosystem</p>
<p style="color:#bbb;font-size:0.75rem;margin:6px 0 0;">equipa.rad.kiwi</p>
</div>
</div>
</body></html>`;
try {
await resend.emails.send({
from: fromAddress,
to: [email],
bcc: ["alex@rad.kiwi"],
subject: `Your equipA Growth Report${businessName ? " — " + businessName : ""}`,
html: emailHtml,
});
await updateLeadEmail(leadId, email);
res.json({ success: true });
} catch (err) {
console.error("Resend error:", err.message);
res.status(500).json({ success: false, error: "Failed to send email. Please download the report instead." });
}
});
app.get("*", (req, res) => {
res.sendFile(path.join(__dirname, "public", "index.html"));
});
app.listen(PORT, () => {
console.log(`equipA.kiwi running on port ${PORT}`);
});