Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
197 lines
6.8 KiB
JavaScript
197 lines
6.8 KiB
JavaScript
/**
|
|
* Widget ticket-ref resolution controller — ADR-0012 §Phase 5 (M3 backend).
|
|
*
|
|
* GET /api/v3/widgets/:widgetId/tickets/:ticketId/resolve
|
|
*
|
|
* Returns the live ticket row plus a slim snapshot projection so a
|
|
* `ticket_ref` atom rendered in any of {live, snapshot, hybrid} modes can
|
|
* always feed off the same endpoint.
|
|
*
|
|
* Response shape (success):
|
|
* {
|
|
* ticket: { id, base_id, table_id, data, created_at, updated_at },
|
|
* snapshot: {
|
|
* title: string|null,
|
|
* status: string|number|null,
|
|
* assigned_to: string|number|null,
|
|
* updated_at: ISO-string|null,
|
|
* snapshotted_at: ISO-string,
|
|
* }
|
|
* }
|
|
*
|
|
* Auth — relies on the route being mounted behind `authenticate`
|
|
* (server.js → `app.use('/api/v3', authenticate, widgetRoutesV3)`), same as
|
|
* resolveTicketsController.
|
|
*
|
|
* Error shapes:
|
|
* - widgetId / ticketId not numeric → 400 generic
|
|
* - widget not found → 404 (Widget)
|
|
* - widget not tickets-linked (no tickets_table_id and no fallback)
|
|
* → 400 with body
|
|
* { error: 'WIDGET_NOT_TICKETS_LINKED', code: 'WIDGET_NOT_TICKETS_LINKED' }
|
|
* (matches the contract requested by the frontend agent)
|
|
* - ticket not found in resolved tickets_table_id
|
|
* → 404 (Ticket)
|
|
*
|
|
* The "widget is tickets-linked" gate covers two cases:
|
|
* - `tickets_list` preset → always linked
|
|
* - documents-widget with `tickets_table_id` (or `ticket_binding.table_id`)
|
|
*
|
|
* For documents-widgets without an explicit binding we fall back to the
|
|
* canonical Tickets table 1708 — same default as resolveTicketsController.
|
|
*/
|
|
import express from 'express';
|
|
import { getWidgetById } from '../../../services/WidgetService.js';
|
|
import { dbAll as realDbAll, dbGet as realDbGet } from '../../../database/connection.js';
|
|
import { apiLogger } from '../../../utils/logger.js';
|
|
import { success, error, notFound } from '../../../utils/response.js';
|
|
import {
|
|
serializeTicketSnapshot,
|
|
readTicketsTableId,
|
|
DEFAULT_TICKETS_TABLE_ID,
|
|
} from '../../../services/atoms/ticket-ref-serializer.js';
|
|
|
|
const NOT_TICKETS_LINKED = {
|
|
status: 400,
|
|
body: { error: 'WIDGET_NOT_TICKETS_LINKED', code: 'WIDGET_NOT_TICKETS_LINKED' },
|
|
};
|
|
|
|
/**
|
|
* Build the column-id → column-name map for a tickets table so we can return
|
|
* rows in the same shape as GET /tables/:tableId/rows.
|
|
*
|
|
* Mirrors `buildIdToNameMap` in resolveTicketsController.js but lives here to
|
|
* avoid cross-controller imports during early development.
|
|
*/
|
|
async function buildIdToNameMap(tableId, dbAll) {
|
|
const cols = await dbAll(
|
|
'SELECT id, column_name FROM table_columns WHERE table_id = ?',
|
|
[tableId]
|
|
);
|
|
const map = {};
|
|
for (const c of cols) map[String(c.id)] = c.column_name;
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Re-shape a raw `table_rows` row to the shape the rest of the API uses.
|
|
* Same projection as parseTicketRow in resolveTicketsController.js.
|
|
*/
|
|
function parseTicketRow(row, idToNameMap) {
|
|
const parsedData = typeof row.data === 'string' ? JSON.parse(row.data) : (row.data || {});
|
|
const transformed = { id: row.id };
|
|
if (idToNameMap && Object.keys(idToNameMap).length > 0) {
|
|
for (const [key, value] of Object.entries(parsedData)) {
|
|
const colName = idToNameMap[key] || key;
|
|
transformed[colName] = value;
|
|
}
|
|
} else {
|
|
Object.assign(transformed, parsedData);
|
|
}
|
|
return {
|
|
id: row.id,
|
|
base_id: row.base_id,
|
|
table_id: row.table_id,
|
|
data: transformed,
|
|
created_at: row.created_at,
|
|
updated_at: row.updated_at,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Decide whether a widget exposes a tickets binding (and therefore can host
|
|
* ticket_ref atoms / answer this endpoint).
|
|
*
|
|
* A widget is "tickets-linked" when:
|
|
* - preset_name === 'tickets_list', OR
|
|
* - widget.config has any tickets_table_id-style key, OR
|
|
* - it's a documents preset (preset_name in {'documents','documents_legacy'}
|
|
* OR config.registry_table_id is set) — these fall back to
|
|
* DEFAULT_TICKETS_TABLE_ID for ticket_ref atoms.
|
|
*/
|
|
function isWidgetTicketsLinked(widget) {
|
|
if (!widget) return false;
|
|
if (widget.preset_name === 'tickets_list') return true;
|
|
const cfg = widget.config || {};
|
|
if (readTicketsTableId(cfg) != null) return true;
|
|
if (widget.preset_name === 'documents' || widget.preset_name === 'documents_legacy') return true;
|
|
if (cfg.registry_table_id != null) return true;
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Resolve the tickets table id a widget points at. For tickets-linked widgets
|
|
* without an explicit binding we fall back to DEFAULT_TICKETS_TABLE_ID.
|
|
*/
|
|
function resolveTicketsTableIdForWidget(widget) {
|
|
const cfg = widget.config || {};
|
|
const explicit = readTicketsTableId(cfg);
|
|
if (explicit != null) return explicit;
|
|
return DEFAULT_TICKETS_TABLE_ID;
|
|
}
|
|
|
|
/**
|
|
* Build the route. Accepts injected dbGet/dbAll/loadWidget for testability.
|
|
*
|
|
* Production callers use the `router` default export which wires up the real
|
|
* dependencies. Tests can call `buildResolveRouter` directly with mocks.
|
|
*/
|
|
export function buildResolveRouter({
|
|
dbGet: dbGetImpl,
|
|
dbAll: dbAllImpl,
|
|
loadWidget = getWidgetById,
|
|
now = () => new Date(),
|
|
} = {}) {
|
|
const r = express.Router();
|
|
|
|
r.get('/widgets/:widgetId/tickets/:ticketId/resolve', async (req, res) => {
|
|
try {
|
|
const widgetId = parseInt(req.params.widgetId, 10);
|
|
const ticketId = parseInt(req.params.ticketId, 10);
|
|
if (!Number.isFinite(widgetId) || !Number.isFinite(ticketId)) {
|
|
return error(res, 'BAD_REQUEST', 'widgetId and ticketId must be integers', 400);
|
|
}
|
|
|
|
const widget = await loadWidget(widgetId);
|
|
if (!widget) {
|
|
return notFound(res, 'Widget');
|
|
}
|
|
|
|
if (!isWidgetTicketsLinked(widget)) {
|
|
return res.status(NOT_TICKETS_LINKED.status).json(NOT_TICKETS_LINKED.body);
|
|
}
|
|
|
|
const ticketsTableId = resolveTicketsTableIdForWidget(widget);
|
|
|
|
const row = await dbGetImpl(
|
|
`SELECT id, base_id, table_id, data, created_at, updated_at
|
|
FROM table_rows
|
|
WHERE id = ? AND table_id = ?`,
|
|
[ticketId, ticketsTableId]
|
|
);
|
|
if (!row) {
|
|
return notFound(res, 'Ticket');
|
|
}
|
|
|
|
const idToNameMap = await buildIdToNameMap(ticketsTableId, dbAllImpl);
|
|
const ticket = parseTicketRow(row, idToNameMap);
|
|
const snapshot = serializeTicketSnapshot(ticket, { now });
|
|
|
|
return success(res, { ticket, snapshot });
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'GET /widgets/:widgetId/tickets/:ticketId/resolve error');
|
|
return error(res, 'TICKET_REF_RESOLVE_FAILED', err.message || 'Failed to resolve ticket', 500);
|
|
}
|
|
});
|
|
|
|
return r;
|
|
}
|
|
|
|
// Default production router — uses real DB connection.
|
|
const router = express.Router();
|
|
router.use(buildResolveRouter({
|
|
dbGet: realDbGet,
|
|
dbAll: realDbAll,
|
|
}));
|
|
|
|
export default router;
|