Skip to content

Commit 9602b7b

Browse files
committed
feat: Add client monitoring dashboard & company blocking in super admin
- New Client Monitoring section with per-company interview usage stats - Block/restrict controls for companies and interview access - Enhanced companies table with status pills and action buttons - 3 new admin API endpoints (interview-stats, company block, interview block) - API-level enforcement: blocked companies cannot create interviews - Dashboard now shows 6 stat cards including Interviews count - SQL migration for is_blocked and interviews_blocked columns
1 parent 4dc49fa commit 9602b7b

3 files changed

Lines changed: 486 additions & 7 deletions

File tree

backend/simpatico-ats.js

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1278,6 +1278,9 @@ route("GET", "/admin/users", handleAdminListUsers);
12781278
route("GET", "/admin/jobs", handleAdminListJobs);
12791279
route("GET", "/admin/audit-logs", handleAdminAuditLogs);
12801280
route("POST", "/admin/test-email", handleAdminTestEmail);
1281+
route("GET", "/admin/interview-stats", handleAdminInterviewStats);
1282+
route("PATCH", "/admin/companies/:id/block", handleAdminToggleCompanyBlock);
1283+
route("PATCH", "/admin/companies/:id/interview-block", handleAdminToggleInterviewBlock);
12811284

12821285
// ── Attendance ──
12831286
route("POST", "/attendance/clock-in", handleClockIn);
@@ -6385,6 +6388,36 @@ async function handleCreateInterview(request, env, ctx) {
63856388
throw new ValidationError("candidate_name and candidate_email are required");
63866389
}
63876390

6391+
// ── Enforce company-level blocks ──
6392+
const companyId = body.company_id || ctx.tenantId;
6393+
if (companyId && companyId !== "default") {
6394+
try {
6395+
const compRes = await fetch(
6396+
`${env.SUPABASE_URL}/rest/v1/companies?id=eq.${companyId}&select=is_blocked,interviews_blocked`,
6397+
{
6398+
headers: {
6399+
apikey: env.SUPABASE_SERVICE_KEY,
6400+
Authorization: `Bearer ${env.SUPABASE_SERVICE_KEY}`,
6401+
},
6402+
},
6403+
);
6404+
if (compRes.ok) {
6405+
const companies = await compRes.json();
6406+
if (companies.length > 0) {
6407+
if (companies[0].is_blocked) {
6408+
throw new ForbiddenError("This company account has been blocked by the platform administrator.");
6409+
}
6410+
if (companies[0].interviews_blocked) {
6411+
throw new ForbiddenError("Interview access has been restricted for this company. Please contact the platform administrator.");
6412+
}
6413+
}
6414+
}
6415+
} catch (e) {
6416+
if (e instanceof ForbiddenError) throw e;
6417+
console.warn("[Interview] Company block check failed:", e.message);
6418+
}
6419+
}
6420+
63886421
const token = body.token || Array.from(crypto.getRandomValues(new Uint8Array(24)))
63896422
.map(b => b.toString(36).padStart(2, "0")).join("").slice(0, 32);
63906423

@@ -6714,6 +6747,154 @@ async function handleAdminTestEmail(request, env, ctx) {
67146747
return apiResponse({ sent: true, to });
67156748
}
67166749

6750+
/**
6751+
* GET /admin/interview-stats — Per-company interview usage stats (super_admin only).
6752+
* Returns each company's total interviews, completed, pending, and date of last interview.
6753+
*/
6754+
async function handleAdminInterviewStats(request, env, ctx) {
6755+
requireRole(ctx, "super_admin", "superadmin");
6756+
6757+
// Fetch all companies
6758+
const compRes = await fetch(
6759+
`${env.SUPABASE_URL}/rest/v1/companies?select=id,name,is_blocked,interviews_blocked`,
6760+
{
6761+
headers: {
6762+
apikey: env.SUPABASE_SERVICE_KEY,
6763+
Authorization: `Bearer ${env.SUPABASE_SERVICE_KEY}`,
6764+
},
6765+
},
6766+
);
6767+
const companies = compRes.ok ? await compRes.json() : [];
6768+
6769+
// Fetch all interviews with company_id
6770+
const intRes = await fetch(
6771+
`${env.SUPABASE_URL}/rest/v1/interviews?select=id,company_id,status,created_at&order=created_at.desc`,
6772+
{
6773+
headers: {
6774+
apikey: env.SUPABASE_SERVICE_KEY,
6775+
Authorization: `Bearer ${env.SUPABASE_SERVICE_KEY}`,
6776+
},
6777+
},
6778+
);
6779+
const interviews = intRes.ok ? await intRes.json() : [];
6780+
6781+
// Build per-company stats
6782+
const statsMap = {};
6783+
for (const c of companies) {
6784+
statsMap[c.id] = {
6785+
company_id: c.id,
6786+
company_name: c.name,
6787+
is_blocked: c.is_blocked || false,
6788+
interviews_blocked: c.interviews_blocked || false,
6789+
total_interviews: 0,
6790+
completed: 0,
6791+
pending: 0,
6792+
scheduled: 0,
6793+
expired: 0,
6794+
last_interview_at: null,
6795+
};
6796+
}
6797+
6798+
for (const iv of interviews) {
6799+
const cid = iv.company_id;
6800+
if (!statsMap[cid]) {
6801+
statsMap[cid] = {
6802+
company_id: cid,
6803+
company_name: "Unknown",
6804+
is_blocked: false,
6805+
interviews_blocked: false,
6806+
total_interviews: 0,
6807+
completed: 0,
6808+
pending: 0,
6809+
scheduled: 0,
6810+
expired: 0,
6811+
last_interview_at: null,
6812+
};
6813+
}
6814+
statsMap[cid].total_interviews++;
6815+
if (iv.status === "completed") statsMap[cid].completed++;
6816+
else if (iv.status === "pending") statsMap[cid].pending++;
6817+
else if (iv.status === "scheduled") statsMap[cid].scheduled++;
6818+
else if (iv.status === "expired") statsMap[cid].expired++;
6819+
if (!statsMap[cid].last_interview_at) {
6820+
statsMap[cid].last_interview_at = iv.created_at;
6821+
}
6822+
}
6823+
6824+
const stats = Object.values(statsMap).sort((a, b) => b.total_interviews - a.total_interviews);
6825+
return apiResponse({
6826+
stats,
6827+
total_interviews: interviews.length,
6828+
total_companies: companies.length,
6829+
});
6830+
}
6831+
6832+
/**
6833+
* PATCH /admin/companies/:id/block — Toggle company blocked status (super_admin only).
6834+
*/
6835+
async function handleAdminToggleCompanyBlock(request, env, ctx, [companyId]) {
6836+
requireRole(ctx, "super_admin", "superadmin");
6837+
6838+
const body = await safeJson(request);
6839+
const blocked = !!body.blocked;
6840+
6841+
const res = await fetch(
6842+
`${env.SUPABASE_URL}/rest/v1/companies?id=eq.${companyId}`,
6843+
{
6844+
method: "PATCH",
6845+
headers: {
6846+
"Content-Type": "application/json",
6847+
apikey: env.SUPABASE_SERVICE_KEY,
6848+
Authorization: `Bearer ${env.SUPABASE_SERVICE_KEY}`,
6849+
Prefer: "return=representation",
6850+
},
6851+
body: JSON.stringify({ is_blocked: blocked }),
6852+
},
6853+
);
6854+
6855+
if (!res.ok) throw new AppError("Failed to update company", res.status);
6856+
const updated = await res.json();
6857+
6858+
await audit(env, ctx, blocked ? "admin.company_blocked" : "admin.company_unblocked", "companies", companyId, {
6859+
blocked,
6860+
});
6861+
6862+
return apiResponse({ company: updated[0] || { id: companyId, is_blocked: blocked } });
6863+
}
6864+
6865+
/**
6866+
* PATCH /admin/companies/:id/interview-block — Toggle interview access for a company (super_admin only).
6867+
*/
6868+
async function handleAdminToggleInterviewBlock(request, env, ctx, [companyId]) {
6869+
requireRole(ctx, "super_admin", "superadmin");
6870+
6871+
const body = await safeJson(request);
6872+
const blocked = !!body.blocked;
6873+
6874+
const res = await fetch(
6875+
`${env.SUPABASE_URL}/rest/v1/companies?id=eq.${companyId}`,
6876+
{
6877+
method: "PATCH",
6878+
headers: {
6879+
"Content-Type": "application/json",
6880+
apikey: env.SUPABASE_SERVICE_KEY,
6881+
Authorization: `Bearer ${env.SUPABASE_SERVICE_KEY}`,
6882+
Prefer: "return=representation",
6883+
},
6884+
body: JSON.stringify({ interviews_blocked: blocked }),
6885+
},
6886+
);
6887+
6888+
if (!res.ok) throw new AppError("Failed to update company interview access", res.status);
6889+
const updated = await res.json();
6890+
6891+
await audit(env, ctx, blocked ? "admin.interviews_blocked" : "admin.interviews_unblocked", "companies", companyId, {
6892+
interviews_blocked: blocked,
6893+
});
6894+
6895+
return apiResponse({ company: updated[0] || { id: companyId, interviews_blocked: blocked } });
6896+
}
6897+
67176898
// ===============================================================
67186899
// § ATTENDANCE — Clock In / Clock Out / Records
67196900
// ===============================================================
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-- Migration: Add blocking columns to companies table
2+
-- Purpose: Enable super admin to block companies or restrict their interview access
3+
-- Date: 2026-05-17
4+
5+
-- Add is_blocked column (blocks full platform access)
6+
ALTER TABLE companies
7+
ADD COLUMN IF NOT EXISTS is_blocked BOOLEAN DEFAULT FALSE;
8+
9+
-- Add interviews_blocked column (restricts interview creation only)
10+
ALTER TABLE companies
11+
ADD COLUMN IF NOT EXISTS interviews_blocked BOOLEAN DEFAULT FALSE;
12+
13+
-- Add index for quick filtering of blocked companies
14+
CREATE INDEX IF NOT EXISTS idx_companies_is_blocked ON companies (is_blocked) WHERE is_blocked = true;
15+
CREATE INDEX IF NOT EXISTS idx_companies_interviews_blocked ON companies (interviews_blocked) WHERE interviews_blocked = true;
16+
17+
-- Comment on columns for documentation
18+
COMMENT ON COLUMN companies.is_blocked IS 'When true, the company account is fully blocked from platform access';
19+
COMMENT ON COLUMN companies.interviews_blocked IS 'When true, the company cannot create or conduct new interviews';

0 commit comments

Comments
 (0)