Skip to content

Commit 81f9b8d

Browse files
Session 25: UserProfile+Clerk webhook, commIntegration errors, CodingChallenge page
1 parent fc4c79a commit 81f9b8d

10 files changed

Lines changed: 594 additions & 16 deletions

File tree

apps/api/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import geoFencingRoutes from "./routes/geoFencing";
1313
import videoProctoringRoutes from "./routes/videoProctoring";
1414
import ssoIntegrationRoutes from "./routes/ssoIntegration";
1515
import webhooksRoutes from "./routes/webhooks";
16+
import clerkWebhookRoutes from "./routes/clerkWebhook";
1617
import multiTenancyRoutes from "./routes/multiTenancy";
1718
import { initializeDefaultPlanLimits } from "./lib/multiTenancy";
1819
import jobMatchingRoutes from "./routes/jobMatching";
@@ -336,6 +337,11 @@ app.use(
336337
webhooksRoutes,
337338
);
338339

340+
app.use(
341+
"/api/clerk",
342+
clerkWebhookRoutes,
343+
);
344+
339345
app.use(
340346
"/api/tenants",
341347
multiTenancyRoutes,

apps/api/src/lib/analytics.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { InterviewModel } from "../models/Interview";
22
import { ProctoringSessionModel } from "../models/ProctoringSession";
3-
import { ResumeModel } from "../models/Resume";
3+
import { UserProfileModel } from "../models/UserProfile";
44
import { logger } from "./logger";
55

66
interface InterviewAnalytics {
@@ -194,20 +194,15 @@ export async function getTopPerformers(limit: number = 10): Promise<{ candidateI
194194
.lean();
195195

196196
const userIds = [...new Set(docs.map(d => d.userId))];
197-
const resumes = await ResumeModel.find({ userId: { $in: userIds } }).select("userId fileName").lean();
197+
const profiles = await UserProfileModel.find({ userId: { $in: userIds } }).select("userId name").lean();
198198
const nameMap = new Map<string, string>();
199-
for (const r of resumes) {
200-
const name = r.fileName
201-
.replace(/\.\w+$/, '')
202-
.replace(/[_-]/g, ' ')
203-
.replace(/\b(resume|cv|bio)\b/gi, '')
204-
.trim();
205-
if (name) nameMap.set(r.userId, name);
199+
for (const p of profiles) {
200+
if (p.name) nameMap.set(p.userId, p.name);
206201
}
207202

208203
return docs.map((d) => ({
209204
candidateId: d.userId,
210-
name: nameMap.get(d.userId) || `Candidate ${d.userId.slice(0, 8)}`,
205+
name: nameMap.get(d.userId) || d.userId.slice(0, 8),
211206
score: d.score as number,
212207
trend: 'stable',
213208
}));

apps/api/src/lib/communicationIntegration.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,7 @@ export async function sendSlackMessage(webhookUrl: string, message: SlackMessage
3838
export async function createSlackChannel(name: string, topic?: string): Promise<{ channelId: string; name: string }> {
3939
const token = process.env.SLACK_BOT_TOKEN;
4040
if (!token) {
41-
logger.warn("SLACK_BOT_TOKEN not configured; returning placeholder channel ID");
42-
return { channelId: `C${Date.now()}`, name };
41+
throw new Error("SLACK_BOT_TOKEN not configured — cannot create Slack channel");
4342
}
4443
try {
4544
const res = await axios.post("https://slack.com/api/conversations.create", {
@@ -61,7 +60,7 @@ export async function createSlackChannel(name: string, topic?: string): Promise<
6160
throw new Error(res.data?.error || "Slack API error");
6261
} catch (err: any) {
6362
logger.error({ err: err.message }, "Slack channel creation failed");
64-
return { channelId: `C${Date.now()}`, name };
63+
throw err;
6564
}
6665
}
6766

@@ -305,8 +304,7 @@ export async function createGoogleMeetEvent(config: GoogleMeetConfig, event: Goo
305304
logger.info({ summary: event.summary }, "Creating Google Meet event");
306305
const accessToken = await refreshGoogleAccessToken(config);
307306
if (!accessToken) {
308-
logger.warn("No Google access token; returning placeholder Meet link");
309-
return { meetLink: `https://meet.google.com/abc-${Date.now()}`, eventId: `gcal_${Date.now()}` };
307+
throw new Error("Google OAuth token refresh failed — cannot create Meet event. Ensure valid refreshToken is provided.");
310308
}
311309
try {
312310
const res = await axios.post("https://www.googleapis.com/calendar/v3/calendars/primary/events", {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import axios from "axios";
2+
import { UserProfileModel } from "../models/UserProfile";
3+
import { logger } from "./logger";
4+
5+
const CLERK_SECRET_KEY = process.env.CLERK_SECRET_KEY;
6+
const CLERK_API = "https://api.clerk.com/v1";
7+
8+
export async function syncUserFromClerk(userId: string): Promise<void> {
9+
try {
10+
if (!CLERK_SECRET_KEY) return;
11+
12+
const { data } = await axios.get(`${CLERK_API}/users/${userId}`, {
13+
headers: { Authorization: `Bearer ${CLERK_SECRET_KEY}` },
14+
timeout: 5000,
15+
});
16+
17+
const name = [data.first_name, data.last_name].filter(Boolean).join(" ") || data.username || userId;
18+
const email = data.email_addresses?.[0]?.email_address || "";
19+
const avatarUrl = data.image_url || "";
20+
21+
await UserProfileModel.findOneAndUpdate(
22+
{ userId },
23+
{ name, email, avatarUrl, lastSyncedAt: new Date() },
24+
{ upsert: true, new: true },
25+
);
26+
} catch (err: unknown) {
27+
logger.warn({ err: (err as Error).message, userId }, "Failed to sync user from Clerk");
28+
}
29+
}
30+
31+
export async function getOrCreateUserProfile(userId: string): Promise<{ name: string; email: string }> {
32+
const existing = await UserProfileModel.findOne({ userId }).lean();
33+
if (existing) return { name: existing.name, email: existing.email };
34+
35+
await syncUserFromClerk(userId);
36+
const synced = await UserProfileModel.findOne({ userId }).lean();
37+
return { name: synced?.name || userId, email: synced?.email || "" };
38+
}

apps/api/src/middleware/auth.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ import { requireAuth as clerkRequireAuth } from "@clerk/express";
22
import { Request, Response, NextFunction } from "express";
33
import dotenv from "dotenv";
44
import { logger } from "../lib/logger";
5+
import { syncUserFromClerk } from "../lib/syncUserProfile";
6+
import { UserProfileModel } from "../models/UserProfile";
57

68
dotenv.config();
79

10+
const LazySyncInterval = parseInt(process.env.USER_SYNC_INTERVAL || "3600000");
811

912
export const requireAuth = (
1013
req: Request,
@@ -19,6 +22,21 @@ export const requireAuth = (
1922
.status(401)
2023
.json({ error: "Unauthorized! Please login first." });
2124
}
25+
26+
const userId = (req as any).auth?.userId;
27+
if (userId && process.env.CLERK_SECRET_KEY) {
28+
UserProfileModel.findOne({ userId }).lean()
29+
.then(existing => {
30+
const needsSync = !existing ||
31+
!existing.name ||
32+
Date.now() - new Date(existing.lastSyncedAt).getTime() > LazySyncInterval;
33+
if (needsSync) syncUserFromClerk(userId);
34+
})
35+
.catch(() => {});
36+
}
37+
2238
next();
2339
});
2440
};
41+
42+

apps/api/src/models/UserProfile.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import mongoose from "mongoose";
2+
3+
export interface IUserProfile {
4+
userId: string;
5+
name: string;
6+
email: string;
7+
avatarUrl: string;
8+
role: "candidate" | "interviewer" | "admin";
9+
lastSyncedAt: Date;
10+
createdAt: Date;
11+
updatedAt: Date;
12+
}
13+
14+
const userProfileSchema = new mongoose.Schema({
15+
userId: { type: String, required: true, unique: true, index: true },
16+
name: { type: String, default: "" },
17+
email: { type: String, default: "" },
18+
avatarUrl: { type: String, default: "" },
19+
role: { type: String, enum: ["candidate", "interviewer", "admin"], default: "candidate" },
20+
lastSyncedAt: { type: Date, default: Date.now },
21+
}, { timestamps: true });
22+
23+
export const UserProfileModel = mongoose.model<IUserProfile>("UserProfile", userProfileSchema);
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { Router, Request, Response } from "express";
2+
import { Webhook } from "svix";
3+
import { UserProfileModel } from "../models/UserProfile";
4+
import { logger } from "../lib/logger";
5+
6+
const router = Router();
7+
8+
router.post("/webhook", async (req: Request, res: Response) => {
9+
const secret = process.env.CLERK_WEBHOOK_SECRET;
10+
if (!secret) {
11+
res.status(500).json({ error: "CLERK_WEBHOOK_SECRET not configured" });
12+
return;
13+
}
14+
15+
const svixId = req.headers["svix-id"] as string;
16+
const svixTimestamp = req.headers["svix-timestamp"] as string;
17+
const svixSignature = req.headers["svix-signature"] as string;
18+
19+
if (!svixId || !svixTimestamp || !svixSignature) {
20+
res.status(400).json({ error: "Missing Svix headers" });
21+
return;
22+
}
23+
24+
let payload: any;
25+
try {
26+
const wh = new Webhook(secret);
27+
payload = wh.verify(JSON.stringify(req.body), {
28+
"svix-id": svixId,
29+
"svix-timestamp": svixTimestamp,
30+
"svix-signature": svixSignature,
31+
});
32+
} catch {
33+
res.status(400).json({ error: "Invalid webhook signature" });
34+
return;
35+
}
36+
37+
const eventType = payload.type as string;
38+
const data = payload.data;
39+
40+
if (!data?.id) {
41+
res.status(400).json({ error: "Missing user id in payload" });
42+
return;
43+
}
44+
45+
try {
46+
if (eventType === "user.created" || eventType === "user.updated") {
47+
const name = [data.first_name, data.last_name].filter(Boolean).join(" ") || data.username || data.id;
48+
const email = data.email_addresses?.[0]?.email_address || "";
49+
const avatarUrl = data.image_url || "";
50+
51+
await UserProfileModel.findOneAndUpdate(
52+
{ userId: data.id },
53+
{ name, email, avatarUrl, lastSyncedAt: new Date() },
54+
{ upsert: true, new: true },
55+
);
56+
logger.info({ userId: data.id, name, eventType }, "User profile synced via Clerk webhook");
57+
}
58+
59+
if (eventType === "user.deleted") {
60+
await UserProfileModel.deleteOne({ userId: data.id });
61+
logger.info({ userId: data.id }, "User profile removed via Clerk webhook");
62+
}
63+
64+
res.json({ success: true });
65+
} catch (err) {
66+
logger.error({ err: (err as Error).message, eventType }, "Clerk webhook handler error");
67+
res.status(500).json({ error: "Webhook handler error" });
68+
}
69+
});
70+
71+
export default router;

apps/api/tsconfig.tsbuildinfo

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

apps/web/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import AdminPage from "./pages/AdminPage";
2929
import CandidatePortal from "./pages/CandidatePortal";
3030
import SchedulingPage from "./pages/SchedulingPage";
3131
import QuestionBankPage from "./pages/QuestionBankPage";
32+
import CodingChallengePage from "./pages/CodingChallengePage";
3233
import ReportsPage from "./pages/ReportsPage";
3334
import PreparationPage from "./pages/PreparationPage";
3435
import PipelinePage from "./pages/PipelinePage";
@@ -193,6 +194,7 @@ function App() {
193194
<Route path="/candidate-portal" element={<ProtectedRoute><CandidatePortal /></ProtectedRoute>} />
194195
<Route path="/scheduling" element={<ProtectedRoute><SchedulingPage /></ProtectedRoute>} />
195196
<Route path="/questions" element={<ProtectedRoute><QuestionBankPage /></ProtectedRoute>} />
197+
<Route path="/coding-challenge/:id" element={<ProtectedRoute><CodingChallengePage /></ProtectedRoute>} />
196198
<Route path="/reports" element={<ProtectedRoute><ReportsPage /></ProtectedRoute>} />
197199
<Route path="/preparation" element={<ProtectedRoute><PreparationPage /></ProtectedRoute>} />
198200
<Route path="/pipeline" element={<ProtectedRoute><PipelinePage /></ProtectedRoute>} />

0 commit comments

Comments
 (0)