Skip to content

Commit 646e57e

Browse files
committed
feat: implement testimonial magic link workflow with Redis-backed rate limiting, email service, and CI pipeline automation
1 parent c2fafe5 commit 646e57e

7 files changed

Lines changed: 166 additions & 93 deletions

File tree

.github/workflows/ci.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,3 @@ jobs:
4141
DATABASE_URL: postgresql://mock:mock@localhost:5432/mock
4242
STRIPE_SECRET_KEY: sk_test_mock
4343
STRIPE_WEBHOOK_SECRET: whsec_mock
44-
UPSTASH_REDIS_REST_URL: https://mock.upstash.io
45-
UPSTASH_REDIS_REST_TOKEN: mock-token

src/app/(dashboard)/testimonials/page.tsx

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ export default function TestimonialsModerationPage() {
139139
setLoadingItems(true);
140140
try {
141141
const widgetsRes = await fetch("/api/widgets");
142-
const widgetsData = await widgetsRes.json();
142+
const widgetsData = await widgetsRes.json().catch(() => ({ widgets: [] }));
143143
if (widgetsData.widgets) {
144144
setWidgetsList(widgetsData.widgets);
145145
if (widgetsData.widgets.length > 0) {
@@ -149,7 +149,7 @@ export default function TestimonialsModerationPage() {
149149
}
150150

151151
const testimonialsRes = await fetch("/api/testimonials");
152-
const testimonialsData = await testimonialsRes.json();
152+
const testimonialsData = await testimonialsRes.json().catch(() => ({ testimonials: [] }));
153153
if (testimonialsData.testimonials) {
154154
setItems(testimonialsData.testimonials);
155155
}
@@ -188,11 +188,15 @@ export default function TestimonialsModerationPage() {
188188
}),
189189
});
190190

191-
const data = await res.json();
191+
const data = await res.json().catch(() => ({
192+
error: `Server responded with HTTP ${res.status}: ${res.statusText || "Unexpected error"}`,
193+
}));
194+
192195
if (res.ok && data.success) {
193-
if (data.devApprovalUrl) {
196+
const linkToCopy = data.approvalUrl || data.devApprovalUrl;
197+
if (linkToCopy) {
194198
try {
195-
await navigator.clipboard.writeText(data.devApprovalUrl);
199+
await navigator.clipboard.writeText(linkToCopy);
196200
} catch (_) {}
197201
}
198202
if (data.emailSent) {
@@ -214,8 +218,8 @@ export default function TestimonialsModerationPage() {
214218
} else {
215219
showToast(data.error || "Failed to send magic link.", "error");
216220
}
217-
} catch {
218-
showToast("Network error while sending magic link.", "error");
221+
} catch (err: any) {
222+
showToast(err?.message || "Network error while sending magic link.", "error");
219223
} finally {
220224
setSubmitting(false);
221225
}
@@ -229,11 +233,14 @@ export default function TestimonialsModerationPage() {
229233
headers: { "Content-Type": "application/json" },
230234
body: JSON.stringify({ testimonialId }),
231235
});
232-
const data = await res.json();
236+
const data = await res.json().catch(() => ({
237+
error: `Server responded with HTTP ${res.status}: ${res.statusText || "Unexpected error"}`,
238+
}));
233239
if (res.ok && data.success) {
234-
if (data.approvalUrl) {
240+
const linkToCopy = data.approvalUrl || data.devApprovalUrl;
241+
if (linkToCopy) {
235242
try {
236-
await navigator.clipboard.writeText(data.approvalUrl);
243+
await navigator.clipboard.writeText(linkToCopy);
237244
} catch (_) {}
238245
}
239246
if (data.emailSent) {
@@ -247,8 +254,8 @@ export default function TestimonialsModerationPage() {
247254
} else {
248255
showToast(data.error || "Failed to resend magic link.", "error");
249256
}
250-
} catch {
251-
showToast("Network error while resending link.", "error");
257+
} catch (err: any) {
258+
showToast(err?.message || "Network error while resending link.", "error");
252259
} finally {
253260
setResendingId(null);
254261
}
@@ -275,7 +282,9 @@ export default function TestimonialsModerationPage() {
275282
}),
276283
});
277284

278-
const data = await res.json();
285+
const data = await res.json().catch(() => ({
286+
error: `Server responded with HTTP ${res.status}: ${res.statusText || "Unexpected error"}`,
287+
}));
279288
if (res.ok && data.success) {
280289
showToast("Offline praise successfully imported!", "success");
281290
setShowImportModal(false);
@@ -286,8 +295,8 @@ export default function TestimonialsModerationPage() {
286295
} else {
287296
showToast(data.error || "Import failed.", "error");
288297
}
289-
} catch {
290-
showToast("Network error while importing praise.", "error");
298+
} catch (err: any) {
299+
showToast(err?.message || "Network error while importing praise.", "error");
291300
} finally {
292301
setSubmitting(false);
293302
}

src/app/api/testimonials/magic-link/route.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,16 +80,22 @@ export async function POST(req: Request) {
8080
return t;
8181
});
8282

83-
// Send email with raw token link
84-
const emailResult = await sendMagicLinkApprovalEmail({
85-
toEmail: data.clientEmail,
86-
creatorName: user.user_metadata?.name || user.email || "Freelancer",
87-
creatorEmail: user.email || undefined,
88-
rawToken,
89-
promptMessage: cleanPrompt,
90-
});
83+
// Send email with raw token link in a safe try-catch
84+
let emailResult: { success: boolean; error?: string } = { success: false };
85+
try {
86+
emailResult = await sendMagicLinkApprovalEmail({
87+
toEmail: data.clientEmail,
88+
creatorName: user.user_metadata?.name || user.email || "Freelancer",
89+
creatorEmail: user.email || undefined,
90+
rawToken,
91+
promptMessage: cleanPrompt,
92+
});
93+
} catch (emailErr: any) {
94+
console.error("[MAGIC_LINK_EMAIL_DISPATCH_ERROR]", emailErr);
95+
emailResult = { success: false, error: emailErr?.message || "Failed to dispatch email" };
96+
}
9197

92-
// Construct approval URL for dev mode fallback
98+
// Construct approval URL for creator reference and clipboard copy
9399
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
94100
const approvalUrl = `${appUrl}/approve-testimonial?token=${encodeURIComponent(rawToken)}`;
95101
console.log(`\n=========================================\n[DEV MAGIC LINK GENERATED]\nRecipient: ${data.clientEmail}\nApproval URL: ${approvalUrl}\n=========================================\n`);
@@ -98,6 +104,8 @@ export async function POST(req: Request) {
98104
success: true,
99105
testimonialId: newTestimonial.id,
100106
emailSent: emailResult.success,
107+
emailError: emailResult.error || null,
108+
approvalUrl,
101109
devApprovalUrl: approvalUrl,
102110
});
103111
} catch (error: any) {

src/app/api/testimonials/resend-magic-link/route.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -76,14 +76,20 @@ export async function POST(req: Request) {
7676
const meta = (testimonial.metadata || {}) as Record<string, any>;
7777
const promptMessage = meta.promptMessage || undefined;
7878

79-
// Send email with new token
80-
const emailResult = await sendMagicLinkApprovalEmail({
81-
toEmail: clientEmail,
82-
creatorName: user.user_metadata?.name || user.email || "Freelancer",
83-
creatorEmail: user.email || undefined,
84-
rawToken,
85-
promptMessage,
86-
});
79+
// Send email with new token in a safe try-catch
80+
let emailResult: { success: boolean; error?: string } = { success: false };
81+
try {
82+
emailResult = await sendMagicLinkApprovalEmail({
83+
toEmail: clientEmail,
84+
creatorName: user.user_metadata?.name || user.email || "Freelancer",
85+
creatorEmail: user.email || undefined,
86+
rawToken,
87+
promptMessage,
88+
});
89+
} catch (emailErr: any) {
90+
console.error("[RESEND_MAGIC_LINK_EMAIL_DISPATCH_ERROR]", emailErr);
91+
emailResult = { success: false, error: emailErr?.message || "Failed to dispatch email" };
92+
}
8793

8894
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
8995
const approvalUrl = `${appUrl}/approve-testimonial?token=${encodeURIComponent(rawToken)}`;
@@ -93,6 +99,7 @@ export async function POST(req: Request) {
9399
emailSent: emailResult.success,
94100
emailError: emailResult.error || null,
95101
approvalUrl,
102+
devApprovalUrl: approvalUrl,
96103
});
97104
} catch (error: any) {
98105
console.error("Resend magic link error:", error);

src/lib/cache/redis.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import { Redis } from "@upstash/redis";
22

3-
let redis: Redis | null = null;
3+
const isMockRedis =
4+
!process.env.UPSTASH_REDIS_REST_URL ||
5+
process.env.UPSTASH_REDIS_REST_URL.includes("mock") ||
6+
process.env.NODE_ENV === "test";
47

5-
if (process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN) {
6-
redis = Redis.fromEnv();
8+
if (!isMockRedis && process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN) {
9+
try {
10+
redis = Redis.fromEnv();
11+
} catch (err) {
12+
console.error("[REDIS_INIT_ERROR] Failed to initialize Upstash Redis client:", err);
13+
}
714
}
815

916
const CACHE_TTL_SECONDS = 60 * 60 * 24; // 24 hours fallback TTL

src/lib/email/index.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ function getSmtpTransporter() {
1919
tls: {
2020
rejectUnauthorized: false,
2121
},
22+
connectionTimeout: 5000, // 5s connection timeout to avoid hanging
23+
greetingTimeout: 4000, // 4s greeting timeout
24+
socketTimeout: 6000, // 6s socket timeout
2225
});
2326
}
2427
return null;
@@ -40,6 +43,7 @@ export function getFromAddress(): string {
4043
/**
4144
* Universal email dispatcher: routes through Gmail SMTP if configured,
4245
* otherwise falls back to Resend API.
46+
* Uses strict timeouts to guarantee non-blocking execution.
4347
*/
4448
async function sendEmailMessage(options: {
4549
to: string;
@@ -55,7 +59,7 @@ async function sendEmailMessage(options: {
5559
// 1. Send via Gmail SMTP if configured (100% Primary Inbox Delivery)
5660
if (transporter) {
5761
try {
58-
await transporter.sendMail({
62+
const sendPromise = transporter.sendMail({
5963
from: fromAddress,
6064
to: options.to,
6165
subject: options.subject,
@@ -64,6 +68,17 @@ async function sendEmailMessage(options: {
6468
replyTo: options.replyTo,
6569
headers: options.headers,
6670
});
71+
72+
const timeoutPromise = new Promise<{ timeout: true }>((resolve) =>
73+
setTimeout(() => resolve({ timeout: true }), 6000)
74+
);
75+
76+
const result = await Promise.race([sendPromise, timeoutPromise]);
77+
if (result && "timeout" in result) {
78+
logger.error(`[GMAIL_SMTP_TIMEOUT] Timeout while sending email to ${options.to}`);
79+
return { success: false, error: "SMTP connection timed out" };
80+
}
81+
6782
logger.info(`[GMAIL_SMTP] Email delivered to ${options.to}: [${options.subject}]`);
6883
return { success: true };
6984
} catch (err: any) {
@@ -75,7 +90,7 @@ async function sendEmailMessage(options: {
7590
// 2. Send via Resend API
7691
if (resend) {
7792
try {
78-
await resend.emails.send({
93+
const sendPromise = resend.emails.send({
7994
from: fromAddress,
8095
to: options.to,
8196
subject: options.subject,
@@ -84,6 +99,17 @@ async function sendEmailMessage(options: {
8499
replyTo: options.replyTo,
85100
headers: options.headers,
86101
});
102+
103+
const timeoutPromise = new Promise<{ timeout: true }>((resolve) =>
104+
setTimeout(() => resolve({ timeout: true }), 6000)
105+
);
106+
107+
const result = await Promise.race([sendPromise, timeoutPromise]);
108+
if (result && "timeout" in result) {
109+
logger.error(`[RESEND_TIMEOUT] Timeout while sending email via Resend to ${options.to}`);
110+
return { success: false, error: "Resend API request timed out" };
111+
}
112+
87113
return { success: true };
88114
} catch (err: any) {
89115
const errMsg = err?.message || String(err);

0 commit comments

Comments
 (0)