Skip to content

Commit 943f4ee

Browse files
committed
uppp
1 parent 23f7b1d commit 943f4ee

6 files changed

Lines changed: 2206 additions & 0 deletions

File tree

src/app/shop/newsletter/actions.ts

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
'use server';
2+
3+
/**
4+
* Newsletter Subscription Server Actions
5+
*
6+
* Implements newsletter subscription flow with:
7+
* - Email validation via Zod
8+
* - Rate limiting (100 req/min per IP)
9+
* - Consent recording (honors DNT)
10+
* - Single opt-in activation
11+
* - Audit trail
12+
* - Deduplication by (storeId, email)
13+
*
14+
* @module app/shop/newsletter/actions
15+
*/
16+
17+
import { z } from 'zod';
18+
import { headers } from 'next/headers';
19+
import { NewsletterService } from '@/services/newsletter-service';
20+
import { checkSimpleRateLimit, getClientIp } from '@/lib/simple-rate-limit';
21+
import { RateLimitError } from '@/lib/errors';
22+
23+
/**
24+
* Newsletter subscription schema
25+
*/
26+
const newsletterSchema = z.object({
27+
email: z
28+
.string()
29+
.email('Please enter a valid email address')
30+
.min(3, 'Email is too short')
31+
.max(255, 'Email is too long')
32+
.toLowerCase()
33+
.trim(),
34+
storeId: z.string().min(1, 'Store ID is required'),
35+
source: z.string().optional().default('newsletter-form'),
36+
});
37+
38+
/**
39+
* Server Action result type
40+
*/
41+
export interface NewsletterActionResult {
42+
success: boolean;
43+
message: string;
44+
alreadySubscribed?: boolean;
45+
error?: {
46+
code: string;
47+
message: string;
48+
details?: Record<string, string[]>;
49+
};
50+
}
51+
52+
/**
53+
* Subscribe to newsletter
54+
*
55+
* Server Action for newsletter subscriptions with full validation,
56+
* rate limiting, consent recording, and audit logging.
57+
*
58+
* @param formData - Form data containing email and storeId
59+
* @returns Result object with success status and message
60+
*
61+
* @example
62+
* ```tsx
63+
* 'use client';
64+
*
65+
* import { useFormState } from 'react-dom';
66+
* import { subscribeToNewsletter } from './actions';
67+
*
68+
* export function NewsletterForm() {
69+
* const [state, formAction] = useFormState(subscribeToNewsletter, null);
70+
*
71+
* return (
72+
* <form action={formAction}>
73+
* <input name="email" type="email" required />
74+
* <input name="storeId" type="hidden" value={storeId} />
75+
* <button type="submit">Subscribe</button>
76+
* {state?.error && <p>{state.error.message}</p>}
77+
* {state?.success && <p>{state.message}</p>}
78+
* </form>
79+
* );
80+
* }
81+
* ```
82+
*/
83+
export async function subscribeToNewsletter(
84+
_prevState: NewsletterActionResult | null,
85+
formData: FormData
86+
): Promise<NewsletterActionResult> {
87+
try {
88+
// 1. Rate limiting check
89+
const headersList = await headers();
90+
const request = new Request('http://localhost', {
91+
headers: headersList,
92+
});
93+
94+
const rateLimitResult = checkSimpleRateLimit(request);
95+
96+
if (!rateLimitResult.success) {
97+
throw new RateLimitError(
98+
'Too many subscription attempts. Please try again later.',
99+
rateLimitResult.retryAfter
100+
);
101+
}
102+
103+
// 2. Extract and validate form data
104+
const rawData = {
105+
email: formData.get('email'),
106+
storeId: formData.get('storeId'),
107+
source: formData.get('source') || 'newsletter-form',
108+
};
109+
110+
const validation = newsletterSchema.safeParse(rawData);
111+
112+
if (!validation.success) {
113+
const fieldErrors: Record<string, string[]> = {};
114+
validation.error.errors.forEach((err) => {
115+
const field = err.path[0]?.toString() || 'unknown';
116+
if (!fieldErrors[field]) {
117+
fieldErrors[field] = [];
118+
}
119+
fieldErrors[field].push(err.message);
120+
});
121+
122+
return {
123+
success: false,
124+
message: 'Please correct the errors in the form',
125+
error: {
126+
code: 'VALIDATION_ERROR',
127+
message: 'Invalid form data',
128+
details: fieldErrors,
129+
},
130+
};
131+
}
132+
133+
const { email, storeId, source } = validation.data;
134+
135+
// 3. Get request metadata for consent/audit
136+
const ipAddress = getClientIp(request);
137+
const userAgent = headersList.get('user-agent') || undefined;
138+
const dnt = headersList.get('dnt') === '1'; // Do Not Track header
139+
140+
// 4. Subscribe via newsletter service
141+
const result = await NewsletterService.subscribe({
142+
email,
143+
storeId,
144+
source,
145+
metadata: {
146+
ipAddress,
147+
userAgent,
148+
dnt,
149+
},
150+
});
151+
152+
// 5. Return success result
153+
if (result.alreadySubscribed) {
154+
return {
155+
success: true,
156+
message: 'You are already subscribed to our newsletter',
157+
alreadySubscribed: true,
158+
};
159+
}
160+
161+
return {
162+
success: true,
163+
message: 'Successfully subscribed! Thank you for joining our newsletter.',
164+
};
165+
} catch (error) {
166+
// Handle known errors
167+
if (error instanceof RateLimitError) {
168+
return {
169+
success: false,
170+
message: error.message,
171+
error: {
172+
code: 'RATE_LIMIT_EXCEEDED',
173+
message: error.message,
174+
},
175+
};
176+
}
177+
178+
// Handle unexpected errors
179+
console.error('Newsletter subscription error:', error);
180+
181+
return {
182+
success: false,
183+
message: 'An error occurred while processing your subscription. Please try again.',
184+
error: {
185+
code: 'INTERNAL_ERROR',
186+
message: 'Failed to process subscription',
187+
},
188+
};
189+
}
190+
}
191+
192+
/**
193+
* Unsubscribe from newsletter
194+
*
195+
* Server Action for newsletter unsubscriptions with audit logging.
196+
*
197+
* @param formData - Form data containing email and storeId
198+
* @returns Result object with success status and message
199+
*/
200+
export async function unsubscribeFromNewsletter(
201+
_prevState: NewsletterActionResult | null,
202+
formData: FormData
203+
): Promise<NewsletterActionResult> {
204+
try {
205+
// 1. Rate limiting check
206+
const headersList = await headers();
207+
const request = new Request('http://localhost', {
208+
headers: headersList,
209+
});
210+
211+
const rateLimitResult = checkSimpleRateLimit(request);
212+
213+
if (!rateLimitResult.success) {
214+
throw new RateLimitError(
215+
'Too many unsubscribe attempts. Please try again later.',
216+
rateLimitResult.retryAfter
217+
);
218+
}
219+
220+
// 2. Validate form data
221+
const email = formData.get('email')?.toString();
222+
const storeId = formData.get('storeId')?.toString();
223+
224+
if (!email || !storeId) {
225+
return {
226+
success: false,
227+
message: 'Email and store ID are required',
228+
error: {
229+
code: 'VALIDATION_ERROR',
230+
message: 'Missing required fields',
231+
},
232+
};
233+
}
234+
235+
// 3. Get request metadata for audit
236+
const ipAddress = getClientIp(request);
237+
const userAgent = headersList.get('user-agent') || undefined;
238+
239+
// 4. Unsubscribe via newsletter service
240+
await NewsletterService.unsubscribe({
241+
email,
242+
storeId,
243+
metadata: {
244+
ipAddress,
245+
userAgent,
246+
},
247+
});
248+
249+
return {
250+
success: true,
251+
message: 'You have been unsubscribed from our newsletter',
252+
};
253+
} catch (error) {
254+
console.error('Newsletter unsubscription error:', error);
255+
256+
return {
257+
success: false,
258+
message: 'An error occurred while processing your unsubscription. Please try again.',
259+
error: {
260+
code: 'INTERNAL_ERROR',
261+
message: 'Failed to process unsubscription',
262+
},
263+
};
264+
}
265+
}

0 commit comments

Comments
 (0)