2026-05-01 17:15:41 +00:00
|
|
|
// CARD Asset Ownership API Routes
|
|
|
|
|
// Proxies CARD operations (confirm, decline, redirect, search) and orchestrates
|
|
|
|
|
// the two-step update_token flow for mutations.
|
|
|
|
|
|
|
|
|
|
const express = require('express');
|
2026-05-06 11:44:17 -06:00
|
|
|
const pool = require('../db');
|
|
|
|
|
const { requireAuth, requireGroup } = require('../middleware/auth');
|
2026-05-01 17:15:41 +00:00
|
|
|
const logAudit = require('../helpers/auditLog');
|
|
|
|
|
const {
|
|
|
|
|
isConfigured,
|
|
|
|
|
missingVars,
|
|
|
|
|
getTeams,
|
|
|
|
|
getTeamAssets,
|
|
|
|
|
getOwner,
|
|
|
|
|
confirmAsset,
|
|
|
|
|
declineAsset,
|
|
|
|
|
redirectAsset,
|
2026-05-27 18:56:40 -06:00
|
|
|
resolveAssetId,
|
2026-05-01 17:15:41 +00:00
|
|
|
} = require('../helpers/cardApi');
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Error classification — maps CARD API / token errors to client responses
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
function handleCardError(err, res) {
|
|
|
|
|
const msg = err.message || String(err);
|
|
|
|
|
console.error('[card-api]', msg);
|
|
|
|
|
|
|
|
|
|
if (msg.includes('Token acquisition failed')) {
|
|
|
|
|
if (msg.includes('HTTP 401')) {
|
|
|
|
|
return res.status(401).json({ error: 'CARD authorization failed. Check service account credentials.' });
|
|
|
|
|
}
|
|
|
|
|
if (msg.includes('HTTP 403')) {
|
|
|
|
|
return res.status(403).json({ error: 'CARD access denied. The service account may not be onboarded with the CARD team.' });
|
|
|
|
|
}
|
|
|
|
|
if (msg.includes('HTTP 525')) {
|
|
|
|
|
return res.status(502).json({ error: 'CARD LDAP error. The service account may not be provisioned correctly.' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (msg.includes('401')) {
|
|
|
|
|
return res.status(401).json({ error: 'CARD token expired or invalid. The request has been retried once automatically.' });
|
|
|
|
|
}
|
|
|
|
|
if (msg.includes('403')) {
|
|
|
|
|
return res.status(403).json({ error: 'Insufficient CARD permissions for this operation.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return res.status(502).json({ error: 'CARD API request failed.', details: msg });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Router factory
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-05-06 11:44:17 -06:00
|
|
|
function createCardApiRouter() {
|
2026-05-01 17:15:41 +00:00
|
|
|
const router = express.Router();
|
|
|
|
|
|
Add Granite Loader Sheet generator with CARD enrichment
Implement the Granite Team_Device Loader xlsx export feature:
- Add graniteLoaderConfig.js with all 41 columns, groupings, and
operation-type requirements (Change/Add/Delete/Move)
- Add graniteLoaderExport.js for client-side xlsx generation using
the xlsx library
- Add LoaderModal component with operation type selection, column
checkboxes, bulk defaults with per-row overrides, editable preview
table, CARD enrichment integration, and standalone paste-IPs mode
- Add POST /api/card/enrich-batch endpoint for batch IP lookup in
CARD returning EQUIP_INST_ID, hostname, site, ASN, team
- Integrate 'Generate Loader Sheet' button in Ivanti Queue floating
action bar (visible when CARD/GRANITE/DECOM items selected)
- Add card-connectivity-test.js script for verifying CARD API access
2026-05-27 17:18:36 -06:00
|
|
|
/**
|
|
|
|
|
* GET /status
|
|
|
|
|
*
|
|
|
|
|
* Returns whether the CARD API integration is configured.
|
|
|
|
|
*
|
|
|
|
|
* @response 200 - { configured: true }
|
|
|
|
|
* @response 503 - { configured: false, error: string, missingVars: string[] }
|
|
|
|
|
*/
|
2026-05-06 11:44:17 -06:00
|
|
|
router.get('/status', requireAuth(), (req, res) => {
|
2026-05-01 17:15:41 +00:00
|
|
|
if (!isConfigured) {
|
2026-05-06 11:44:17 -06:00
|
|
|
return res.status(503).json({ configured: false, error: 'CARD API is not configured.', missingVars });
|
2026-05-01 17:15:41 +00:00
|
|
|
}
|
|
|
|
|
res.json({ configured: true });
|
|
|
|
|
});
|
|
|
|
|
|
Add Granite Loader Sheet generator with CARD enrichment
Implement the Granite Team_Device Loader xlsx export feature:
- Add graniteLoaderConfig.js with all 41 columns, groupings, and
operation-type requirements (Change/Add/Delete/Move)
- Add graniteLoaderExport.js for client-side xlsx generation using
the xlsx library
- Add LoaderModal component with operation type selection, column
checkboxes, bulk defaults with per-row overrides, editable preview
table, CARD enrichment integration, and standalone paste-IPs mode
- Add POST /api/card/enrich-batch endpoint for batch IP lookup in
CARD returning EQUIP_INST_ID, hostname, site, ASN, team
- Integrate 'Generate Loader Sheet' button in Ivanti Queue floating
action bar (visible when CARD/GRANITE/DECOM items selected)
- Add card-connectivity-test.js script for verifying CARD API access
2026-05-27 17:18:36 -06:00
|
|
|
/**
|
|
|
|
|
* GET /teams
|
|
|
|
|
*
|
|
|
|
|
* Returns the list of teams from the CARD API.
|
|
|
|
|
*
|
|
|
|
|
* @response 200 - Array of team objects from CARD
|
|
|
|
|
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
|
|
|
|
*/
|
2026-05-06 11:44:17 -06:00
|
|
|
router.get('/teams', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
2026-05-01 17:15:41 +00:00
|
|
|
if (!isConfigured) {
|
|
|
|
|
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await getTeams();
|
|
|
|
|
if (result.ok) {
|
|
|
|
|
let body;
|
2026-05-06 11:44:17 -06:00
|
|
|
try { body = JSON.parse(result.body); } catch (_) { body = result.body; }
|
2026-05-01 17:15:41 +00:00
|
|
|
const teams = Array.isArray(body) ? body : (body && body.teams) || [];
|
|
|
|
|
return res.json(teams);
|
|
|
|
|
}
|
|
|
|
|
let errorBody;
|
2026-05-06 11:44:17 -06:00
|
|
|
try { errorBody = JSON.parse(result.body); } catch (_) { errorBody = { error: result.body }; }
|
2026-05-01 17:15:41 +00:00
|
|
|
return res.status(result.status).json(errorBody);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
return handleCardError(err, res);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
Add Granite Loader Sheet generator with CARD enrichment
Implement the Granite Team_Device Loader xlsx export feature:
- Add graniteLoaderConfig.js with all 41 columns, groupings, and
operation-type requirements (Change/Add/Delete/Move)
- Add graniteLoaderExport.js for client-side xlsx generation using
the xlsx library
- Add LoaderModal component with operation type selection, column
checkboxes, bulk defaults with per-row overrides, editable preview
table, CARD enrichment integration, and standalone paste-IPs mode
- Add POST /api/card/enrich-batch endpoint for batch IP lookup in
CARD returning EQUIP_INST_ID, hostname, site, ASN, team
- Integrate 'Generate Loader Sheet' button in Ivanti Queue floating
action bar (visible when CARD/GRANITE/DECOM items selected)
- Add card-connectivity-test.js script for verifying CARD API access
2026-05-27 17:18:36 -06:00
|
|
|
/**
|
|
|
|
|
* GET /teams/:teamName/assets
|
|
|
|
|
*
|
|
|
|
|
* Returns paginated assets for a team filtered by disposition.
|
|
|
|
|
*
|
|
|
|
|
* @param {string} teamName - Team name (path parameter)
|
|
|
|
|
* @query {string} disposition - Required. Asset disposition filter.
|
|
|
|
|
* @query {number} [page] - Page number for pagination.
|
|
|
|
|
* @query {number} [page_size=50] - Number of results per page.
|
|
|
|
|
* @response 200 - { assets: object[], total: number, ... } from CARD
|
|
|
|
|
* @response 400 - { error: string } — missing disposition
|
|
|
|
|
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
|
|
|
|
*/
|
2026-05-06 11:44:17 -06:00
|
|
|
router.get('/teams/:teamName/assets', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
2026-05-01 17:15:41 +00:00
|
|
|
if (!isConfigured) {
|
|
|
|
|
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { teamName } = req.params;
|
|
|
|
|
const { disposition, page, page_size } = req.query;
|
|
|
|
|
|
|
|
|
|
if (!disposition) {
|
|
|
|
|
return res.status(400).json({ error: 'disposition query parameter is required.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await getTeamAssets(teamName, {
|
|
|
|
|
disposition,
|
|
|
|
|
page: page ? parseInt(page, 10) : undefined,
|
|
|
|
|
pageSize: page_size ? parseInt(page_size, 10) : 50,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (result.ok) {
|
|
|
|
|
let body;
|
2026-05-06 11:44:17 -06:00
|
|
|
try { body = JSON.parse(result.body); } catch (_) { body = result.body; }
|
2026-05-01 17:15:41 +00:00
|
|
|
|
|
|
|
|
let resultCount = 0;
|
|
|
|
|
if (body && typeof body === 'object' && typeof body.total === 'number') {
|
|
|
|
|
resultCount = body.total;
|
|
|
|
|
} else if (body && Array.isArray(body.assets)) {
|
|
|
|
|
resultCount = body.assets.length;
|
|
|
|
|
}
|
2026-05-06 11:44:17 -06:00
|
|
|
logAudit({
|
2026-05-01 17:15:41 +00:00
|
|
|
userId: req.user.id,
|
|
|
|
|
username: req.user.username,
|
|
|
|
|
action: 'card_search',
|
|
|
|
|
entityType: 'card_asset',
|
|
|
|
|
entityId: teamName,
|
|
|
|
|
details: { disposition, resultCount },
|
|
|
|
|
ipAddress: req.ip,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return res.json(body);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let errorBody;
|
2026-05-06 11:44:17 -06:00
|
|
|
try { errorBody = JSON.parse(result.body); } catch (_) { errorBody = { error: result.body }; }
|
2026-05-01 17:15:41 +00:00
|
|
|
return res.status(result.status).json(errorBody);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
return handleCardError(err, res);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
Add Granite Loader Sheet generator with CARD enrichment
Implement the Granite Team_Device Loader xlsx export feature:
- Add graniteLoaderConfig.js with all 41 columns, groupings, and
operation-type requirements (Change/Add/Delete/Move)
- Add graniteLoaderExport.js for client-side xlsx generation using
the xlsx library
- Add LoaderModal component with operation type selection, column
checkboxes, bulk defaults with per-row overrides, editable preview
table, CARD enrichment integration, and standalone paste-IPs mode
- Add POST /api/card/enrich-batch endpoint for batch IP lookup in
CARD returning EQUIP_INST_ID, hostname, site, ASN, team
- Integrate 'Generate Loader Sheet' button in Ivanti Queue floating
action bar (visible when CARD/GRANITE/DECOM items selected)
- Add card-connectivity-test.js script for verifying CARD API access
2026-05-27 17:18:36 -06:00
|
|
|
/**
|
|
|
|
|
* GET /owner/:assetId
|
|
|
|
|
*
|
|
|
|
|
* Returns the CARD owner record for a given asset ID.
|
|
|
|
|
*
|
|
|
|
|
* @param {string} assetId - CARD asset identifier (path parameter)
|
|
|
|
|
* @response 200 - CARD owner/asset object
|
|
|
|
|
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
|
|
|
|
*/
|
2026-05-06 11:44:17 -06:00
|
|
|
router.get('/owner/:assetId', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
2026-05-01 17:15:41 +00:00
|
|
|
if (!isConfigured) {
|
|
|
|
|
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { assetId } = req.params;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await getOwner(assetId);
|
|
|
|
|
if (result.ok) {
|
|
|
|
|
let body;
|
2026-05-06 11:44:17 -06:00
|
|
|
try { body = JSON.parse(result.body); } catch (_) { body = result.body; }
|
2026-05-01 17:15:41 +00:00
|
|
|
return res.json(body);
|
|
|
|
|
}
|
|
|
|
|
let errorBody;
|
2026-05-06 11:44:17 -06:00
|
|
|
try { errorBody = JSON.parse(result.body); } catch (_) { errorBody = { error: result.body }; }
|
2026-05-01 17:15:41 +00:00
|
|
|
return res.status(result.status).json(errorBody);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
return handleCardError(err, res);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
Add Granite Loader Sheet generator with CARD enrichment
Implement the Granite Team_Device Loader xlsx export feature:
- Add graniteLoaderConfig.js with all 41 columns, groupings, and
operation-type requirements (Change/Add/Delete/Move)
- Add graniteLoaderExport.js for client-side xlsx generation using
the xlsx library
- Add LoaderModal component with operation type selection, column
checkboxes, bulk defaults with per-row overrides, editable preview
table, CARD enrichment integration, and standalone paste-IPs mode
- Add POST /api/card/enrich-batch endpoint for batch IP lookup in
CARD returning EQUIP_INST_ID, hostname, site, ASN, team
- Integrate 'Generate Loader Sheet' button in Ivanti Queue floating
action bar (visible when CARD/GRANITE/DECOM items selected)
- Add card-connectivity-test.js script for verifying CARD API access
2026-05-27 17:18:36 -06:00
|
|
|
/**
|
|
|
|
|
* POST /queue/:queueItemId/confirm
|
|
|
|
|
*
|
|
|
|
|
* Confirms ownership of a CARD asset for a queue item. Fetches the owner
|
|
|
|
|
* record to obtain the update_token, then calls the CARD confirm endpoint.
|
|
|
|
|
* Marks the queue item as complete on success.
|
|
|
|
|
*
|
|
|
|
|
* @param {string} queueItemId - Queue item ID (path parameter)
|
|
|
|
|
* @body {string} teamName - Team name to confirm ownership for (required)
|
|
|
|
|
* @body {string} assetId - CARD asset identifier (required)
|
|
|
|
|
* @body {string} [comment] - Optional comment for the confirmation
|
|
|
|
|
* @response 200 - { success: true, cardResponse: object }
|
|
|
|
|
* @response 400 - { error: string } — missing fields or item not pending
|
|
|
|
|
* @response 404 - { error: string } — queue item not found
|
|
|
|
|
* @response 502 - { error: string } — CARD API failure
|
|
|
|
|
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
|
|
|
|
*/
|
2026-05-06 11:44:17 -06:00
|
|
|
router.post('/queue/:queueItemId/confirm', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
2026-05-01 17:15:41 +00:00
|
|
|
if (!isConfigured) {
|
|
|
|
|
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { queueItemId } = req.params;
|
2026-05-27 18:56:40 -06:00
|
|
|
const { teamName, assetId: rawAssetId, comment } = req.body;
|
2026-05-01 17:15:41 +00:00
|
|
|
|
|
|
|
|
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
|
|
|
|
|
return res.status(400).json({ error: 'teamName is required.' });
|
|
|
|
|
}
|
2026-05-27 18:56:40 -06:00
|
|
|
if (!rawAssetId || typeof rawAssetId !== 'string' || !rawAssetId.trim()) {
|
2026-05-01 17:15:41 +00:00
|
|
|
return res.status(400).json({ error: 'assetId is required.' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 18:56:40 -06:00
|
|
|
// Resolve bare IP to full CARD asset ID
|
|
|
|
|
let assetId = rawAssetId.trim();
|
|
|
|
|
if (!/\d+-[A-Z]+$/i.test(assetId)) {
|
|
|
|
|
const resolved = await resolveAssetId(assetId);
|
|
|
|
|
if (!resolved) {
|
|
|
|
|
return res.status(404).json({ error: `Asset not found in CARD for IP: ${assetId}` });
|
|
|
|
|
}
|
|
|
|
|
assetId = resolved;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 17:15:41 +00:00
|
|
|
try {
|
2026-05-06 11:44:17 -06:00
|
|
|
const { rows } = await pool.query(
|
|
|
|
|
'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2 AND workflow_type = $3',
|
2026-05-01 17:15:41 +00:00
|
|
|
[queueItemId, req.user.id, 'CARD']
|
|
|
|
|
);
|
2026-05-06 11:44:17 -06:00
|
|
|
const item = rows[0];
|
2026-05-01 17:15:41 +00:00
|
|
|
|
|
|
|
|
if (!item) {
|
|
|
|
|
return res.status(404).json({ error: 'Queue item not found.' });
|
|
|
|
|
}
|
|
|
|
|
if (item.status !== 'pending') {
|
|
|
|
|
return res.status(400).json({ error: 'Only pending queue items can be executed.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ownerResult = await getOwner(assetId);
|
|
|
|
|
if (!ownerResult.ok) {
|
|
|
|
|
const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`;
|
2026-05-06 11:44:17 -06:00
|
|
|
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: ownerResult.status }, ipAddress: req.ip });
|
2026-05-01 17:15:41 +00:00
|
|
|
let errorBody;
|
|
|
|
|
try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; }
|
|
|
|
|
return res.status(ownerResult.status).json(errorBody);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let ownerData;
|
|
|
|
|
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
|
|
|
|
|
const updateToken = ownerData.owner && ownerData.owner.update_token;
|
|
|
|
|
|
|
|
|
|
if (!updateToken) {
|
|
|
|
|
const errMsg = 'update_token not found in owner record.';
|
2026-05-06 11:44:17 -06:00
|
|
|
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: null }, ipAddress: req.ip });
|
2026-05-01 17:15:41 +00:00
|
|
|
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const confirmResult = await confirmAsset(assetId, teamName.trim(), updateToken, comment || '');
|
|
|
|
|
|
|
|
|
|
if (confirmResult.ok) {
|
2026-05-06 11:44:17 -06:00
|
|
|
await pool.query(
|
|
|
|
|
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = NOW() WHERE id = $1",
|
2026-05-01 17:15:41 +00:00
|
|
|
[queueItemId]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let cardResponse;
|
|
|
|
|
try { cardResponse = JSON.parse(confirmResult.body); } catch (_) { cardResponse = confirmResult.body; }
|
|
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_confirm', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { assetId, teamName: teamName.trim(), comment: comment || '', cardStatus: confirmResult.status }, ipAddress: req.ip });
|
2026-05-01 17:15:41 +00:00
|
|
|
|
|
|
|
|
return res.json({ success: true, cardResponse });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const errMsg = `Confirm failed: HTTP ${confirmResult.status}`;
|
2026-05-06 11:44:17 -06:00
|
|
|
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'confirm', assetId, error: errMsg, cardStatus: confirmResult.status }, ipAddress: req.ip });
|
2026-05-01 17:15:41 +00:00
|
|
|
let errorBody;
|
|
|
|
|
try { errorBody = JSON.parse(confirmResult.body); } catch (_) { errorBody = { error: confirmResult.body }; }
|
|
|
|
|
return res.status(confirmResult.status).json(errorBody);
|
|
|
|
|
} catch (err) {
|
2026-05-06 11:44:17 -06:00
|
|
|
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'confirm', assetId, error: err.message, cardStatus: null }, ipAddress: req.ip });
|
2026-05-01 17:15:41 +00:00
|
|
|
return handleCardError(err, res);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
Add Granite Loader Sheet generator with CARD enrichment
Implement the Granite Team_Device Loader xlsx export feature:
- Add graniteLoaderConfig.js with all 41 columns, groupings, and
operation-type requirements (Change/Add/Delete/Move)
- Add graniteLoaderExport.js for client-side xlsx generation using
the xlsx library
- Add LoaderModal component with operation type selection, column
checkboxes, bulk defaults with per-row overrides, editable preview
table, CARD enrichment integration, and standalone paste-IPs mode
- Add POST /api/card/enrich-batch endpoint for batch IP lookup in
CARD returning EQUIP_INST_ID, hostname, site, ASN, team
- Integrate 'Generate Loader Sheet' button in Ivanti Queue floating
action bar (visible when CARD/GRANITE/DECOM items selected)
- Add card-connectivity-test.js script for verifying CARD API access
2026-05-27 17:18:36 -06:00
|
|
|
/**
|
|
|
|
|
* POST /queue/:queueItemId/decline
|
|
|
|
|
*
|
|
|
|
|
* Declines ownership of a CARD asset for a queue item. Fetches the owner
|
|
|
|
|
* record to obtain the update_token, then calls the CARD decline endpoint.
|
|
|
|
|
* Marks the queue item as complete on success.
|
|
|
|
|
*
|
|
|
|
|
* @param {string} queueItemId - Queue item ID (path parameter)
|
|
|
|
|
* @body {string} teamName - Team name declining ownership (required)
|
|
|
|
|
* @body {string} assetId - CARD asset identifier (required)
|
|
|
|
|
* @body {string} [comment] - Optional comment for the decline
|
|
|
|
|
* @response 200 - { success: true, cardResponse: object }
|
|
|
|
|
* @response 400 - { error: string } — missing fields or item not pending
|
|
|
|
|
* @response 404 - { error: string } — queue item not found
|
|
|
|
|
* @response 502 - { error: string } — CARD API failure
|
|
|
|
|
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
|
|
|
|
*/
|
2026-05-06 11:44:17 -06:00
|
|
|
router.post('/queue/:queueItemId/decline', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
2026-05-01 17:15:41 +00:00
|
|
|
if (!isConfigured) {
|
|
|
|
|
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { queueItemId } = req.params;
|
2026-05-27 18:56:40 -06:00
|
|
|
const { teamName, assetId: rawAssetId, comment } = req.body;
|
2026-05-01 17:15:41 +00:00
|
|
|
|
|
|
|
|
if (!teamName || typeof teamName !== 'string' || !teamName.trim()) {
|
|
|
|
|
return res.status(400).json({ error: 'teamName is required.' });
|
|
|
|
|
}
|
2026-05-27 18:56:40 -06:00
|
|
|
if (!rawAssetId || typeof rawAssetId !== 'string' || !rawAssetId.trim()) {
|
2026-05-01 17:15:41 +00:00
|
|
|
return res.status(400).json({ error: 'assetId is required.' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 18:56:40 -06:00
|
|
|
// Resolve bare IP to full CARD asset ID
|
|
|
|
|
let assetId = rawAssetId.trim();
|
|
|
|
|
if (!/\d+-[A-Z]+$/i.test(assetId)) {
|
|
|
|
|
const resolved = await resolveAssetId(assetId);
|
|
|
|
|
if (!resolved) {
|
|
|
|
|
return res.status(404).json({ error: `Asset not found in CARD for IP: ${assetId}` });
|
|
|
|
|
}
|
|
|
|
|
assetId = resolved;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 17:15:41 +00:00
|
|
|
try {
|
2026-05-06 11:44:17 -06:00
|
|
|
const { rows } = await pool.query(
|
|
|
|
|
'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2 AND workflow_type = $3',
|
2026-05-01 17:15:41 +00:00
|
|
|
[queueItemId, req.user.id, 'CARD']
|
|
|
|
|
);
|
2026-05-06 11:44:17 -06:00
|
|
|
const item = rows[0];
|
2026-05-01 17:15:41 +00:00
|
|
|
|
|
|
|
|
if (!item) {
|
|
|
|
|
return res.status(404).json({ error: 'Queue item not found.' });
|
|
|
|
|
}
|
|
|
|
|
if (item.status !== 'pending') {
|
|
|
|
|
return res.status(400).json({ error: 'Only pending queue items can be executed.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ownerResult = await getOwner(assetId);
|
|
|
|
|
if (!ownerResult.ok) {
|
|
|
|
|
const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`;
|
2026-05-06 11:44:17 -06:00
|
|
|
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: errMsg, cardStatus: ownerResult.status }, ipAddress: req.ip });
|
2026-05-01 17:15:41 +00:00
|
|
|
let errorBody;
|
|
|
|
|
try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; }
|
|
|
|
|
return res.status(ownerResult.status).json(errorBody);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let ownerData;
|
|
|
|
|
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
|
|
|
|
|
const updateToken = ownerData.owner && ownerData.owner.update_token;
|
|
|
|
|
|
|
|
|
|
if (!updateToken) {
|
|
|
|
|
const errMsg = 'update_token not found in owner record.';
|
2026-05-06 11:44:17 -06:00
|
|
|
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: errMsg, cardStatus: null }, ipAddress: req.ip });
|
2026-05-01 17:15:41 +00:00
|
|
|
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const declineResult = await declineAsset(assetId, teamName.trim(), updateToken, comment || '');
|
|
|
|
|
|
|
|
|
|
if (declineResult.ok) {
|
2026-05-06 11:44:17 -06:00
|
|
|
await pool.query(
|
|
|
|
|
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = NOW() WHERE id = $1",
|
2026-05-01 17:15:41 +00:00
|
|
|
[queueItemId]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let cardResponse;
|
|
|
|
|
try { cardResponse = JSON.parse(declineResult.body); } catch (_) { cardResponse = declineResult.body; }
|
|
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_decline', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { assetId, teamName: teamName.trim(), comment: comment || '', cardStatus: declineResult.status }, ipAddress: req.ip });
|
2026-05-01 17:15:41 +00:00
|
|
|
|
|
|
|
|
return res.json({ success: true, cardResponse });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const errMsg = `Decline failed: HTTP ${declineResult.status}`;
|
2026-05-06 11:44:17 -06:00
|
|
|
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: errMsg, cardStatus: declineResult.status }, ipAddress: req.ip });
|
2026-05-01 17:15:41 +00:00
|
|
|
let errorBody;
|
|
|
|
|
try { errorBody = JSON.parse(declineResult.body); } catch (_) { errorBody = { error: declineResult.body }; }
|
|
|
|
|
return res.status(declineResult.status).json(errorBody);
|
|
|
|
|
} catch (err) {
|
2026-05-06 11:44:17 -06:00
|
|
|
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'decline', assetId, error: err.message, cardStatus: null }, ipAddress: req.ip });
|
2026-05-01 17:15:41 +00:00
|
|
|
return handleCardError(err, res);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
Add Granite Loader Sheet generator with CARD enrichment
Implement the Granite Team_Device Loader xlsx export feature:
- Add graniteLoaderConfig.js with all 41 columns, groupings, and
operation-type requirements (Change/Add/Delete/Move)
- Add graniteLoaderExport.js for client-side xlsx generation using
the xlsx library
- Add LoaderModal component with operation type selection, column
checkboxes, bulk defaults with per-row overrides, editable preview
table, CARD enrichment integration, and standalone paste-IPs mode
- Add POST /api/card/enrich-batch endpoint for batch IP lookup in
CARD returning EQUIP_INST_ID, hostname, site, ASN, team
- Integrate 'Generate Loader Sheet' button in Ivanti Queue floating
action bar (visible when CARD/GRANITE/DECOM items selected)
- Add card-connectivity-test.js script for verifying CARD API access
2026-05-27 17:18:36 -06:00
|
|
|
/**
|
|
|
|
|
* POST /queue/:queueItemId/redirect
|
|
|
|
|
*
|
|
|
|
|
* Redirects a CARD asset from one team to another for a queue item. Fetches
|
|
|
|
|
* the owner record to obtain the update_token, then calls the CARD redirect
|
|
|
|
|
* endpoint. Marks the queue item as complete on success.
|
|
|
|
|
*
|
|
|
|
|
* @param {string} queueItemId - Queue item ID (path parameter)
|
|
|
|
|
* @body {string} fromTeam - Current owning team (required)
|
|
|
|
|
* @body {string} toTeam - Target team to redirect to (required)
|
|
|
|
|
* @body {string} assetId - CARD asset identifier (required)
|
|
|
|
|
* @response 200 - { success: true, cardResponse: object }
|
|
|
|
|
* @response 400 - { error: string } — missing fields or item not pending
|
|
|
|
|
* @response 404 - { error: string } — queue item not found
|
|
|
|
|
* @response 502 - { error: string } — CARD API failure
|
|
|
|
|
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
|
|
|
|
*/
|
2026-05-06 11:44:17 -06:00
|
|
|
router.post('/queue/:queueItemId/redirect', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
2026-05-01 17:15:41 +00:00
|
|
|
if (!isConfigured) {
|
|
|
|
|
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { queueItemId } = req.params;
|
2026-05-27 18:56:40 -06:00
|
|
|
const { fromTeam, toTeam, assetId: rawAssetId } = req.body;
|
2026-05-01 17:15:41 +00:00
|
|
|
|
|
|
|
|
if (!fromTeam || typeof fromTeam !== 'string' || !fromTeam.trim()) {
|
|
|
|
|
return res.status(400).json({ error: 'fromTeam is required.' });
|
|
|
|
|
}
|
|
|
|
|
if (!toTeam || typeof toTeam !== 'string' || !toTeam.trim()) {
|
|
|
|
|
return res.status(400).json({ error: 'toTeam is required.' });
|
|
|
|
|
}
|
2026-05-27 18:56:40 -06:00
|
|
|
if (!rawAssetId || typeof rawAssetId !== 'string' || !rawAssetId.trim()) {
|
2026-05-01 17:15:41 +00:00
|
|
|
return res.status(400).json({ error: 'assetId is required.' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 18:56:40 -06:00
|
|
|
// Resolve bare IP to full CARD asset ID (e.g., 10.240.78.110 → 10.240.78.110-CTEC)
|
|
|
|
|
let assetId = rawAssetId.trim();
|
|
|
|
|
if (!/\d+-[A-Z]+$/i.test(assetId)) {
|
|
|
|
|
const resolved = await resolveAssetId(assetId);
|
|
|
|
|
if (!resolved) {
|
|
|
|
|
return res.status(404).json({ error: `Asset not found in CARD for IP: ${assetId}` });
|
|
|
|
|
}
|
|
|
|
|
assetId = resolved;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 17:15:41 +00:00
|
|
|
try {
|
2026-05-06 11:44:17 -06:00
|
|
|
const { rows } = await pool.query(
|
|
|
|
|
'SELECT * FROM ivanti_todo_queue WHERE id = $1 AND user_id = $2 AND workflow_type = $3',
|
2026-05-01 17:15:41 +00:00
|
|
|
[queueItemId, req.user.id, 'CARD']
|
|
|
|
|
);
|
2026-05-06 11:44:17 -06:00
|
|
|
const item = rows[0];
|
2026-05-01 17:15:41 +00:00
|
|
|
|
|
|
|
|
if (!item) {
|
|
|
|
|
return res.status(404).json({ error: 'Queue item not found.' });
|
|
|
|
|
}
|
|
|
|
|
if (item.status !== 'pending') {
|
|
|
|
|
return res.status(400).json({ error: 'Only pending queue items can be executed.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ownerResult = await getOwner(assetId);
|
|
|
|
|
if (!ownerResult.ok) {
|
|
|
|
|
const errMsg = `Failed to fetch owner record: HTTP ${ownerResult.status}`;
|
2026-05-06 11:44:17 -06:00
|
|
|
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: ownerResult.status }, ipAddress: req.ip });
|
2026-05-01 17:15:41 +00:00
|
|
|
let errorBody;
|
|
|
|
|
try { errorBody = JSON.parse(ownerResult.body); } catch (_) { errorBody = { error: ownerResult.body }; }
|
|
|
|
|
return res.status(ownerResult.status).json(errorBody);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let ownerData;
|
|
|
|
|
try { ownerData = JSON.parse(ownerResult.body); } catch (_) { ownerData = {}; }
|
|
|
|
|
const updateToken = ownerData.owner && ownerData.owner.update_token;
|
|
|
|
|
|
|
|
|
|
if (!updateToken) {
|
|
|
|
|
const errMsg = 'update_token not found in owner record.';
|
2026-05-06 11:44:17 -06:00
|
|
|
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: null }, ipAddress: req.ip });
|
2026-05-01 17:15:41 +00:00
|
|
|
return res.status(502).json({ error: 'CARD API request failed.', details: errMsg });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const redirectResult = await redirectAsset(assetId, fromTeam.trim(), toTeam.trim(), updateToken);
|
|
|
|
|
|
|
|
|
|
if (redirectResult.ok) {
|
2026-05-06 11:44:17 -06:00
|
|
|
await pool.query(
|
|
|
|
|
"UPDATE ivanti_todo_queue SET status = 'complete', updated_at = NOW() WHERE id = $1",
|
2026-05-01 17:15:41 +00:00
|
|
|
[queueItemId]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let cardResponse;
|
|
|
|
|
try { cardResponse = JSON.parse(redirectResult.body); } catch (_) { cardResponse = redirectResult.body; }
|
|
|
|
|
|
2026-05-06 11:44:17 -06:00
|
|
|
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_redirect', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { assetId, fromTeam: fromTeam.trim(), toTeam: toTeam.trim(), cardStatus: redirectResult.status }, ipAddress: req.ip });
|
2026-05-01 17:15:41 +00:00
|
|
|
|
|
|
|
|
return res.json({ success: true, cardResponse });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const errMsg = `Redirect failed: HTTP ${redirectResult.status}`;
|
2026-05-06 11:44:17 -06:00
|
|
|
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'redirect', assetId, error: errMsg, cardStatus: redirectResult.status }, ipAddress: req.ip });
|
2026-05-01 17:15:41 +00:00
|
|
|
let errorBody;
|
|
|
|
|
try { errorBody = JSON.parse(redirectResult.body); } catch (_) { errorBody = { error: redirectResult.body }; }
|
|
|
|
|
return res.status(redirectResult.status).json(errorBody);
|
|
|
|
|
} catch (err) {
|
2026-05-06 11:44:17 -06:00
|
|
|
logAudit({ userId: req.user.id, username: req.user.username, action: 'card_action_failed', entityType: 'ivanti_todo_queue', entityId: String(queueItemId), details: { actionType: 'redirect', assetId, error: err.message, cardStatus: null }, ipAddress: req.ip });
|
2026-05-01 17:15:41 +00:00
|
|
|
return handleCardError(err, res);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
Add Granite Loader Sheet generator with CARD enrichment
Implement the Granite Team_Device Loader xlsx export feature:
- Add graniteLoaderConfig.js with all 41 columns, groupings, and
operation-type requirements (Change/Add/Delete/Move)
- Add graniteLoaderExport.js for client-side xlsx generation using
the xlsx library
- Add LoaderModal component with operation type selection, column
checkboxes, bulk defaults with per-row overrides, editable preview
table, CARD enrichment integration, and standalone paste-IPs mode
- Add POST /api/card/enrich-batch endpoint for batch IP lookup in
CARD returning EQUIP_INST_ID, hostname, site, ASN, team
- Integrate 'Generate Loader Sheet' button in Ivanti Queue floating
action bar (visible when CARD/GRANITE/DECOM items selected)
- Add card-connectivity-test.js script for verifying CARD API access
2026-05-27 17:18:36 -06:00
|
|
|
/**
|
|
|
|
|
* POST /enrich-batch
|
|
|
|
|
*
|
|
|
|
|
* Batch lookup IPs in CARD to extract Granite loader fields. Tries each IP
|
|
|
|
|
* with known asset ID suffixes (CTEC, NATL, CHTR, etc.) and falls back to
|
|
|
|
|
* bare IP lookup. Returns enrichment results for each IP.
|
|
|
|
|
*
|
|
|
|
|
* @body {string[]} ips - Non-empty array of IP address strings (max 200)
|
|
|
|
|
* @response 200 - { results: object[], enriched_count: number, not_found_count: number, total: number }
|
|
|
|
|
* Each result: { ip: string, found: boolean, equip_inst_id: string|null, hostname: string|null, site_name?: string|null, mgmt_ip_asn?: string|null, responsible_team?: string|null, equipment_class?: string, equip_template?: null, equip_status?: string|null, error?: string }
|
|
|
|
|
* @response 400 - { error: string } — invalid or empty ips array, or exceeds 200
|
|
|
|
|
* @response 503 - { error: string, missingVars: string[] } — CARD not configured
|
|
|
|
|
*/
|
|
|
|
|
router.post('/enrich-batch', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => {
|
|
|
|
|
if (!isConfigured) {
|
|
|
|
|
return res.status(503).json({ error: 'CARD API is not configured.', missingVars });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { ips } = req.body || {};
|
|
|
|
|
if (!Array.isArray(ips) || ips.length === 0) {
|
|
|
|
|
return res.status(400).json({ error: 'ips must be a non-empty array of IP address strings.' });
|
|
|
|
|
}
|
|
|
|
|
if (ips.length > 200) {
|
|
|
|
|
return res.status(400).json({ error: 'Maximum 200 IPs per request.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Known CARD asset ID suffixes to try
|
|
|
|
|
const SUFFIXES = ['CTEC', 'NATL', 'CHTR', 'COML', 'RESI', 'WIFI', 'VOIP'];
|
|
|
|
|
|
|
|
|
|
const results = [];
|
|
|
|
|
let enrichedCount = 0;
|
|
|
|
|
let notFoundCount = 0;
|
|
|
|
|
|
|
|
|
|
for (const ip of ips) {
|
|
|
|
|
if (!ip || typeof ip !== 'string') {
|
|
|
|
|
results.push({ ip: ip || '', found: false, equip_inst_id: null, hostname: null, error: 'Invalid IP' });
|
|
|
|
|
notFoundCount++;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const trimmedIp = ip.trim();
|
|
|
|
|
let found = false;
|
|
|
|
|
let enriched = null;
|
|
|
|
|
|
|
|
|
|
// Try owner lookup with each suffix
|
|
|
|
|
for (const suffix of SUFFIXES) {
|
|
|
|
|
try {
|
|
|
|
|
const assetId = `${trimmedIp}-${suffix}`;
|
|
|
|
|
const ownerResult = await getOwner(assetId);
|
|
|
|
|
if (ownerResult.ok) {
|
|
|
|
|
const asset = JSON.parse(ownerResult.body);
|
|
|
|
|
enriched = extractGraniteFields(asset, trimmedIp);
|
|
|
|
|
found = true;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
} catch (_) {
|
|
|
|
|
// Continue to next suffix
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try without suffix (bare IP)
|
|
|
|
|
if (!found) {
|
|
|
|
|
try {
|
|
|
|
|
const ownerResult = await getOwner(trimmedIp);
|
|
|
|
|
if (ownerResult.ok) {
|
|
|
|
|
const asset = JSON.parse(ownerResult.body);
|
|
|
|
|
enriched = extractGraniteFields(asset, trimmedIp);
|
|
|
|
|
found = true;
|
|
|
|
|
}
|
|
|
|
|
} catch (_) {
|
|
|
|
|
// Not found
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (found && enriched) {
|
|
|
|
|
results.push({ ip: trimmedIp, found: true, ...enriched });
|
|
|
|
|
enrichedCount++;
|
|
|
|
|
} else {
|
|
|
|
|
results.push({ ip: trimmedIp, found: false, equip_inst_id: null, hostname: null, error: 'IP not found in CARD' });
|
|
|
|
|
notFoundCount++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res.json({ results, enriched_count: enrichedCount, not_found_count: notFoundCount, total: ips.length });
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-01 17:15:41 +00:00
|
|
|
return router;
|
|
|
|
|
}
|
|
|
|
|
|
Add Granite Loader Sheet generator with CARD enrichment
Implement the Granite Team_Device Loader xlsx export feature:
- Add graniteLoaderConfig.js with all 41 columns, groupings, and
operation-type requirements (Change/Add/Delete/Move)
- Add graniteLoaderExport.js for client-side xlsx generation using
the xlsx library
- Add LoaderModal component with operation type selection, column
checkboxes, bulk defaults with per-row overrides, editable preview
table, CARD enrichment integration, and standalone paste-IPs mode
- Add POST /api/card/enrich-batch endpoint for batch IP lookup in
CARD returning EQUIP_INST_ID, hostname, site, ASN, team
- Integrate 'Generate Loader Sheet' button in Ivanti Queue floating
action bar (visible when CARD/GRANITE/DECOM items selected)
- Add card-connectivity-test.js script for verifying CARD API access
2026-05-27 17:18:36 -06:00
|
|
|
/**
|
|
|
|
|
* Extract Granite-relevant fields from a CARD asset record.
|
|
|
|
|
*/
|
|
|
|
|
function extractGraniteFields(asset, ip) {
|
|
|
|
|
const ncim = asset.ncim_discovery || [];
|
|
|
|
|
const granite = asset.netops_granite_allips || [];
|
|
|
|
|
const iseGranite = asset.ise_granite_equipment || [];
|
|
|
|
|
const flags = (asset.card_flags && asset.card_flags[0]) || {};
|
|
|
|
|
const qualys = asset.qualys_hosts || [];
|
|
|
|
|
const ivanti = asset.ivanti_assets || [];
|
|
|
|
|
|
|
|
|
|
// EQUIP_INST_ID — primary from ncim_discovery, fallbacks
|
|
|
|
|
let equip_inst_id = null;
|
|
|
|
|
let site_name = null;
|
|
|
|
|
let responsible_team = null;
|
|
|
|
|
let hostname = null;
|
|
|
|
|
|
|
|
|
|
if (ncim.length > 0) {
|
|
|
|
|
equip_inst_id = ncim[0].EQUIP_INST_ID || null;
|
|
|
|
|
responsible_team = ncim[0].GRANITE_RESP_TEAM || ncim[0].RESPONSIBLE_TEAM || null;
|
|
|
|
|
site_name = ncim[0].SITE_NAME || ncim[0].SITENAME || null;
|
|
|
|
|
hostname = ncim[0].HOSTNAME || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!equip_inst_id && Array.isArray(granite) && granite.length > 0) {
|
|
|
|
|
equip_inst_id = granite[0].EQUIP_INST_ID || null;
|
|
|
|
|
if (!site_name) site_name = granite[0].SITE_NAME || null;
|
|
|
|
|
if (!responsible_team) responsible_team = granite[0].RESPONSIBLE_TEAM || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!equip_inst_id && Array.isArray(iseGranite) && iseGranite.length > 0) {
|
|
|
|
|
equip_inst_id = iseGranite[0].EQUIP_INST_ID || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Hostname fallbacks
|
|
|
|
|
if (!hostname) {
|
|
|
|
|
hostname = (flags.CARD_HOSTNAME && flags.CARD_HOSTNAME[0])
|
|
|
|
|
|| (qualys.length > 0 && qualys[0].HOSTNAME)
|
|
|
|
|
|| (ivanti.length > 0 && ivanti[0].hostName)
|
|
|
|
|
|| null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ASN
|
|
|
|
|
const mgmt_ip_asn = (flags.CARD_ASN) || null;
|
|
|
|
|
|
|
|
|
|
// Equipment class — always S (Shelf) from CARD context
|
|
|
|
|
const equipment_class = 'S';
|
|
|
|
|
|
|
|
|
|
// Equip status from flags
|
|
|
|
|
const equip_status = (flags.status) || null;
|
|
|
|
|
|
|
|
|
|
// Equip template — not typically in CARD data, leave null
|
|
|
|
|
const equip_template = null;
|
|
|
|
|
|
|
|
|
|
// Confirmed team from owner record
|
|
|
|
|
const confirmed_team = asset.owner && asset.owner.confirmed
|
|
|
|
|
? asset.owner.confirmed.name : null;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
equip_inst_id: equip_inst_id ? String(equip_inst_id) : null,
|
|
|
|
|
hostname,
|
|
|
|
|
site_name,
|
|
|
|
|
mgmt_ip_asn: mgmt_ip_asn ? String(mgmt_ip_asn) : null,
|
|
|
|
|
responsible_team: responsible_team || confirmed_team || null,
|
|
|
|
|
equipment_class,
|
|
|
|
|
equip_template,
|
|
|
|
|
equip_status,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 17:15:41 +00:00
|
|
|
module.exports = createCardApiRouter;
|