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:
1 parent
69e9325927
commit
de5c39dddf
20 files changed
+754
-12
No files matched your search
@@ -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",
|
||||
|
||||
@@ -289,6 +289,7 @@ export const integrationTypes = [
|
||||
"uptimekuma",
|
||||
"phpipam",
|
||||
"pbs",
|
||||
"osticket",
|
||||
] as const;
|
||||
export type IntegrationType = (typeof integrationTypes)[number];
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
}));
|
||||
Reference in new issue
Block a user