142 lines
3.6 KiB
TypeScript
142 lines
3.6 KiB
TypeScript
import * as z from 'zod';
|
|
|
|
import {
|
|
apiError,
|
|
apiSuccess,
|
|
emailSchema,
|
|
requiredString,
|
|
} from '@kit/next/route-helpers';
|
|
import { checkRateLimit, getClientIp } from '@kit/next/routes/rate-limit';
|
|
import { getLogger } from '@kit/shared/logger';
|
|
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
|
|
|
const MembershipApplySchema = z.object({
|
|
accountId: requiredString('Konto-ID'),
|
|
firstName: requiredString('Vorname'),
|
|
lastName: requiredString('Nachname'),
|
|
email: emailSchema,
|
|
phone: z.string().optional(),
|
|
street: z.string().optional(),
|
|
postalCode: z.string().optional(),
|
|
city: z.string().optional(),
|
|
dateOfBirth: z.string().optional(),
|
|
message: z.string().optional(),
|
|
captchaToken: z.string().optional(),
|
|
});
|
|
|
|
// Rate limits
|
|
const MAX_PER_IP = 5;
|
|
const MAX_PER_ACCOUNT = 20;
|
|
const WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
|
|
|
export async function POST(request: Request) {
|
|
const logger = await getLogger();
|
|
|
|
try {
|
|
// Rate limit by IP
|
|
const ip = getClientIp(request);
|
|
const ipLimit = checkRateLimit(
|
|
`membership-apply:ip:${ip}`,
|
|
MAX_PER_IP,
|
|
WINDOW_MS,
|
|
);
|
|
|
|
if (!ipLimit.allowed) {
|
|
return apiError(
|
|
'Zu viele Anfragen. Bitte versuchen Sie es später erneut.',
|
|
429,
|
|
);
|
|
}
|
|
|
|
const body = await request.json();
|
|
const parsed = MembershipApplySchema.safeParse(body);
|
|
|
|
if (!parsed.success) {
|
|
return apiError(parsed.error.issues[0]?.message ?? 'Ungültige Eingabe');
|
|
}
|
|
|
|
const {
|
|
accountId,
|
|
firstName,
|
|
lastName,
|
|
email,
|
|
phone,
|
|
street,
|
|
postalCode,
|
|
city,
|
|
dateOfBirth,
|
|
message,
|
|
captchaToken,
|
|
} = parsed.data;
|
|
|
|
// Rate limit by account
|
|
const accountLimit = checkRateLimit(
|
|
`membership-apply:account:${accountId}`,
|
|
MAX_PER_ACCOUNT,
|
|
WINDOW_MS,
|
|
);
|
|
|
|
if (!accountLimit.allowed) {
|
|
return apiError('Zu viele Bewerbungen für diese Organisation.', 429);
|
|
}
|
|
|
|
// Verify CAPTCHA when configured — token is required, not optional
|
|
if (process.env.CAPTCHA_SECRET_TOKEN) {
|
|
if (!captchaToken) {
|
|
return apiError('CAPTCHA-Überprüfung erforderlich.', 400);
|
|
}
|
|
|
|
const { verifyCaptchaToken } = await import('@kit/auth/captcha/server');
|
|
|
|
try {
|
|
await verifyCaptchaToken(captchaToken);
|
|
} catch {
|
|
return apiError('CAPTCHA-Überprüfung fehlgeschlagen.', 400);
|
|
}
|
|
}
|
|
|
|
const supabase = getSupabaseServerAdminClient();
|
|
|
|
// Validate that the account exists before inserting
|
|
const { data: account } = await supabase
|
|
.from('accounts')
|
|
.select('id')
|
|
.eq('id', accountId)
|
|
.single();
|
|
|
|
if (!account) {
|
|
return apiError('Ungültige Organisation.', 400);
|
|
}
|
|
|
|
const { error } = await supabase.from('membership_applications').insert({
|
|
account_id: accountId,
|
|
first_name: firstName,
|
|
last_name: lastName,
|
|
email,
|
|
phone: phone || null,
|
|
street: street || null,
|
|
postal_code: postalCode || null,
|
|
city: city || null,
|
|
date_of_birth: dateOfBirth || null,
|
|
message: message || null,
|
|
status: 'submitted',
|
|
});
|
|
|
|
if (error) {
|
|
logger.error(
|
|
{ error, context: 'membership-apply-insert' },
|
|
'[membership-apply] Insert error',
|
|
);
|
|
return apiError('Bewerbung fehlgeschlagen', 500);
|
|
}
|
|
|
|
return apiSuccess({ message: 'Bewerbung erfolgreich eingereicht' });
|
|
} catch (err) {
|
|
logger.error(
|
|
{ error: err, context: 'membership-apply' },
|
|
'[membership-apply] Error',
|
|
);
|
|
return apiError('Serverfehler', 500);
|
|
}
|
|
}
|