godcrm/backend/routes/v3/widgets/resolveTicketRefController.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

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;