feat(mc-browser): reuse Control Library UI for Master Controls
- MC page.tsx imports ControlListView + useControlLibraryState directly - useControlLibraryState accepts optional backendUrl override - MC API route returns data in canonical control format - Same filters, pagination, sorting, click-to-detail as Control Library Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,41 +6,46 @@ const pool = new Pool({
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/sdk/v1/master-controls?action=list|detail|members
|
||||
* MC API that returns data in the same format as the canonical controls
|
||||
* endpoint. This allows the MC page to reuse ControlListView components.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action') || 'list'
|
||||
const endpoint = searchParams.get('endpoint') || 'controls'
|
||||
|
||||
if (action === 'list') {
|
||||
return handleList(searchParams)
|
||||
}
|
||||
if (action === 'detail') {
|
||||
return handleDetail(searchParams)
|
||||
}
|
||||
if (action === 'members') {
|
||||
return handleMembers(searchParams)
|
||||
}
|
||||
switch (endpoint) {
|
||||
case 'frameworks':
|
||||
return NextResponse.json([])
|
||||
|
||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
|
||||
case 'controls':
|
||||
return handleControls(searchParams)
|
||||
|
||||
case 'controls-count':
|
||||
return handleCount(searchParams)
|
||||
|
||||
case 'controls-meta':
|
||||
return handleMeta(searchParams)
|
||||
|
||||
case 'control':
|
||||
return handleDetail(searchParams)
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: 'unknown' }, { status: 400 })
|
||||
}
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: String(e) }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleList(params: URLSearchParams) {
|
||||
async function handleControls(params: URLSearchParams) {
|
||||
const search = params.get('search') || ''
|
||||
const minControls = parseInt(params.get('min_controls') || '0')
|
||||
const sortBy = params.get('sort') || 'total_controls'
|
||||
const order = params.get('order') || 'DESC'
|
||||
const limit = Math.min(parseInt(params.get('limit') || '100'), 500)
|
||||
const limit = Math.min(parseInt(params.get('limit') || '50'), 200)
|
||||
const offset = parseInt(params.get('offset') || '0')
|
||||
const sort = params.get('sort') || 'control_id'
|
||||
const order = params.get('order') === 'desc' ? 'DESC' : 'ASC'
|
||||
|
||||
const validSort = ['total_controls', 'canonical_name', 'created_at'].includes(sortBy) ? sortBy : 'total_controls'
|
||||
const validOrder = order === 'ASC' ? 'ASC' : 'DESC'
|
||||
|
||||
let where = 'WHERE 1=1'
|
||||
let where = "WHERE 1=1"
|
||||
const args: unknown[] = []
|
||||
let idx = 1
|
||||
|
||||
@@ -49,81 +54,150 @@ async function handleList(params: URLSearchParams) {
|
||||
args.push(`%${search}%`)
|
||||
idx++
|
||||
}
|
||||
if (minControls > 0) {
|
||||
where += ` AND mc.total_controls >= $${idx}`
|
||||
args.push(minControls)
|
||||
idx++
|
||||
}
|
||||
|
||||
const countRes = await pool.query(
|
||||
`SELECT count(*) FROM compliance.master_controls mc ${where}`, args
|
||||
)
|
||||
const total = parseInt(countRes.rows[0].count)
|
||||
const sortCol = sort === 'control_id' ? 'mc.master_control_id' :
|
||||
sort === 'created_at' ? 'mc.created_at' : 'mc.master_control_id'
|
||||
|
||||
args.push(limit, offset)
|
||||
const res = await pool.query(`
|
||||
SELECT mc.master_control_id as control_id,
|
||||
mc.canonical_name as title,
|
||||
'Master Control mit ' || mc.total_controls || ' Atomic Controls' as objective,
|
||||
CASE WHEN mc.total_controls > 100 THEN 'high'
|
||||
WHEN mc.total_controls > 20 THEN 'medium'
|
||||
ELSE 'low' END as severity,
|
||||
'master_control' as category,
|
||||
mc.total_controls,
|
||||
mc.phases_covered,
|
||||
mc.id,
|
||||
mc.created_at
|
||||
FROM compliance.master_controls mc
|
||||
${where}
|
||||
ORDER BY ${sortCol} ${order}
|
||||
LIMIT $${idx} OFFSET $${idx + 1}
|
||||
`, args)
|
||||
|
||||
// Map to canonical control format
|
||||
const controls = res.rows.map(r => ({
|
||||
id: r.id,
|
||||
control_id: r.control_id,
|
||||
title: r.title,
|
||||
objective: r.objective,
|
||||
severity: r.severity,
|
||||
category: r.category,
|
||||
release_state: 'active',
|
||||
source_citation: null,
|
||||
verification_method: null,
|
||||
evidence_type: null,
|
||||
target_audience: [],
|
||||
requirements: [],
|
||||
test_procedure: [],
|
||||
evidence: [],
|
||||
open_anchors: [],
|
||||
total_controls: r.total_controls,
|
||||
phases_covered: r.phases_covered,
|
||||
created_at: r.created_at,
|
||||
}))
|
||||
|
||||
return NextResponse.json(controls)
|
||||
}
|
||||
|
||||
async function handleCount(params: URLSearchParams) {
|
||||
const search = params.get('search') || ''
|
||||
let where = "WHERE 1=1"
|
||||
const args: unknown[] = []
|
||||
|
||||
if (search) {
|
||||
where += ` AND mc.canonical_name ILIKE $1`
|
||||
args.push(`%${search}%`)
|
||||
}
|
||||
|
||||
const res = await pool.query(
|
||||
`SELECT mc.id, mc.master_control_id, mc.canonical_name,
|
||||
mc.total_controls, mc.phases_covered, mc.phase_control_count
|
||||
FROM compliance.master_controls mc
|
||||
${where}
|
||||
ORDER BY mc.${validSort} ${validOrder}
|
||||
LIMIT $${idx} OFFSET $${idx + 1}`,
|
||||
args
|
||||
`SELECT count(*) FROM compliance.master_controls mc ${where}`, args
|
||||
)
|
||||
return NextResponse.json({ total: parseInt(res.rows[0].count) })
|
||||
}
|
||||
|
||||
async function handleMeta(params: URLSearchParams) {
|
||||
const res = await pool.query(`
|
||||
SELECT count(*) as total,
|
||||
count(CASE WHEN total_controls > 100 THEN 1 END) as high_count,
|
||||
count(CASE WHEN total_controls BETWEEN 20 AND 100 THEN 1 END) as medium_count,
|
||||
count(CASE WHEN total_controls < 20 THEN 1 END) as low_count
|
||||
FROM compliance.master_controls
|
||||
`)
|
||||
const r = res.rows[0]
|
||||
|
||||
// Get top L1 tokens as "domains"
|
||||
const domainRes = await pool.query(`
|
||||
SELECT split_part(canonical_name, '_', 1) as domain, count(*) as count
|
||||
FROM compliance.master_controls
|
||||
GROUP BY 1 ORDER BY 2 DESC LIMIT 30
|
||||
`)
|
||||
|
||||
return NextResponse.json({
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
master_controls: res.rows,
|
||||
total: parseInt(r.total),
|
||||
severity_counts: {
|
||||
high: parseInt(r.high_count),
|
||||
medium: parseInt(r.medium_count),
|
||||
low: parseInt(r.low_count),
|
||||
},
|
||||
domains: domainRes.rows.map(d => ({ domain: d.domain, count: parseInt(d.count) })),
|
||||
sources: [],
|
||||
no_source_count: 0,
|
||||
release_state_counts: { active: parseInt(r.total) },
|
||||
verification_method_counts: {},
|
||||
category_counts: {},
|
||||
evidence_type_counts: {},
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDetail(params: URLSearchParams) {
|
||||
const id = params.get('id')
|
||||
if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 })
|
||||
|
||||
const res = await pool.query(
|
||||
`SELECT mc.id, mc.master_control_id, mc.canonical_name,
|
||||
mc.total_controls, mc.phases_covered, mc.phase_control_count
|
||||
FROM compliance.master_controls mc
|
||||
WHERE mc.master_control_id = $1 OR mc.id::text = $1`,
|
||||
[id]
|
||||
)
|
||||
const id = params.get('id') || ''
|
||||
const res = await pool.query(`
|
||||
SELECT mc.id, mc.master_control_id as control_id, mc.canonical_name as title,
|
||||
'Master Control mit ' || mc.total_controls || ' Atomic Controls' as objective,
|
||||
mc.total_controls, mc.phases_covered, mc.phase_control_count, mc.created_at
|
||||
FROM compliance.master_controls mc
|
||||
WHERE mc.master_control_id = $1 OR mc.id::text = $1
|
||||
`, [id])
|
||||
|
||||
if (res.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ master_control: res.rows[0] })
|
||||
}
|
||||
const mc = res.rows[0]
|
||||
|
||||
async function handleMembers(params: URLSearchParams) {
|
||||
const mcId = params.get('mc_id')
|
||||
if (!mcId) return NextResponse.json({ error: 'mc_id required' }, { status: 400 })
|
||||
|
||||
const limit = Math.min(parseInt(params.get('limit') || '50'), 200)
|
||||
const offset = parseInt(params.get('offset') || '0')
|
||||
|
||||
const res = await pool.query(
|
||||
`SELECT cc.control_id, cc.title, cc.objective, cc.severity,
|
||||
mcm.phase, mcm.action,
|
||||
COALESCE(pc.source_citation::jsonb->>'source', '') as regulation_source,
|
||||
COALESCE(pc.source_citation::jsonb->>'article', '') as regulation_article
|
||||
FROM compliance.master_control_members mcm
|
||||
JOIN compliance.canonical_controls cc ON cc.id = mcm.control_uuid
|
||||
LEFT JOIN compliance.canonical_controls pc ON pc.id = cc.parent_control_uuid
|
||||
WHERE mcm.master_control_uuid = (
|
||||
SELECT id FROM compliance.master_controls WHERE master_control_id = $1 OR id::text = $1 LIMIT 1
|
||||
)
|
||||
ORDER BY mcm.phase, cc.control_id
|
||||
LIMIT $2 OFFSET $3`,
|
||||
[mcId, limit, offset]
|
||||
)
|
||||
// Load members
|
||||
const membersRes = await pool.query(`
|
||||
SELECT cc.control_id, cc.title, cc.severity, mcm.phase, mcm.action
|
||||
FROM compliance.master_control_members mcm
|
||||
JOIN compliance.canonical_controls cc ON cc.id = mcm.control_uuid
|
||||
WHERE mcm.master_control_uuid = $1
|
||||
ORDER BY mcm.phase, cc.control_id
|
||||
LIMIT 100
|
||||
`, [mc.id])
|
||||
|
||||
return NextResponse.json({
|
||||
mc_id: mcId,
|
||||
members: res.rows,
|
||||
count: res.rows.length,
|
||||
id: mc.id,
|
||||
control_id: mc.control_id,
|
||||
title: mc.title,
|
||||
objective: mc.objective,
|
||||
severity: mc.total_controls > 100 ? 'high' : mc.total_controls > 20 ? 'medium' : 'low',
|
||||
category: 'master_control',
|
||||
release_state: 'active',
|
||||
total_controls: mc.total_controls,
|
||||
phases_covered: mc.phases_covered,
|
||||
phase_control_count: mc.phase_control_count,
|
||||
members: membersRes.rows,
|
||||
requirements: membersRes.rows.map((m: { control_id: string; title: string; phase: string }) =>
|
||||
`[${m.phase}] ${m.control_id}: ${m.title}`
|
||||
),
|
||||
test_procedure: [],
|
||||
evidence: [],
|
||||
open_anchors: [],
|
||||
target_audience: [],
|
||||
source_citation: null,
|
||||
created_at: mc.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user