Add an osTicket integration: list open tickets from its database

osTicket's own REST API only supports creating tickets, not listing or
reading them, so this reads osTicket's MySQL/MariaDB database directly
with a read-only user instead - the only integration in this app that
isn't a REST API. Joins the ticket, status, priority, department,
staff, team, and user tables, filtered to tickets in the "open" state
(status names are customizable per install, but that state flag isn't).

Surfaces per-ticket subject, priority, department, assignee, requester,
and osTicket's own overdue/awaiting-reply flags, plus a page at
/osticket and a Dashboard widget with open/overdue/awaiting-reply
counts.

Not verified against a live instance: unlike the HTTP-based
integrations, there was no way to fake a MySQL server to test against
in this environment, so the query is built from osTicket's published
schema but has never actually run against a real database. See
INTEGRATIONS.md for the read-only grant needed and further caveats.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-29 23:09:56 +02:00
1 parent 69e9325927
commit de5c39dddf
20 files changed
+754 -12

No files matched your search

+1
View File
@@ -16,6 +16,7 @@
"drizzle-orm": "^0.45.2",
"express": "^4.21.2",
"express-session": "^1.18.1",
"mysql2": "^3.24.5",
"node-schedule": "^2.1.1",
"nodemailer": "^6.9.14",
"openid-client": "^6.1.7",
+1
View File
@@ -289,6 +289,7 @@ export const integrationTypes = [
"uptimekuma",
"phpipam",
"pbs",
"osticket",
] as const;
export type IntegrationType = (typeof integrationTypes)[number];
+14
View File
@@ -62,6 +62,20 @@ export const INTEGRATION_FIELDS: Partial<Record<IntegrationType, IntegrationFiel
{ key: "tokenSecret", label: "API token secret", secret: true, type: "password" },
{ key: "insecure", label: "Allow self-signed certificate", secret: false, type: "checkbox" },
],
osticket: [
{ key: "host", label: "Database host", secret: false, placeholder: "osticket-db.example.lan" },
{ key: "port", label: "Database port", secret: false, optional: true, placeholder: "3306" },
{ key: "database", label: "Database name", secret: false, placeholder: "osticket" },
{ key: "username", label: "Database username", secret: false, placeholder: "read-only user" },
{ key: "password", label: "Database password", secret: true, type: "password" },
{
key: "tablePrefix",
label: "Table prefix",
secret: false,
optional: true,
placeholder: "ost_ (osTicket's default, unless changed at install)",
},
],
};
/** Fixed base URL per integration type, stored on the row for display/reference. */
+162
View File
@@ -0,0 +1,162 @@
/**
* osTicket adapter — reads directly from osTicket's own MySQL/MariaDB database.
* Requires config: host, database, username, password; optional: port (default 3306), tablePrefix
* (default "ost_", configurable at osTicket install time), insecure (skip TLS cert verification,
* only meaningful if the DB itself is reached over TLS).
*
* osTicket's own REST API only supports *creating* tickets (POST /api/tickets.json) — there is no
* official endpoint to list or read existing ones (confirmed against osTicket's own developer
* docs). Listing tickets therefore means reading the database directly with a read-only user, the
* same way osTicket's own admin panel does internally. This is the only integration in this app
* that isn't a REST API for that reason.
*
* Ticket status names are fully customizable per install ("Open" might be renamed), but every
* status maps to a fixed `state` column of either "open" or "closed" — filtering on `state` stays
* correct regardless of what the admin renamed things to.
*
* Subject and priority aren't columns on the ticket table itself — osTicket normalizes them into
* its dynamic custom-fields system. `ost_ticket__cdata` is a denormalized cache of exactly those
* two fields that osTicket's own admin panel reads from for ticket lists (faster than joining the
* generic form-fields tables), but by osTicket's own design it's a regenerated cache tied to the
* "Ticket Details" form — GitHub issues on the osTicket repo document it occasionally going stale
* or briefly missing after a form change. It's LEFT JOINed here (not required) so a ticket with no
* matching cdata row still shows up, just with an empty subject/priority rather than being dropped.
*/
import mysql from "mysql2/promise";
import { withDiagLogging } from "../../services/diagLog.js";
export interface OsTicketConfig {
host: string;
port?: string;
database: string;
username: string;
password: string;
tablePrefix?: string;
}
export interface OsTicketTicket {
ticketId: number;
number: string;
subject: string | null;
statusName: string;
priorityName: string | null;
priorityColor: string | null;
departmentName: string | null;
staffName: string | null;
teamName: string | null;
requesterName: string | null;
requesterEmail: string | null;
source: string | null;
isOverdue: boolean;
isAnswered: boolean;
createdAt: string;
lastActivityAt: string | null;
dueAt: string | null;
}
export interface OsTicketAdapter {
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
listOpenTickets(): Promise<OsTicketTicket[]>;
}
function toIso(value: unknown): string | null {
if (value instanceof Date) return value.toISOString();
return null;
}
// The prefix is spliced directly into table names below (MySQL has no way to parameterize an
// identifier), so it's restricted to what a real identifier can contain rather than trusted as-is.
const SAFE_PREFIX = /^[A-Za-z0-9_]*$/;
export function createOsTicketAdapter(config: OsTicketConfig): OsTicketAdapter {
const prefix = config.tablePrefix?.trim() || "ost_";
if (!SAFE_PREFIX.test(prefix)) {
throw new Error("Table prefix may only contain letters, numbers, and underscores");
}
const port = Number(config.port) || 3306;
async function withConnection<T>(fn: (conn: mysql.Connection) => Promise<T>): Promise<T> {
const conn = await mysql.createConnection({
host: config.host,
port,
database: config.database,
user: config.username,
password: config.password,
connectTimeout: 10_000,
});
try {
return await fn(conn);
} finally {
await conn.end().catch(() => {});
}
}
async function listOpenTickets(): Promise<OsTicketTicket[]> {
const sql = `
SELECT
t.ticket_id AS ticketId,
t.number AS number,
cdata.subject AS subject,
ts.name AS statusName,
tp.priority AS priorityName,
tp.priority_color AS priorityColor,
d.name AS departmentName,
CASE WHEN s.staff_id IS NOT NULL THEN TRIM(CONCAT(s.firstname, ' ', s.lastname)) ELSE NULL END AS staffName,
tm.name AS teamName,
u.name AS requesterName,
ue.address AS requesterEmail,
t.source AS source,
t.isoverdue AS isOverdue,
t.isanswered AS isAnswered,
t.created AS createdAt,
t.lastupdate AS lastActivityAt,
t.duedate AS dueAt
FROM ${prefix}ticket t
JOIN ${prefix}ticket_status ts ON ts.id = t.status_id
LEFT JOIN ${prefix}ticket__cdata cdata ON cdata.ticket_id = t.ticket_id
LEFT JOIN ${prefix}ticket_priority tp ON tp.priority_id = cdata.priority
LEFT JOIN ${prefix}department d ON d.id = t.dept_id
LEFT JOIN ${prefix}staff s ON s.staff_id = t.staff_id
LEFT JOIN ${prefix}team tm ON tm.team_id = t.team_id
LEFT JOIN ${prefix}user u ON u.id = t.user_id
LEFT JOIN ${prefix}user_email ue ON ue.id = t.user_email_id
WHERE ts.state = 'open'
ORDER BY t.isoverdue DESC, t.created ASC
`;
return withConnection(async (conn) => {
const [rows] = await conn.query<mysql.RowDataPacket[]>(sql);
return rows.map((row) => ({
ticketId: Number(row.ticketId),
number: String(row.number),
subject: row.subject ?? null,
statusName: String(row.statusName),
priorityName: row.priorityName ?? null,
priorityColor: row.priorityColor ?? null,
departmentName: row.departmentName ?? null,
staffName: row.staffName || null,
teamName: row.teamName ?? null,
requesterName: row.requesterName ?? null,
requesterEmail: row.requesterEmail ?? null,
source: row.source ?? null,
isOverdue: Boolean(row.isOverdue),
isAnswered: Boolean(row.isAnswered),
createdAt: toIso(row.createdAt) ?? new Date(0).toISOString(),
lastActivityAt: toIso(row.lastActivityAt),
dueAt: toIso(row.dueAt),
}));
});
}
async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> {
const start = Date.now();
try {
await withConnection((conn) => conn.query("SELECT 1"));
return { ok: true, latencyMs: Date.now() - start };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
return withDiagLogging("osticket", { ping, listOpenTickets });
}
+3
View File
@@ -9,6 +9,7 @@ import { createSynologyAdapter } from "./synology/adapter.js";
import { createUptimeKumaAdapter } from "./uptimekuma/adapter.js";
import { createPhpIpamAdapter } from "./phpipam/adapter.js";
import { createPbsAdapter } from "./pbs/adapter.js";
import { createOsTicketAdapter } from "./osticket/adapter.js";
export interface PingableAdapter {
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
@@ -41,6 +42,8 @@ export function createIntegrationAdapter(type: IntegrationType, config: Integrat
return createPhpIpamAdapter(config as any);
case "pbs":
return createPbsAdapter(config as any);
case "osticket":
return createOsTicketAdapter(config as any);
default:
throw new Error(`Integration type "${type}" is not implemented yet`);
}
+42
View File
@@ -22,6 +22,7 @@ import { createProxmoxAdapter, guestsWithoutBackupCoverage } from "../integratio
import { createSynologyAdapter } from "../integrations/synology/adapter.js";
import { createUptimeKumaAdapter } from "../integrations/uptimekuma/adapter.js";
import { createPbsAdapter } from "../integrations/pbs/adapter.js";
import { createOsTicketAdapter } from "../integrations/osticket/adapter.js";
import { attachMatchedServers, summarizeMonitors, toMatchableServers } from "../services/uptimeKumaMatch.js";
import { asyncHandler } from "../utils/asyncHandler.js";
@@ -858,3 +859,44 @@ integrationsRouter.get("/:id/pbs/status", asyncHandler(async (req, res) => {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}));
// ─── osTicket ────────────────────────────────────────────────────────────────
// Read-only, and the only integration that reads a database directly rather
// than an HTTP API — see server/src/integrations/osticket/adapter.ts for why.
async function requireOsTicketAdapter(req: Request, res: Response) {
const id = Number(req.params.id);
const loaded = await loadIntegrationConfig(id);
if (!loaded) {
res.status(404).json({ error: "not_found" });
return null;
}
if (loaded.integration.type !== "osticket") {
res.status(400).json({ error: "wrong_type" });
return null;
}
if (!loaded.integration.enabled) {
res.status(400).json({ error: "integration_disabled" });
return null;
}
return { integration: loaded.integration, adapter: createOsTicketAdapter(loaded.config as any) };
}
integrationsRouter.get("/:id/osticket/tickets", asyncHandler(async (req, res) => {
const found = await requireOsTicketAdapter(req, res);
if (!found) return;
try {
const tickets = await found.adapter.listOpenTickets();
res.json({
tickets,
summary: {
total: tickets.length,
overdue: tickets.filter((t) => t.isOverdue).length,
awaitingReply: tickets.filter((t) => !t.isAnswered).length,
},
});
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}));