Init
This commit is contained in:
+630
@@ -0,0 +1,630 @@
|
||||
import { Pool } from 'pg';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Ticket, TicketStatus } from './types';
|
||||
|
||||
// Single shared pool. DATABASE_URL points at the `forge` Postgres database; the
|
||||
// schema self-bootstraps in initDB() on boot (no ORM, no migrations), mirroring
|
||||
// the Husky template's approach.
|
||||
export const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
|
||||
export async function initDB(): Promise<void> {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
number TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT '',
|
||||
short_desc TEXT NOT NULL DEFAULT '',
|
||||
assigned_to TEXT,
|
||||
assignment_group TEXT,
|
||||
brand TEXT,
|
||||
market TEXT,
|
||||
business_unit TEXT,
|
||||
requested_for TEXT,
|
||||
opened_at TIMESTAMPTZ,
|
||||
due_date DATE,
|
||||
state_changed_at TIMESTAMPTZ,
|
||||
state_changed_by TEXT,
|
||||
last_activity_at TIMESTAMPTZ,
|
||||
last_activity_by TEXT,
|
||||
last_comment TEXT,
|
||||
description TEXT,
|
||||
link TEXT,
|
||||
final_cost NUMERIC,
|
||||
ttfr_minutes INTEGER,
|
||||
client_resp_minutes INTEGER,
|
||||
fulfillment_date TIMESTAMPTZ,
|
||||
first_reply_at TIMESTAMPTZ,
|
||||
first_assigned_date TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ,
|
||||
jira JSONB,
|
||||
activity JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS tickets_status_idx ON tickets (status)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS tickets_state_idx ON tickets (state)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS tickets_group_idx ON tickets (assignment_group)`);
|
||||
|
||||
// Analytics columns (additive — never changes an existing column's shape).
|
||||
for (const col of [
|
||||
'currency_code TEXT', 'opened_by TEXT', 'opened_date DATE', 'closed_date DATE',
|
||||
'to_do_at TIMESTAMPTZ', 'in_uat_at TIMESTAMPTZ', 'jira_key TEXT',
|
||||
'ticket_year INTEGER', 'size TEXT',
|
||||
'po_number TEXT', 'invoiced TEXT',
|
||||
]) {
|
||||
await pool.query(`ALTER TABLE tickets ADD COLUMN IF NOT EXISTS ${col}`);
|
||||
}
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS tickets_year_idx ON tickets (ticket_year)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS tickets_closed_date_idx ON tickets (closed_date)`);
|
||||
|
||||
// Key/value config (SLA norms, thresholds, FX, size thresholds, palettes).
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS app_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value JSONB NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id SERIAL PRIMARY KEY,
|
||||
token_id TEXT UNIQUE NOT NULL,
|
||||
token_hash TEXT UNIQUE NOT NULL,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_used_at TIMESTAMPTZ,
|
||||
revoked BOOLEAN NOT NULL DEFAULT false,
|
||||
expires_at TIMESTAMPTZ
|
||||
)
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS app_users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'viewer',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_login_at TIMESTAMPTZ
|
||||
)
|
||||
`);
|
||||
// Existing deployments: add the role column (defaults viewer for prior rows).
|
||||
await pool.query(`ALTER TABLE app_users ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'viewer'`);
|
||||
|
||||
await seedUser();
|
||||
await seedConfig();
|
||||
await seedFromArchives();
|
||||
}
|
||||
|
||||
// --- Config (SLA norms / thresholds / FX / size / palettes) ----------------
|
||||
export interface ForgeConfig {
|
||||
insightsThresholds: Record<string, number>;
|
||||
fxRates: Record<string, number>;
|
||||
sizeThresholds: Record<string, number>;
|
||||
norms: Record<string, Record<string, number>>;
|
||||
brandColors: Record<string, string>;
|
||||
snowColors: Record<string, string>;
|
||||
statesOrder: string[];
|
||||
displayCurrency: string;
|
||||
colleagues: string[];
|
||||
latamAssignees: string[];
|
||||
jiraColumns: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: ForgeConfig = {
|
||||
insightsThresholds: {},
|
||||
fxRates: { GBP: 1, EUR: 1.2, MXN: 20 },
|
||||
sizeThresholds: { XS: 120, S: 300, M: 600, L: 1200, XL: 3000, XXL: 9000 },
|
||||
norms: {},
|
||||
brandColors: {},
|
||||
snowColors: {},
|
||||
statesOrder: [],
|
||||
displayCurrency: 'GBP',
|
||||
colleagues: [],
|
||||
latamAssignees: [],
|
||||
jiraColumns: [],
|
||||
};
|
||||
|
||||
let _config: ForgeConfig | null = null;
|
||||
export function loadConfig(): ForgeConfig {
|
||||
if (_config) return _config;
|
||||
let cfg: ForgeConfig;
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'config.json'), 'utf-8'));
|
||||
cfg = { ...DEFAULT_CONFIG, ...raw };
|
||||
} catch {
|
||||
cfg = DEFAULT_CONFIG;
|
||||
}
|
||||
_config = cfg;
|
||||
return cfg;
|
||||
}
|
||||
|
||||
async function seedConfig(): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
for (const [key, value] of Object.entries(cfg)) {
|
||||
await pool.query(
|
||||
`INSERT INTO app_config (key, value) VALUES ($1, $2)
|
||||
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
|
||||
[key, JSON.stringify(value)],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert a cost to GBP and map to a t-shirt size (largest nominal that fits,
|
||||
// the chart path used by the initial app). Zero/absent cost → null ("no cost").
|
||||
const SIZE_ORDER = ['XS', 'S', 'M', 'L', 'XL', 'XXL'];
|
||||
export function sizeOf(finalCost: number | null, currencyCode: string | null, cfg: ForgeConfig): string | null {
|
||||
if (finalCost == null || !(finalCost > 0)) return null;
|
||||
const rate = cfg.fxRates[(currencyCode || 'GBP').toUpperCase()] ?? 1; // units per 1 GBP
|
||||
const gbp = finalCost / rate;
|
||||
const th = cfg.sizeThresholds;
|
||||
let best = 'XS'; // largest nominal ≤ gbp, capped at XXL; sub-XS cost still reads XS
|
||||
for (const s of SIZE_ORDER) if (gbp >= (th[s] ?? Infinity)) best = s;
|
||||
return best;
|
||||
}
|
||||
|
||||
// Seed the single login account from AUTH_USER/AUTH_PASS on first boot. If the
|
||||
// user already exists its password is left untouched (change it in the DB, or
|
||||
// delete the row and reboot to re-seed). No-op when the env vars are unset.
|
||||
async function seedUser(): Promise<void> {
|
||||
const username = process.env.AUTH_USER;
|
||||
const password = process.env.AUTH_PASS;
|
||||
if (!username || !password) {
|
||||
console.warn('AUTH_USER/AUTH_PASS unset — no login account seeded (read API will reject all logins)');
|
||||
return;
|
||||
}
|
||||
const { rows } = await pool.query('SELECT 1 FROM app_users WHERE username = $1', [username]);
|
||||
if (rows.length > 0) {
|
||||
// Bootstrap account is always admin (fixes the default 'viewer' for pre-role rows).
|
||||
await pool.query(`UPDATE app_users SET role='admin' WHERE username=$1 AND role<>'admin'`, [username]);
|
||||
return;
|
||||
}
|
||||
const bcrypt = await import('bcryptjs');
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
await pool.query('INSERT INTO app_users (username, password_hash, role) VALUES ($1, $2, $3)', [username, hash, 'admin']);
|
||||
console.log(`Seeded admin account: ${username}`);
|
||||
}
|
||||
|
||||
// --- User management (Project Leadership / Admin) --------------------------
|
||||
export type Role = 'viewer' | 'pm' | 'lead' | 'admin';
|
||||
export const ROLES: Role[] = ['viewer', 'pm', 'lead', 'admin'];
|
||||
export function isRole(v: unknown): v is Role {
|
||||
return typeof v === 'string' && (ROLES as string[]).includes(v);
|
||||
}
|
||||
|
||||
export interface AppUser { id: number; username: string; role: Role; createdAt: string | null; lastLoginAt: string | null; }
|
||||
|
||||
export async function listUsers(): Promise<AppUser[]> {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, username, role, created_at, last_login_at FROM app_users ORDER BY username`,
|
||||
);
|
||||
return rows.map(r => ({
|
||||
id: r.id, username: r.username, role: r.role,
|
||||
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
|
||||
lastLoginAt: r.last_login_at instanceof Date ? r.last_login_at.toISOString() : r.last_login_at,
|
||||
}));
|
||||
}
|
||||
|
||||
// Create a user. Returns 'exists' if the username is taken, else the new user.
|
||||
export async function createUser(username: string, password: string, role: Role): Promise<AppUser | 'exists'> {
|
||||
const bcrypt = await import('bcryptjs');
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO app_users (username, password_hash, role) VALUES ($1, $2, $3)
|
||||
RETURNING id, username, role, created_at, last_login_at`,
|
||||
[username, hash, role],
|
||||
);
|
||||
const r = rows[0];
|
||||
return { id: r.id, username: r.username, role: r.role,
|
||||
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at, lastLoginAt: null };
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code === '23505') return 'exists'; // unique violation
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function countAdmins(): Promise<number> {
|
||||
const { rows } = await pool.query<{ n: string }>(`SELECT count(*)::text AS n FROM app_users WHERE role = 'admin'`);
|
||||
return Number(rows[0]?.n ?? '0');
|
||||
}
|
||||
|
||||
export async function getUserRole(username: string): Promise<Role | null> {
|
||||
const { rows } = await pool.query<{ role: Role }>(`SELECT role FROM app_users WHERE username = $1`, [username]);
|
||||
return rows[0]?.role ?? null;
|
||||
}
|
||||
|
||||
// Delete a user AND purge their active sessions (connect-pg-simple stores the
|
||||
// session as JSON in user_sessions.sess), so a removed user is logged out at once
|
||||
// instead of keeping their cached role for the cookie's lifetime.
|
||||
export async function deleteUser(username: string): Promise<void> {
|
||||
await pool.query(`DELETE FROM app_users WHERE username = $1`, [username]);
|
||||
await pool.query(`DELETE FROM user_sessions WHERE (sess -> 'user' ->> 'username') = $1`, [username])
|
||||
.catch(err => console.error('session purge failed:', (err as Error).message));
|
||||
}
|
||||
|
||||
// --- Empty helpers ---------------------------------------------------------
|
||||
function ts(v: unknown): string | null {
|
||||
if (v == null) return null;
|
||||
const s = String(v).trim();
|
||||
return s === '' ? null : s;
|
||||
}
|
||||
function num(v: unknown): number | null {
|
||||
if (v == null || v === '') return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
// Derive the calendar year from the best available opened date.
|
||||
function yearOf(...vals: (string | null)[]): number | null {
|
||||
for (const v of vals) {
|
||||
if (v) { const y = Number(String(v).slice(0, 4)); if (y > 2000 && y < 3000) return y; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reshape a raw active-archive record into a normalized Ticket.
|
||||
function fromActive(r: Record<string, unknown>, cfg: ForgeConfig): Ticket {
|
||||
const meta = (r.meta ?? {}) as Record<string, unknown>;
|
||||
const finalCost = num(meta.finalCost);
|
||||
const currencyCode = ts(meta.currencyCode);
|
||||
return {
|
||||
number: String(r.number),
|
||||
status: 'active',
|
||||
state: String(r.state ?? ''),
|
||||
shortDesc: String(r.shortDesc ?? ''),
|
||||
assignedTo: ts(r.assignedTo),
|
||||
assignmentGroup: ts(r.assignmentGroup),
|
||||
brand: ts(meta.brand),
|
||||
market: ts(meta.market),
|
||||
businessUnit: ts(meta.businessUnit),
|
||||
requestedFor: ts(meta.requestedFor),
|
||||
openedBy: null,
|
||||
openedAt: ts(meta.openedAt),
|
||||
openedDate: ts(meta.openedAt),
|
||||
closedDate: null,
|
||||
toDoAt: null,
|
||||
inUatAt: null,
|
||||
jiraKey: null,
|
||||
currencyCode,
|
||||
ticketYear: yearOf(ts(meta.openedAt)),
|
||||
size: sizeOf(finalCost, currencyCode, cfg),
|
||||
poNumber: null,
|
||||
invoiced: null,
|
||||
dueDate: ts(meta.dueDate),
|
||||
stateChangedAt: ts(meta.stateChangedAt),
|
||||
stateChangedBy: ts(meta.stateChangedBy),
|
||||
lastActivityAt: ts(meta.lastActivityAt),
|
||||
lastActivityBy: ts(meta.lastActivityBy),
|
||||
lastComment: ts(r.lastComment),
|
||||
description: ts(meta.description),
|
||||
link: ts(r.link),
|
||||
finalCost,
|
||||
ttfrMinutes: null,
|
||||
clientRespMinutes: null,
|
||||
fulfillmentDate: null,
|
||||
firstReplyAt: null,
|
||||
firstAssignedDate: null,
|
||||
updatedAt: ts(r._updated),
|
||||
jira: null,
|
||||
activity: Array.isArray(meta.activity) ? (meta.activity as Ticket['activity']) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function fromClosed(number: string, r: Record<string, unknown>, cfg: ForgeConfig): Ticket {
|
||||
const finalCost = num(r.finalCost);
|
||||
const currencyCode = ts(r.currencyCode);
|
||||
return {
|
||||
number,
|
||||
status: 'closed',
|
||||
state: String(r.state ?? ''),
|
||||
shortDesc: String(r.shortDesc ?? ''),
|
||||
assignedTo: ts(r.assignedTo),
|
||||
assignmentGroup: ts(r.assignmentGroup),
|
||||
brand: ts(r.brand),
|
||||
market: ts(r.market),
|
||||
businessUnit: ts(r.businessUnit),
|
||||
requestedFor: ts(r.requestedFor),
|
||||
openedBy: ts(r.openedBy),
|
||||
openedAt: ts(r.openedAt),
|
||||
openedDate: ts(r.openedDate) ?? ts(r.openedAt),
|
||||
closedDate: ts(r.closedDate),
|
||||
toDoAt: ts(r.toDoAt),
|
||||
inUatAt: ts(r.inUatAt),
|
||||
jiraKey: ts(r.jiraKey),
|
||||
currencyCode,
|
||||
ticketYear: (num(r.year) ?? yearOf(ts(r.closedDate), ts(r.openedDate), ts(r.openedAt))),
|
||||
size: sizeOf(finalCost, currencyCode, cfg),
|
||||
poNumber: null,
|
||||
invoiced: null,
|
||||
dueDate: null,
|
||||
stateChangedAt: ts(r.stateChangedAt),
|
||||
stateChangedBy: ts(r.stateChangedBy),
|
||||
lastActivityAt: ts(r.lastActivityAt),
|
||||
lastActivityBy: ts(r.lastActivityBy),
|
||||
lastComment: null,
|
||||
description: null,
|
||||
link: null,
|
||||
finalCost,
|
||||
ttfrMinutes: num(r.ttfrMinutes),
|
||||
clientRespMinutes: num(r.clientRespMinutes),
|
||||
fulfillmentDate: ts(r.fulfillmentDate),
|
||||
firstReplyAt: ts(r.firstReplyAt),
|
||||
firstAssignedDate: ts(r.firstAssignedDate),
|
||||
updatedAt: ts(r._updated),
|
||||
jira: null,
|
||||
activity: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Build the normalized ticket list from the bundled JSON archives. Jira info
|
||||
// (keyed by ticket number) is attached to BOTH active and closed tickets.
|
||||
//
|
||||
// Order matters: closed first, then active. A ticket number can appear in both
|
||||
// archives (a still-open request whose analytics row was also cached); upsert
|
||||
// applies later entries last, so putting active last makes the live active
|
||||
// board win over the stale closed snapshot.
|
||||
// Finance rows keyed by ticket number: cost / currency / PO / invoiced.
|
||||
interface FinanceRow { cost?: unknown; currency?: unknown; po?: unknown; invoiced?: unknown; }
|
||||
function loadFinance(dir: string): Record<string, FinanceRow> {
|
||||
try {
|
||||
return (JSON.parse(fs.readFileSync(path.join(dir, 'finance.json'), 'utf-8')).rows ?? {}) as Record<string, FinanceRow>;
|
||||
} catch { return {}; }
|
||||
}
|
||||
|
||||
// Enrich a ticket from its finance row: fill missing cost/currency (recomputing
|
||||
// size), and set the PO number ('' = in finance with no PO yet) + invoiced flag.
|
||||
function applyFinance(t: Ticket, fin: FinanceRow | undefined, cfg: ForgeConfig): void {
|
||||
if (!fin) return;
|
||||
t.poNumber = fin.po == null ? null : String(fin.po);
|
||||
t.invoiced = ts(fin.invoiced);
|
||||
if (t.finalCost == null) {
|
||||
const c = num(fin.cost);
|
||||
if (c != null) {
|
||||
t.finalCost = c;
|
||||
if (!t.currencyCode) t.currencyCode = ts(fin.currency);
|
||||
t.size = sizeOf(t.finalCost, t.currencyCode, cfg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadArchiveTickets(): Ticket[] {
|
||||
const dir = path.join(__dirname, 'data');
|
||||
const cfg = loadConfig();
|
||||
const finance = loadFinance(dir);
|
||||
const activeTickets: Ticket[] = [];
|
||||
const closedTickets: Ticket[] = [];
|
||||
let jira: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
const active = JSON.parse(fs.readFileSync(path.join(dir, 'active_archive.json'), 'utf-8'));
|
||||
jira = (active.jira ?? {}) as Record<string, unknown>;
|
||||
for (const raw of active.tickets ?? []) {
|
||||
const t = fromActive(raw as Record<string, unknown>, cfg);
|
||||
const j = (jira[t.number] as { key?: string | null }) ?? null;
|
||||
t.jira = j;
|
||||
if (j?.key) t.jiraKey = j.key;
|
||||
applyFinance(t, finance[t.number], cfg);
|
||||
activeTickets.push(t);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('seed: active archive skipped —', (err as Error).message);
|
||||
}
|
||||
|
||||
try {
|
||||
const closed = JSON.parse(fs.readFileSync(path.join(dir, 'closed_archive.json'), 'utf-8'));
|
||||
const map = (closed.tickets ?? {}) as Record<string, Record<string, unknown>>;
|
||||
for (const [number, raw] of Object.entries(map)) {
|
||||
const t = fromClosed(number, raw, cfg);
|
||||
const j = (jira[number] as { key?: string | null }) ?? null;
|
||||
t.jira = j;
|
||||
if (!t.jiraKey && j?.key) t.jiraKey = j.key;
|
||||
applyFinance(t, finance[number], cfg);
|
||||
closedTickets.push(t);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('seed: closed archive skipped —', (err as Error).message);
|
||||
}
|
||||
|
||||
return [...closedTickets, ...activeTickets];
|
||||
}
|
||||
|
||||
// One-time seed: if the tickets table is empty, load the bundled JSON archives.
|
||||
// Idempotent via upsert, so a redeploy never duplicates. Skipped silently once
|
||||
// the table has rows (real syncs from the extension take over).
|
||||
async function seedFromArchives(): Promise<void> {
|
||||
const { rows } = await pool.query<{ n: string }>(`SELECT count(*)::text AS n FROM tickets`);
|
||||
if (Number(rows[0]?.n ?? '0') > 0) return;
|
||||
|
||||
const tickets = loadArchiveTickets();
|
||||
if (tickets.length === 0) return;
|
||||
await upsertTickets(tickets);
|
||||
console.log(`Seeded ${tickets.length} tickets from bundled archives`);
|
||||
}
|
||||
|
||||
// Replace all tickets with a fresh load from the archives. Used by the reseed
|
||||
// CLI (server/reseed.ts) to reload after regenerating the archives from a dump.
|
||||
export async function reseedFromArchives(): Promise<number> {
|
||||
const tickets = loadArchiveTickets();
|
||||
await pool.query('TRUNCATE tickets');
|
||||
await upsertTickets(tickets);
|
||||
return tickets.length;
|
||||
}
|
||||
|
||||
// Attach-only Jira enrichment: update ONLY the `jira` JSONB (merged) and `jira_key`
|
||||
// on tickets that already exist, keyed by RITM number. Never touches
|
||||
// status/state/assignee/activity (see ADR — a Jira payload must not ride the
|
||||
// clobbering ticket upsert). Returns how many existing tickets matched.
|
||||
export interface JiraAttach { number: string; jira: Record<string, unknown>; jiraKey?: string | null; }
|
||||
export async function attachJira(items: JiraAttach[]): Promise<number> {
|
||||
if (items.length === 0) return 0;
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
let matched = 0;
|
||||
for (const it of items) {
|
||||
const { rowCount } = await client.query(
|
||||
`UPDATE tickets
|
||||
SET jira = COALESCE(jira, '{}'::jsonb) || $2::jsonb,
|
||||
jira_key = COALESCE($3, jira_key),
|
||||
synced_at = NOW()
|
||||
WHERE number = $1`,
|
||||
[it.number, JSON.stringify(it.jira ?? {}), it.jiraKey ?? null],
|
||||
);
|
||||
matched += rowCount ?? 0;
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return matched;
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert a batch of tickets by number. Used by both the seed and the sync
|
||||
// ingest endpoint. Runs in a single transaction.
|
||||
export async function upsertTickets(tickets: Ticket[]): Promise<number> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
for (const t of tickets) {
|
||||
await client.query(
|
||||
`INSERT INTO tickets (
|
||||
number, status, state, short_desc, assigned_to, assignment_group,
|
||||
brand, market, business_unit, requested_for, opened_at, due_date,
|
||||
state_changed_at, state_changed_by, last_activity_at, last_activity_by,
|
||||
last_comment, description, link, final_cost, ttfr_minutes,
|
||||
client_resp_minutes, fulfillment_date, first_reply_at, first_assigned_date,
|
||||
updated_at, jira, activity,
|
||||
currency_code, opened_by, opened_date, closed_date, to_do_at, in_uat_at,
|
||||
jira_key, ticket_year, size, po_number, invoiced, synced_at
|
||||
) VALUES (
|
||||
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,
|
||||
$21,$22,$23,$24,$25,$26,$27,$28,
|
||||
$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39, NOW()
|
||||
)
|
||||
-- Non-destructive: the sync collector sends a thin payload (state, assignee,
|
||||
-- group, timestamps). For the rich fields it omits (brand/market/description/
|
||||
-- lastComment/jira/analytics), COALESCE keeps the existing value when the
|
||||
-- incoming one is NULL, so a real sync never nulls seeded richness. Activity
|
||||
-- is kept unless the incoming payload actually carries entries.
|
||||
ON CONFLICT (number) DO UPDATE SET
|
||||
status=EXCLUDED.status, state=EXCLUDED.state, short_desc=EXCLUDED.short_desc,
|
||||
assigned_to=EXCLUDED.assigned_to, assignment_group=EXCLUDED.assignment_group,
|
||||
opened_at=COALESCE(EXCLUDED.opened_at, tickets.opened_at),
|
||||
due_date=COALESCE(EXCLUDED.due_date, tickets.due_date),
|
||||
state_changed_at=COALESCE(EXCLUDED.state_changed_at, tickets.state_changed_at),
|
||||
state_changed_by=COALESCE(EXCLUDED.state_changed_by, tickets.state_changed_by),
|
||||
last_activity_at=EXCLUDED.last_activity_at, last_activity_by=EXCLUDED.last_activity_by,
|
||||
last_comment=COALESCE(EXCLUDED.last_comment, tickets.last_comment),
|
||||
description=COALESCE(EXCLUDED.description, tickets.description),
|
||||
link=COALESCE(EXCLUDED.link, tickets.link),
|
||||
brand=COALESCE(EXCLUDED.brand, tickets.brand),
|
||||
market=COALESCE(EXCLUDED.market, tickets.market),
|
||||
business_unit=COALESCE(EXCLUDED.business_unit, tickets.business_unit),
|
||||
requested_for=COALESCE(EXCLUDED.requested_for, tickets.requested_for),
|
||||
final_cost=COALESCE(EXCLUDED.final_cost, tickets.final_cost),
|
||||
ttfr_minutes=COALESCE(EXCLUDED.ttfr_minutes, tickets.ttfr_minutes),
|
||||
client_resp_minutes=COALESCE(EXCLUDED.client_resp_minutes, tickets.client_resp_minutes),
|
||||
fulfillment_date=COALESCE(EXCLUDED.fulfillment_date, tickets.fulfillment_date),
|
||||
first_reply_at=COALESCE(EXCLUDED.first_reply_at, tickets.first_reply_at),
|
||||
first_assigned_date=COALESCE(EXCLUDED.first_assigned_date, tickets.first_assigned_date),
|
||||
updated_at=EXCLUDED.updated_at,
|
||||
jira=COALESCE(EXCLUDED.jira, tickets.jira),
|
||||
activity=CASE WHEN jsonb_array_length(EXCLUDED.activity) > 0
|
||||
THEN EXCLUDED.activity ELSE tickets.activity END,
|
||||
currency_code=COALESCE(EXCLUDED.currency_code, tickets.currency_code),
|
||||
opened_by=COALESCE(EXCLUDED.opened_by, tickets.opened_by),
|
||||
opened_date=COALESCE(EXCLUDED.opened_date, tickets.opened_date),
|
||||
closed_date=COALESCE(EXCLUDED.closed_date, tickets.closed_date),
|
||||
to_do_at=COALESCE(EXCLUDED.to_do_at, tickets.to_do_at),
|
||||
in_uat_at=COALESCE(EXCLUDED.in_uat_at, tickets.in_uat_at),
|
||||
jira_key=COALESCE(EXCLUDED.jira_key, tickets.jira_key),
|
||||
ticket_year=COALESCE(EXCLUDED.ticket_year, tickets.ticket_year),
|
||||
size=COALESCE(EXCLUDED.size, tickets.size),
|
||||
po_number=COALESCE(EXCLUDED.po_number, tickets.po_number),
|
||||
invoiced=COALESCE(EXCLUDED.invoiced, tickets.invoiced),
|
||||
synced_at=NOW()`,
|
||||
[
|
||||
t.number, t.status, t.state, t.shortDesc, t.assignedTo, t.assignmentGroup,
|
||||
t.brand, t.market, t.businessUnit, t.requestedFor, t.openedAt, t.dueDate,
|
||||
t.stateChangedAt, t.stateChangedBy, t.lastActivityAt, t.lastActivityBy,
|
||||
t.lastComment, t.description, t.link, t.finalCost, t.ttfrMinutes,
|
||||
t.clientRespMinutes, t.fulfillmentDate, t.firstReplyAt, t.firstAssignedDate,
|
||||
t.updatedAt, t.jira ? JSON.stringify(t.jira) : null, JSON.stringify(t.activity ?? []),
|
||||
t.currencyCode, t.openedBy, t.openedDate, t.closedDate, t.toDoAt, t.inUatAt,
|
||||
t.jiraKey, t.ticketYear, t.size, t.poNumber, t.invoiced,
|
||||
],
|
||||
);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return tickets.length;
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Map a DB row back to the wire Ticket shape.
|
||||
export function rowToTicket(r: Record<string, unknown>): Ticket {
|
||||
const iso = (v: unknown) => (v instanceof Date ? v.toISOString() : (v as string | null));
|
||||
// DATE columns come back as a JS Date at LOCAL midnight; toISOString() would
|
||||
// shift the calendar day under a non-UTC offset. Emit YYYY-MM-DD from local parts.
|
||||
const dateOnly = (v: unknown): string | null => {
|
||||
if (v == null) return null;
|
||||
if (v instanceof Date) {
|
||||
const y = v.getFullYear();
|
||||
const m = String(v.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(v.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
return String(v);
|
||||
};
|
||||
return {
|
||||
number: r.number as string,
|
||||
status: r.status as TicketStatus,
|
||||
state: (r.state as string) ?? '',
|
||||
shortDesc: (r.short_desc as string) ?? '',
|
||||
assignedTo: (r.assigned_to as string) ?? null,
|
||||
assignmentGroup: (r.assignment_group as string) ?? null,
|
||||
brand: (r.brand as string) ?? null,
|
||||
market: (r.market as string) ?? null,
|
||||
businessUnit: (r.business_unit as string) ?? null,
|
||||
requestedFor: (r.requested_for as string) ?? null,
|
||||
openedBy: (r.opened_by as string) ?? null,
|
||||
openedAt: iso(r.opened_at),
|
||||
openedDate: dateOnly(r.opened_date),
|
||||
closedDate: dateOnly(r.closed_date),
|
||||
toDoAt: iso(r.to_do_at),
|
||||
inUatAt: iso(r.in_uat_at),
|
||||
jiraKey: (r.jira_key as string) ?? null,
|
||||
currencyCode: (r.currency_code as string) ?? null,
|
||||
ticketYear: r.ticket_year != null ? Number(r.ticket_year) : null,
|
||||
size: (r.size as string) ?? null,
|
||||
poNumber: (r.po_number as string) ?? null,
|
||||
invoiced: (r.invoiced as string) ?? null,
|
||||
dueDate: dateOnly(r.due_date),
|
||||
stateChangedAt: iso(r.state_changed_at),
|
||||
stateChangedBy: (r.state_changed_by as string) ?? null,
|
||||
lastActivityAt: iso(r.last_activity_at),
|
||||
lastActivityBy: (r.last_activity_by as string) ?? null,
|
||||
lastComment: (r.last_comment as string) ?? null,
|
||||
description: (r.description as string) ?? null,
|
||||
link: (r.link as string) ?? null,
|
||||
finalCost: r.final_cost != null ? Number(r.final_cost) : null,
|
||||
ttfrMinutes: r.ttfr_minutes != null ? Number(r.ttfr_minutes) : null,
|
||||
clientRespMinutes: r.client_resp_minutes != null ? Number(r.client_resp_minutes) : null,
|
||||
fulfillmentDate: iso(r.fulfillment_date),
|
||||
firstReplyAt: iso(r.first_reply_at),
|
||||
firstAssignedDate: iso(r.first_assigned_date),
|
||||
updatedAt: iso(r.updated_at),
|
||||
jira: (r.jira as Ticket['jira']) ?? null,
|
||||
activity: (r.activity as Ticket['activity']) ?? [],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user