redesign the homepage
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import type { LeadDestinationAdapter, LeadDestinationResult } from './types';
|
||||
|
||||
export class BlockedLeadDestinationAdapter implements LeadDestinationAdapter {
|
||||
readonly name = 'blocked';
|
||||
|
||||
async submitLead(): Promise<LeadDestinationResult> {
|
||||
return {
|
||||
ok: false,
|
||||
category: 'configuration',
|
||||
retryable: false,
|
||||
code: 'lead_destination_not_approved',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { IntegrationEnvironment } from '@/lib/integrations/environment';
|
||||
import { BlockedLeadDestinationAdapter } from './blocked';
|
||||
import { LocalLeadDestinationAdapter } from './local';
|
||||
import type { LeadDestinationAdapter } from './types';
|
||||
|
||||
export function createLeadDestinationAdapter(
|
||||
environment: IntegrationEnvironment,
|
||||
): LeadDestinationAdapter {
|
||||
const approvedLocalRuntime =
|
||||
environment.NODE_ENV !== 'production' || environment.DEMO_LOCAL_TEST_MODE;
|
||||
if (approvedLocalRuntime && environment.DEMO_SUBMISSION_MODE === 'local') {
|
||||
return new LocalLeadDestinationAdapter();
|
||||
}
|
||||
return new BlockedLeadDestinationAdapter();
|
||||
}
|
||||
|
||||
export type { IntegrationContext, LeadDestinationAdapter, LeadDestinationResult } from './types';
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { LeadDestinationAdapter, LeadDestinationResult } from './types';
|
||||
|
||||
function abortError(): Error {
|
||||
return new DOMException('Operation aborted', 'AbortError');
|
||||
}
|
||||
|
||||
export class LocalLeadDestinationAdapter implements LeadDestinationAdapter {
|
||||
readonly name = 'local-discard';
|
||||
|
||||
async submitLead(
|
||||
_lead: Parameters<LeadDestinationAdapter['submitLead']>[0],
|
||||
context: Parameters<LeadDestinationAdapter['submitLead']>[1],
|
||||
): Promise<LeadDestinationResult> {
|
||||
if (context.signal.aborted) throw abortError();
|
||||
|
||||
switch (context.testScenario) {
|
||||
case 'duplicate':
|
||||
return { ok: false, category: 'duplicate', retryable: false, code: 'local_duplicate' };
|
||||
case 'integration-error':
|
||||
return { ok: false, category: 'integration', retryable: true, code: 'local_error' };
|
||||
case 'timeout':
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, 30_000);
|
||||
context.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
reject(abortError());
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
break;
|
||||
case 'success':
|
||||
case undefined:
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
externalId: `local_${context.correlationId}`,
|
||||
acceptedAt: new Date().toISOString(),
|
||||
mode: 'local',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { DemoLead } from '../schema';
|
||||
|
||||
export interface IntegrationContext {
|
||||
correlationId: string;
|
||||
idempotencyKey: string;
|
||||
signal: AbortSignal;
|
||||
environment: 'development' | 'test' | 'production';
|
||||
testScenario?: 'success' | 'duplicate' | 'timeout' | 'integration-error';
|
||||
}
|
||||
|
||||
export type LeadDestinationResult =
|
||||
| { ok: true; externalId: string; acceptedAt: string; mode: 'local' }
|
||||
| {
|
||||
ok: false;
|
||||
category: 'duplicate' | 'integration' | 'timeout' | 'configuration';
|
||||
retryable: boolean;
|
||||
code: string;
|
||||
};
|
||||
|
||||
export interface LeadDestinationAdapter {
|
||||
readonly name: string;
|
||||
submitLead(lead: DemoLead, context: IntegrationContext): Promise<LeadDestinationResult>;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { DemoSubmissionResult } from './schema';
|
||||
|
||||
interface StoredOperation {
|
||||
expiresAt: number;
|
||||
result: Promise<DemoSubmissionResult>;
|
||||
}
|
||||
|
||||
const globalStore = globalThis as typeof globalThis & {
|
||||
__rdgDemoIdempotencyStore?: Map<string, StoredOperation>;
|
||||
};
|
||||
|
||||
const store = globalStore.__rdgDemoIdempotencyStore ?? new Map<string, StoredOperation>();
|
||||
globalStore.__rdgDemoIdempotencyStore = store;
|
||||
|
||||
function purgeExpired(now: number) {
|
||||
for (const [key, operation] of store) {
|
||||
if (operation.expiresAt <= now) store.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeIdempotent(
|
||||
key: string,
|
||||
ttlSeconds: number,
|
||||
operation: () => Promise<DemoSubmissionResult>,
|
||||
): Promise<DemoSubmissionResult> {
|
||||
const now = Date.now();
|
||||
purgeExpired(now);
|
||||
const existing = store.get(key);
|
||||
if (existing) return existing.result;
|
||||
|
||||
const result = operation();
|
||||
store.set(key, { expiresAt: now + ttlSeconds * 1_000, result });
|
||||
|
||||
try {
|
||||
const settled = await result;
|
||||
if (!settled.ok && settled.retryable) store.delete(key);
|
||||
return settled;
|
||||
} catch (error) {
|
||||
store.delete(key);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearIdempotencyStoreForTests() {
|
||||
store.clear();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
const sensitiveKeys = new Set([
|
||||
'fullname',
|
||||
'name',
|
||||
'workemail',
|
||||
'email',
|
||||
'company',
|
||||
'market',
|
||||
'message',
|
||||
'phone',
|
||||
'fieldvalue',
|
||||
'serverresponse',
|
||||
]);
|
||||
|
||||
export function redactSensitive(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(redactSensitive);
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, child]) => [
|
||||
key,
|
||||
sensitiveKeys.has(key.toLowerCase()) ? '[REDACTED]' : redactSensitive(child),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function containsSensitiveKey(value: unknown): boolean {
|
||||
if (Array.isArray(value)) return value.some(containsSensitiveKey);
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
return Object.entries(value).some(
|
||||
([key, child]) => sensitiveKeys.has(key.toLowerCase()) || containsSensitiveKey(child),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const fleetSizeValues = ['1-9', '10-24', '25-49', '50-99', '100-249', '250+'] as const;
|
||||
export const demoSourceValues = [
|
||||
'header',
|
||||
'mobile-header',
|
||||
'hero',
|
||||
'pricing',
|
||||
'final-cta',
|
||||
'footer',
|
||||
] as const;
|
||||
|
||||
const compactText = (maximum: number) =>
|
||||
z
|
||||
.string()
|
||||
.transform((value) => value.trim().replace(/\s+/g, ' '))
|
||||
.pipe(z.string().min(1, 'required').max(maximum, 'too_long'));
|
||||
|
||||
const optionalCompactText = (maximum: number) =>
|
||||
z
|
||||
.string()
|
||||
.transform((value) => value.trim().replace(/\s+/g, ' '))
|
||||
.pipe(z.string().max(maximum, 'too_long'))
|
||||
.transform((value) => (value.length === 0 ? undefined : value))
|
||||
.optional();
|
||||
|
||||
export const demoLeadSchema = z
|
||||
.object({
|
||||
fullName: compactText(120),
|
||||
workEmail: z
|
||||
.string()
|
||||
.transform((value) => value.trim().toLowerCase())
|
||||
.pipe(
|
||||
z
|
||||
.string()
|
||||
.min(1, 'required')
|
||||
.max(254, 'too_long')
|
||||
.regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, 'invalid_email'),
|
||||
),
|
||||
company: compactText(160),
|
||||
fleetSize: z.enum(fleetSizeValues, { error: 'invalid_fleet_size' }),
|
||||
market: optionalCompactText(100),
|
||||
preferredLanguage: z.enum(['en', 'fr', 'ar']).optional(),
|
||||
message: optionalCompactText(1_000),
|
||||
idempotencyKey: z.string().uuid('invalid_idempotency_key'),
|
||||
source: z.enum(demoSourceValues, { error: 'invalid_source' }),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const demoSubmissionEnvelopeSchema = z
|
||||
.object({
|
||||
lead: demoLeadSchema,
|
||||
guard: z
|
||||
.object({
|
||||
honeypot: z.string().max(200).default(''),
|
||||
startedAtMs: z.number().int().nonnegative(),
|
||||
})
|
||||
.strict(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type DemoLeadInput = z.input<typeof demoLeadSchema>;
|
||||
export type DemoLead = z.output<typeof demoLeadSchema>;
|
||||
export type DemoSubmissionEnvelope = z.output<typeof demoSubmissionEnvelopeSchema>;
|
||||
export type DemoSource = (typeof demoSourceValues)[number];
|
||||
|
||||
export type DemoFailureCategory =
|
||||
| 'validation'
|
||||
| 'consent'
|
||||
| 'duplicate'
|
||||
| 'rate-limit'
|
||||
| 'integration'
|
||||
| 'timeout'
|
||||
| 'configuration'
|
||||
| 'abuse';
|
||||
|
||||
export type DemoSubmissionResult =
|
||||
| {
|
||||
ok: true;
|
||||
submissionId: string;
|
||||
acceptedAt: string;
|
||||
nextAction: { type: 'success'; mode: 'local' };
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
category: DemoFailureCategory;
|
||||
fieldErrors?: Record<string, string>;
|
||||
formErrorCode: string;
|
||||
retryable: boolean;
|
||||
correlationId?: string;
|
||||
};
|
||||
|
||||
export function zodFieldErrors(error: z.ZodError): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const issue of error.issues) {
|
||||
const path = issue.path.at(-1);
|
||||
if (typeof path !== 'string' || result[path]) continue;
|
||||
result[path] = issue.message;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { IntegrationEnvironment } from '@/lib/integrations/environment';
|
||||
import { createLeadDestinationAdapter, type LeadDestinationAdapter } from './adapters';
|
||||
import { executeIdempotent } from './idempotency';
|
||||
import type { DemoLead, DemoSubmissionResult } from './schema';
|
||||
|
||||
export interface SubmitDemoOptions {
|
||||
environment: IntegrationEnvironment;
|
||||
adapter?: LeadDestinationAdapter;
|
||||
testScenario?: 'success' | 'duplicate' | 'timeout' | 'integration-error';
|
||||
}
|
||||
|
||||
export async function submitDemoLead(
|
||||
lead: DemoLead,
|
||||
options: SubmitDemoOptions,
|
||||
): Promise<DemoSubmissionResult> {
|
||||
const { environment } = options;
|
||||
const adapter = options.adapter ?? createLeadDestinationAdapter(environment);
|
||||
|
||||
return executeIdempotent(
|
||||
lead.idempotencyKey,
|
||||
environment.DEMO_IDEMPOTENCY_TTL_SECONDS,
|
||||
async () => {
|
||||
const correlationId = randomUUID();
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), environment.DEMO_REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const destinationResult = await adapter.submitLead(lead, {
|
||||
correlationId,
|
||||
idempotencyKey: lead.idempotencyKey,
|
||||
signal: controller.signal,
|
||||
environment: environment.NODE_ENV,
|
||||
...(options.testScenario ? { testScenario: options.testScenario } : {}),
|
||||
});
|
||||
|
||||
if (!destinationResult.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
category: destinationResult.category,
|
||||
formErrorCode: destinationResult.code,
|
||||
retryable: destinationResult.retryable,
|
||||
correlationId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
submissionId: destinationResult.externalId,
|
||||
acceptedAt: destinationResult.acceptedAt,
|
||||
nextAction: { type: 'success', mode: destinationResult.mode },
|
||||
};
|
||||
} catch (error) {
|
||||
const timedOut =
|
||||
controller.signal.aborted ||
|
||||
(error instanceof DOMException && error.name === 'AbortError');
|
||||
return {
|
||||
ok: false,
|
||||
category: timedOut ? 'timeout' : 'integration',
|
||||
formErrorCode: timedOut ? 'lead_destination_timeout' : 'lead_destination_failure',
|
||||
retryable: true,
|
||||
correlationId,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user