Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions app/api/interview/[id]/chaos-timeout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from 'next/server';
import dbConnect from '@/src/lib/db/mongoose';
import InterviewSession, { IConstraintChange } from '@/src/lib/db/models/InterviewSession';
import User from '@/src/lib/db/models/User';
import { getAuthenticatedUser } from '@/src/lib/firebase/firebaseAdmin';

export async function POST(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { id } = await context.params;
const body = await req.json();

// Validate request body
const { type, constraintId } = body;
if (!type || (type !== 'warning' && type !== 'penalty')) {
return new Response(JSON.stringify({ error: 'Invalid type. Must be "warning" or "penalty".' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
if (!constraintId || (typeof constraintId !== 'string' && typeof constraintId !== 'number') || constraintId === '') {
return new Response(JSON.stringify({ error: 'Invalid constraintId. Must be a non-empty string or number.' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}

// Authenticate user
const authHeader = req.headers.get('Authorization');
const authenticatedUser = await getAuthenticatedUser(authHeader);

if (!authenticatedUser) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

await dbConnect();

const user = await User.findOne({ firebaseUid: authenticatedUser.uid });
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}

// 1. Fetch Session with ownership check
const session = await InterviewSession.findOne({ id, userId: user._id });
if (!session) {
return NextResponse.json({ error: 'Session not found or access denied' }, { status: 403 });
}
if (session.status !== 'in_progress') {
return NextResponse.json({ success: true, warning: 'Session is no longer in progress' });
}

// 2. Locate Constraint
const constraintChanges = session.constraintChanges || [];
const constraintIndex = constraintChanges.findIndex((c: IConstraintChange) => c.id === constraintId);
if (constraintIndex === -1) {
return NextResponse.json({ error: 'Constraint not found' }, { status: 404 });
}

const constraint = constraintChanges[constraintIndex];

// 3. Early Returns (already addressed or failed)
if (constraint.status === 'addressed' || constraint.failedAt || (type === 'warning' && constraint.overtimeAt)) {
return NextResponse.json({ success: true, warning: 'Already processed' });
}

if (!session.aiMessages) session.aiMessages = [];

// 4. Update state depending on timeout type
if (type === 'warning') {
constraint.overtimeAt = new Date();
session.aiMessages.push({
role: 'interviewer',
content: `Hey, I noticed we still haven't addressed the ${constraint.title} issue. In a real-world scenario, leaving a failure like this unhandled could lead to a broader system outage. Could you walk me through how you'd mitigate this in the next 2 minutes? Let's treat this as a high-priority incident.`,
timestamp: new Date()
});
session.markModified('constraintChanges');
session.markModified('aiMessages');
} else if (type === 'penalty') {
constraint.failedAt = new Date();
constraint.status = 'addressed';
// We keep status as 'addressed' but the failedAt timestamp ensures it's tracked as a failure
session.aiMessages.push({
role: 'interviewer',
content: `Alright, time's up on the ${constraint.title} scenario. Since we weren't able to establish a complete mitigation plan in time, I'll be noting this gap in high-availability planning for the evaluation. That said, let's keep moving forward with the rest of your system design—what were you thinking for the next component?`,
timestamp: new Date()
});
session.markModified('constraintChanges');
session.markModified('aiMessages');
}

await session.save();

return NextResponse.json({ success: true, messages: session.aiMessages, constraintChanges: session.constraintChanges });
} catch (error) {
console.error('Chaos Timeout Error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
55 changes: 55 additions & 0 deletions app/api/interview/[id]/final-validation/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server';
import dbConnect from '@/src/lib/db/mongoose';
import InterviewSession, { IInterviewSession } from '@/src/lib/db/models/InterviewSession';
import User from '@/src/lib/db/models/User';
import { getAuthenticatedUser } from '@/src/lib/firebase/firebaseAdmin';

export async function POST(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
await dbConnect();
const { id } = await context.params;

const authHeader = req.headers.get('Authorization');
const authenticatedUser = await getAuthenticatedUser(authHeader);
if (!authenticatedUser) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const user = await User.findOne({ firebaseUid: authenticatedUser.uid });
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}

const session = await InterviewSession.findOne({ _id: id, userId: user._id });
if (!session) {
return NextResponse.json({ error: 'Session not found' }, { status: 404 });
}

if (!session.aiMessages) session.aiMessages = [];

// Idempotency check: Have we already sent it?
const alreadySent = session.aiMessages.some((m: NonNullable<IInterviewSession['aiMessages']>[number]) =>
m.role === 'interviewer' && m.content.includes("Final Validation Phase")
);

if (alreadySent || session.status !== 'in_progress') {
return NextResponse.json({ success: true, messages: session.aiMessages });
}

session.aiMessages.push({
role: 'interviewer',
content: `**Final Validation Phase**: We have roughly 10 minutes left in the interview! It's time to test your architecture's resiliency. Please turn to the **Simulation Controls** panel, hit 'Run Test', and use the **Target Throughput** slider to simulate traffic. Talk me through how your system behaves under different load scenarios, and point out any bottlenecks.`,
timestamp: new Date()
});
session.markModified('aiMessages');
await session.save();

return NextResponse.json({ success: true, messages: session.aiMessages });
} catch (error) {
console.error('Final Validation Error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
7 changes: 5 additions & 2 deletions app/api/interview/[id]/hint/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,15 @@ function generateConstraintChange(
impactAreas: ['scalability', 'caching', 'load balancing'],
};
} else if ((databaseCount <= 1 || mentionsDisconnected) && difficulty !== 'easy') {
const candidateNodes = nodes.filter((n) => ['SQL', 'Blob', 'Cache', 'Server', 'LB', 'Queue', 'Kafka', 'Function', 'CDN'].includes(n.type));
const victim = candidateNodes.length > 0 ? candidateNodes[Math.floor(Math.random() * candidateNodes.length)] : undefined;
selected = {
type: 'reliability',
title: 'Regional Failover',
description: 'The system must continue serving users during a full regional outage with minimal disruption.',
title: 'Node Failure',
description: victim ? `The ${victim.label || victim.type} component just went offline unexpectedly. The system must continue serving users with minimal disruption.` : 'The system must continue serving users during a full regional outage with minimal disruption.',
severity: difficulty === 'hard' ? 'high' : 'moderate',
impactAreas: ['availability', 'replication', 'disaster recovery'],
impactedNodeId: victim?.id,
};
} else if (!hasQueue && (hasRealtimePrompt || nodeTypes.has('Server') || nodeTypes.has('Function'))) {
selected = {
Expand Down
57 changes: 56 additions & 1 deletion app/interview/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export default function InterviewCanvasPage({ params }: PageProps) {
const [showHints, setShowHints] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [isInterviewPanelOpen, setIsInterviewPanelOpen] = useState(false);
const [finalValidationTriggered, setFinalValidationTriggered] = useState(false);

// Refs for save logic
const isSavingRef = useRef(false);
Expand Down Expand Up @@ -100,6 +101,40 @@ export default function InterviewCanvasPage({ params }: PageProps) {
onConstraintChange: handleConstraintChange
});

const { setMessages } = ai;

// Final Validation Phase Trigger automatically checks and dispatches.
const finalValidationInFlight = useRef(false);
useEffect(() => {
if (finalValidationTriggered || finalValidationInFlight.current) return;
if (timer.minutes === undefined || timer.minutes > 10) return;
if (session?.status !== 'in_progress') return;

finalValidationInFlight.current = true;

authFetch(`/api/interview/${id}/final-validation`, { method: 'POST' })
.then(async res => {
if (!res.ok) {
const errText = await res.text();
throw new Error(errText || res.statusText);
}
return res.json();
})
.then(data => {
setFinalValidationTriggered(true);
if (data.success && data.messages) {
if (setMessages) setMessages(data.messages);
setIsInterviewPanelOpen(true);
}
})
.catch(err => {
console.error('Final validation trigger failed:', err);
})
.finally(() => {
finalValidationInFlight.current = false;
});
}, [timer.minutes, finalValidationTriggered, session?.status, id, setMessages]);

// Fetch session data
const fetchSession = useCallback(async () => {
if (!user?.uid || !id) return;
Expand Down Expand Up @@ -284,6 +319,24 @@ export default function InterviewCanvasPage({ params }: PageProps) {
};
}, [id]);

const handleChaosTimeout = useCallback(async (type: 'warning' | 'penalty', constraintId: string) => {
try {
const res = await authFetch(`/api/interview/${id}/chaos-timeout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, constraintId })
});
const data = await res.json();
if (data.success && data.messages) {
if (setMessages) setMessages(data.messages);
setSession(prev => prev ? { ...prev, constraintChanges: data.constraintChanges } : null);
setIsInterviewPanelOpen(true); // Automatically slide open the interviewer panel
}
} catch (err) {
console.error('Chaos timeout failed:', err);
}
}, [id, setMessages]);

// Loading state
if (authLoading || isLoading) {
return (
Expand Down Expand Up @@ -330,13 +383,14 @@ export default function InterviewCanvasPage({ params }: PageProps) {
<InterviewHeader
difficulty={session.difficulty}
constraintChangeCount={session.constraintChanges?.length || 0}
latestConstraintTitle={session.constraintChanges?.at(-1)?.title}
latestConstraint={session.constraintChanges?.at(-1)}
saveStatus={saveStatus}
timer={timer}
status={session.status}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
sessionId={id}
onChaosTimeout={handleChaosTimeout}
/>

{/* Submit Error Banner */}
Expand Down Expand Up @@ -377,6 +431,7 @@ export default function InterviewCanvasPage({ params }: PageProps) {
onSave={isReadOnly ? undefined : saveDesign}
readOnly={isReadOnly}
stateRef={isReadOnly ? undefined : canvasStateRef}
activeConstraints={session.constraintChanges || []}
/>

{/* Properties Panel */}
Expand Down
Loading
Loading