Initial TicketTracker release
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
import crypto from "node:crypto";
|
||||
import { promisify } from "node:util";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { query } from "./db.js";
|
||||
|
||||
const scrypt = promisify(crypto.scrypt);
|
||||
const sessionCookieName = "tickettracker_session";
|
||||
const sessionMaxAgeMs = Number(process.env.SESSION_MAX_AGE_MS ?? 1000 * 60 * 60 * 24 * 14);
|
||||
|
||||
export type UserRole = "admin" | "user";
|
||||
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
role: UserRole;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: AuthUser;
|
||||
authSessionHash?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string) {
|
||||
const salt = crypto.randomBytes(16).toString("base64url");
|
||||
const key = (await scrypt(password, salt, 64)) as Buffer;
|
||||
return `scrypt$${salt}$${key.toString("base64url")}`;
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, storedHash: string) {
|
||||
const [algorithm, salt, expected] = storedHash.split("$");
|
||||
|
||||
if (algorithm !== "scrypt" || !salt || !expected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedBuffer = Buffer.from(expected, "base64url");
|
||||
const actualBuffer = (await scrypt(password, salt, expectedBuffer.length)) as Buffer;
|
||||
|
||||
return expectedBuffer.length === actualBuffer.length && crypto.timingSafeEqual(expectedBuffer, actualBuffer);
|
||||
}
|
||||
|
||||
export function hashToken(token: string) {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
function readCookie(req: Request, name: string) {
|
||||
const rawCookie = req.headers.cookie;
|
||||
|
||||
if (!rawCookie) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cookies = rawCookie.split(";").map((part) => part.trim());
|
||||
const prefix = `${name}=`;
|
||||
const match = cookies.find((cookie) => cookie.startsWith(prefix));
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return decodeURIComponent(match.slice(prefix.length));
|
||||
}
|
||||
|
||||
function publicUser(user: AuthUser) {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
role: user.role,
|
||||
active: user.active
|
||||
};
|
||||
}
|
||||
|
||||
export async function createAuthSession(res: Response, userId: string) {
|
||||
const token = crypto.randomBytes(32).toString("base64url");
|
||||
const tokenHash = hashToken(token);
|
||||
const expiresAt = new Date(Date.now() + sessionMaxAgeMs);
|
||||
|
||||
await query(
|
||||
`
|
||||
INSERT INTO auth_sessions (token_hash, user_id, expires_at)
|
||||
VALUES ($1, $2, $3);
|
||||
`,
|
||||
[tokenHash, userId, expiresAt.toISOString()]
|
||||
);
|
||||
|
||||
res.cookie(sessionCookieName, token, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.COOKIE_SECURE === "true",
|
||||
maxAge: sessionMaxAgeMs,
|
||||
path: "/"
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearAuthSession(req: Request, res: Response) {
|
||||
if (req.authSessionHash) {
|
||||
await query("DELETE FROM auth_sessions WHERE token_hash = $1;", [req.authSessionHash]);
|
||||
}
|
||||
|
||||
res.clearCookie(sessionCookieName, {
|
||||
sameSite: "lax",
|
||||
secure: process.env.COOKIE_SECURE === "true",
|
||||
path: "/"
|
||||
});
|
||||
}
|
||||
|
||||
export async function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const token = readCookie(req, sessionCookieName);
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({ error: "Nicht angemeldet" });
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenHash = hashToken(token);
|
||||
const result = await query<AuthUser>(
|
||||
`
|
||||
SELECT u.id, u.username, u.display_name, u.role, u.active
|
||||
FROM auth_sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = $1
|
||||
AND s.expires_at > now()
|
||||
AND u.active = true;
|
||||
`,
|
||||
[tokenHash]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
res.status(401).json({ error: "Sitzung ist abgelaufen" });
|
||||
return;
|
||||
}
|
||||
|
||||
req.user = publicUser(result.rows[0]);
|
||||
req.authSessionHash = tokenHash;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAdmin(req: Request, res: Response, next: NextFunction) {
|
||||
if (req.user?.role !== "admin") {
|
||||
res.status(403).json({ error: "Adminrechte erforderlich" });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export function currentUser(req: Request) {
|
||||
if (!req.user) {
|
||||
throw Object.assign(new Error("Nicht angemeldet"), { status: 401 });
|
||||
}
|
||||
|
||||
return req.user;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import pg from "pg";
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
|
||||
if (!connectionString) {
|
||||
throw new Error("DATABASE_URL is required");
|
||||
}
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString,
|
||||
max: Number(process.env.DB_POOL_SIZE ?? 10),
|
||||
idleTimeoutMillis: 30_000
|
||||
});
|
||||
|
||||
export async function query<T extends pg.QueryResultRow = pg.QueryResultRow>(text: string, params: unknown[] = []) {
|
||||
return pool.query<T>(text, params);
|
||||
}
|
||||
|
||||
export async function withTransaction<T>(callback: (client: pg.PoolClient) => Promise<T>) {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const result = await callback(client);
|
||||
await client.query("COMMIT");
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,398 @@
|
||||
import { query } from "./db.js";
|
||||
import { hashPassword } from "./auth.js";
|
||||
|
||||
async function ensureMigrationAdmin() {
|
||||
const username = process.env.ADMIN_USERNAME?.trim() || "admin";
|
||||
const password = process.env.ADMIN_PASSWORD ?? "admin";
|
||||
const displayName = process.env.ADMIN_DISPLAY_NAME?.trim() || "Admin";
|
||||
|
||||
const existingUsers = await query<{ count: string }>("SELECT COUNT(*) AS count FROM users;");
|
||||
|
||||
if (Number(existingUsers.rows[0]?.count ?? 0) === 0) {
|
||||
await query(
|
||||
`
|
||||
INSERT INTO users (username, display_name, password_hash, role)
|
||||
VALUES ($1, $2, $3, 'admin');
|
||||
`,
|
||||
[username, displayName, await hashPassword(password)]
|
||||
);
|
||||
|
||||
if (!process.env.ADMIN_PASSWORD) {
|
||||
console.warn("Initialer Admin wurde mit admin/admin angelegt. Bitte Passwort über ADMIN_PASSWORD setzen oder im Adminbereich ändern.");
|
||||
}
|
||||
}
|
||||
|
||||
const adminResult = await query<{ id: string }>("SELECT id FROM users WHERE role = 'admin' ORDER BY id ASC LIMIT 1;");
|
||||
|
||||
if (adminResult.rows.length === 0) {
|
||||
throw new Error("At least one admin user is required");
|
||||
}
|
||||
|
||||
return adminResult.rows[0].id;
|
||||
}
|
||||
|
||||
async function ensureClosureOwnership(table: string, column: string, primaryKeyColumns: string[], adminId: string) {
|
||||
await query(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES users(id);`);
|
||||
await query(`UPDATE ${table} SET user_id = $1 WHERE user_id IS NULL;`, [adminId]);
|
||||
await query(`ALTER TABLE ${table} ALTER COLUMN user_id SET NOT NULL;`);
|
||||
await query(`ALTER TABLE ${table} DROP CONSTRAINT IF EXISTS ${table}_pkey;`);
|
||||
await query(`ALTER TABLE ${table} ADD CONSTRAINT ${table}_pkey PRIMARY KEY (${primaryKeyColumns.join(", ")}, user_id);`);
|
||||
await query(`CREATE INDEX IF NOT EXISTS idx_${table}_${column}_user ON ${table}(${column}, user_id);`);
|
||||
}
|
||||
|
||||
export async function migrate() {
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT users_role_valid CHECK (role IN ('admin', 'user')),
|
||||
CONSTRAINT users_username_format CHECK (char_length(username) BETWEEN 3 AND 40 AND username !~ '\\s')
|
||||
);
|
||||
`);
|
||||
|
||||
await query(`
|
||||
ALTER TABLE users
|
||||
DROP CONSTRAINT IF EXISTS users_username_format,
|
||||
ADD CONSTRAINT users_username_format CHECK (char_length(username) BETWEEN 3 AND 40 AND username !~ '\\s');
|
||||
`);
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
|
||||
await query("CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions(user_id);");
|
||||
await query("CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions(expires_at);");
|
||||
|
||||
const adminId = await ensureMigrationAdmin();
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS organizations (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
zammad_id BIGINT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
|
||||
await query("ALTER TABLE organizations DROP COLUMN IF EXISTS active;");
|
||||
await query("ALTER TABLE organizations DROP COLUMN IF EXISTS shared;");
|
||||
await query("ALTER TABLE organizations DROP COLUMN IF EXISTS vip;");
|
||||
await query("ALTER TABLE organizations DROP COLUMN IF EXISTS domain;");
|
||||
await query("ALTER TABLE organizations DROP COLUMN IF EXISTS note;");
|
||||
await query("ALTER TABLE organizations DROP COLUMN IF EXISTS zammad_updated_at;");
|
||||
await query("CREATE INDEX IF NOT EXISTS idx_organizations_name_lower ON organizations(lower(name));");
|
||||
await query("DROP INDEX IF EXISTS idx_organizations_active_name;");
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
ticket_number TEXT NOT NULL UNIQUE,
|
||||
organization_id BIGINT REFERENCES organizations(id),
|
||||
customer_name TEXT,
|
||||
work_type TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT ticket_number_format CHECK (ticket_number ~ '^Ticket#[0-9]{6}$'),
|
||||
CONSTRAINT ticket_work_type_valid CHECK (work_type IS NULL OR work_type IN ('support', 'consulting'))
|
||||
);
|
||||
`);
|
||||
|
||||
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS customer_name TEXT;");
|
||||
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS organization_id BIGINT REFERENCES organizations(id);");
|
||||
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS work_type TEXT;");
|
||||
await query(`
|
||||
ALTER TABLE tickets
|
||||
DROP CONSTRAINT IF EXISTS ticket_number_format,
|
||||
ADD CONSTRAINT ticket_number_format CHECK (ticket_number ~ '^(Ticket#[0-9]{6}|Fix#[0-9]+)$');
|
||||
`);
|
||||
await query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'ticket_work_type_valid'
|
||||
) THEN
|
||||
ALTER TABLE tickets
|
||||
ADD CONSTRAINT ticket_work_type_valid CHECK (work_type IS NULL OR work_type IN ('support', 'consulting'));
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
organization_id BIGINT REFERENCES organizations(id),
|
||||
customer_name TEXT NOT NULL,
|
||||
activity TEXT NOT NULL,
|
||||
work_type TEXT NOT NULL,
|
||||
user_id BIGINT REFERENCES users(id),
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
ended_at TIMESTAMPTZ NOT NULL,
|
||||
duration_seconds INTEGER NOT NULL,
|
||||
rounded_minutes INTEGER NOT NULL,
|
||||
billing_status TEXT,
|
||||
billing_updated_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT work_type_valid CHECK (work_type IN ('support', 'consulting')),
|
||||
CONSTRAINT billing_status_valid CHECK (billing_status IS NULL OR billing_status IN ('billed', 'non_billable')),
|
||||
CONSTRAINT duration_non_negative CHECK (duration_seconds >= 0),
|
||||
CONSTRAINT rounded_minutes_positive CHECK (rounded_minutes >= 1),
|
||||
CONSTRAINT ended_after_started CHECK (ended_at >= started_at)
|
||||
);
|
||||
`);
|
||||
|
||||
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES users(id);");
|
||||
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS organization_id BIGINT REFERENCES organizations(id);");
|
||||
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_billing_id BIGINT;");
|
||||
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_billing_slot_id BIGINT;");
|
||||
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_date DATE;");
|
||||
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_key TEXT;");
|
||||
await query("UPDATE sessions SET user_id = $1 WHERE user_id IS NULL;", [adminId]);
|
||||
await query("ALTER TABLE sessions ALTER COLUMN user_id SET NOT NULL;");
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS recurring_billings (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
ticket_number TEXT,
|
||||
organization_id BIGINT NOT NULL REFERENCES organizations(id),
|
||||
activity TEXT NOT NULL,
|
||||
work_type TEXT NOT NULL,
|
||||
recurrence_type TEXT NOT NULL,
|
||||
interval_value INTEGER NOT NULL DEFAULT 1,
|
||||
valid_from DATE NOT NULL,
|
||||
valid_until DATE,
|
||||
start_time TIME NOT NULL DEFAULT '09:00',
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT recurring_billings_work_type_valid CHECK (work_type IN ('support', 'consulting')),
|
||||
CONSTRAINT recurring_billings_type_valid CHECK (recurrence_type IN ('weekly', 'every_n_weeks')),
|
||||
CONSTRAINT recurring_billings_interval_positive CHECK (interval_value >= 1),
|
||||
CONSTRAINT recurring_billings_valid_range CHECK (valid_until IS NULL OR valid_until >= valid_from)
|
||||
);
|
||||
`);
|
||||
|
||||
await query("ALTER TABLE recurring_billings ALTER COLUMN ticket_number DROP NOT NULL;");
|
||||
await query("ALTER TABLE recurring_billings DROP CONSTRAINT IF EXISTS recurring_billings_ticket_number_format;");
|
||||
await query("UPDATE recurring_billings SET recurrence_type = 'every_n_weeks' WHERE recurrence_type = 'every_n_days';");
|
||||
await query(`
|
||||
ALTER TABLE recurring_billings
|
||||
DROP CONSTRAINT IF EXISTS recurring_billings_type_valid,
|
||||
ADD CONSTRAINT recurring_billings_type_valid CHECK (recurrence_type IN ('weekly', 'every_n_weeks'));
|
||||
`);
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS recurring_billing_slots (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
recurring_billing_id BIGINT NOT NULL REFERENCES recurring_billings(id) ON DELETE CASCADE,
|
||||
weekday INTEGER,
|
||||
start_time TIME,
|
||||
duration_minutes INTEGER NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT recurring_billing_slots_weekday_valid CHECK (weekday IS NULL OR weekday BETWEEN 0 AND 6),
|
||||
CONSTRAINT recurring_billing_slots_duration_positive CHECK (duration_minutes >= 1)
|
||||
);
|
||||
`);
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS recurring_billing_exceptions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
recurring_billing_id BIGINT NOT NULL REFERENCES recurring_billings(id) ON DELETE CASCADE,
|
||||
recurring_billing_slot_id BIGINT REFERENCES recurring_billing_slots(id) ON DELETE CASCADE,
|
||||
occurrence_date DATE NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT recurring_billing_exceptions_action_valid CHECK (action IN ('cancelled')),
|
||||
UNIQUE (recurring_billing_id, recurring_billing_slot_id, occurrence_date)
|
||||
);
|
||||
`);
|
||||
|
||||
await query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'sessions_recurring_billing_fkey'
|
||||
) THEN
|
||||
ALTER TABLE sessions
|
||||
ADD CONSTRAINT sessions_recurring_billing_fkey
|
||||
FOREIGN KEY (recurring_billing_id) REFERENCES recurring_billings(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'sessions_recurring_billing_slot_fkey'
|
||||
) THEN
|
||||
ALTER TABLE sessions
|
||||
ADD CONSTRAINT sessions_recurring_billing_slot_fkey
|
||||
FOREIGN KEY (recurring_billing_slot_id) REFERENCES recurring_billing_slots(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS ticket_month_closures (
|
||||
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
user_id BIGINT REFERENCES users(id),
|
||||
month DATE NOT NULL,
|
||||
closed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS month_closures (
|
||||
user_id BIGINT REFERENCES users(id),
|
||||
month DATE NOT NULL,
|
||||
closed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS ticket_day_closures (
|
||||
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
user_id BIGINT REFERENCES users(id),
|
||||
day DATE NOT NULL,
|
||||
closed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS day_closures (
|
||||
user_id BIGINT REFERENCES users(id),
|
||||
day DATE NOT NULL,
|
||||
closed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS ticket_day_billings (
|
||||
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
day DATE NOT NULL,
|
||||
billed_minutes INTEGER NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (ticket_id, user_id, day),
|
||||
CONSTRAINT ticket_day_billings_minutes_non_negative CHECK (billed_minutes >= 0)
|
||||
);
|
||||
`);
|
||||
|
||||
await ensureClosureOwnership("ticket_month_closures", "month", ["ticket_id", "month"], adminId);
|
||||
await ensureClosureOwnership("month_closures", "month", ["month"], adminId);
|
||||
await ensureClosureOwnership("ticket_day_closures", "day", ["ticket_id", "day"], adminId);
|
||||
await ensureClosureOwnership("day_closures", "day", ["day"], adminId);
|
||||
|
||||
await query("CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at);");
|
||||
await query("CREATE INDEX IF NOT EXISTS idx_sessions_ticket_started ON sessions(ticket_id, started_at);");
|
||||
await query("CREATE INDEX IF NOT EXISTS idx_sessions_user_started ON sessions(user_id, started_at);");
|
||||
await query("CREATE INDEX IF NOT EXISTS idx_sessions_user_ticket_started ON sessions(user_id, ticket_id, started_at);");
|
||||
await query("CREATE INDEX IF NOT EXISTS idx_sessions_user_organization_started ON sessions(user_id, organization_id, started_at);");
|
||||
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_recurring_occurrence_key ON sessions(recurring_occurrence_key) WHERE recurring_occurrence_key IS NOT NULL;");
|
||||
await query("CREATE INDEX IF NOT EXISTS idx_sessions_billing_status ON sessions(billing_status);");
|
||||
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_closures_day ON ticket_day_closures(day);");
|
||||
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billings_user_day ON ticket_day_billings(user_id, day);");
|
||||
await query("CREATE INDEX IF NOT EXISTS idx_recurring_billings_user_active ON recurring_billings(user_id, active);");
|
||||
await query("UPDATE recurring_billings SET active = true WHERE active = false;");
|
||||
await query(`
|
||||
WITH deleted_orphan_sessions AS (
|
||||
DELETE FROM sessions s
|
||||
WHERE s.recurring_occurrence_key IS NOT NULL
|
||||
AND split_part(s.recurring_occurrence_key, ':', 1) ~ '^[0-9]+$'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM recurring_billings rb
|
||||
WHERE rb.id = split_part(s.recurring_occurrence_key, ':', 1)::bigint
|
||||
)
|
||||
RETURNING
|
||||
ticket_id,
|
||||
user_id,
|
||||
(started_at AT TIME ZONE 'UTC')::date AS day,
|
||||
date_trunc('month', started_at AT TIME ZONE 'UTC')::date AS month
|
||||
),
|
||||
deleted_ticket_month_closures AS (
|
||||
DELETE FROM ticket_month_closures tmc
|
||||
USING deleted_orphan_sessions dos
|
||||
WHERE tmc.ticket_id = dos.ticket_id
|
||||
AND tmc.user_id = dos.user_id
|
||||
AND tmc.month = dos.month
|
||||
RETURNING tmc.ticket_id
|
||||
),
|
||||
deleted_month_closures AS (
|
||||
DELETE FROM month_closures mc
|
||||
USING deleted_orphan_sessions dos
|
||||
WHERE mc.user_id = dos.user_id
|
||||
AND mc.month = dos.month
|
||||
RETURNING mc.user_id
|
||||
),
|
||||
deleted_ticket_day_closures AS (
|
||||
DELETE FROM ticket_day_closures tdc
|
||||
USING deleted_orphan_sessions dos
|
||||
WHERE tdc.ticket_id = dos.ticket_id
|
||||
AND tdc.user_id = dos.user_id
|
||||
AND tdc.day = dos.day
|
||||
RETURNING tdc.ticket_id
|
||||
),
|
||||
deleted_day_closures AS (
|
||||
DELETE FROM day_closures dc
|
||||
USING deleted_orphan_sessions dos
|
||||
WHERE dc.user_id = dos.user_id
|
||||
AND dc.day = dos.day
|
||||
RETURNING dc.user_id
|
||||
)
|
||||
DELETE FROM tickets t
|
||||
WHERE t.id IN (SELECT ticket_id FROM deleted_orphan_sessions)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sessions s
|
||||
WHERE s.ticket_id = t.id
|
||||
);
|
||||
`);
|
||||
await query(`
|
||||
DELETE FROM tickets t
|
||||
WHERE t.ticket_number ~ '^Fix#[0-9]+$'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sessions s
|
||||
WHERE s.ticket_id = t.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM recurring_billings rb
|
||||
WHERE rb.id = substring(t.ticket_number FROM 5)::bigint
|
||||
);
|
||||
`);
|
||||
|
||||
await query(`
|
||||
UPDATE tickets t
|
||||
SET customer_name = latest.customer_name,
|
||||
work_type = latest.work_type
|
||||
FROM (
|
||||
SELECT DISTINCT ON (ticket_id)
|
||||
ticket_id,
|
||||
customer_name,
|
||||
work_type
|
||||
FROM sessions
|
||||
ORDER BY ticket_id, started_at DESC
|
||||
) latest
|
||||
WHERE t.id = latest.ticket_id
|
||||
AND (t.customer_name IS NULL OR t.work_type IS NULL);
|
||||
`);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
export const ticketPattern = /^Ticket#\d{6}$/;
|
||||
export const trackableTicketPattern = /^(Ticket#\d{6}|Fix#\d+)$/;
|
||||
export const monthPattern = /^\d{4}-\d{2}$/;
|
||||
export const dayPattern = /^\d{4}-\d{2}-\d{2}$/;
|
||||
export const usernamePattern = /^\S{3,40}$/u;
|
||||
|
||||
export function requireString(value: unknown, field: string) {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw badRequest(`${field} is required`);
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function parseTicketNumber(value: unknown) {
|
||||
const ticketNumber = requireString(value, "ticketNumber");
|
||||
|
||||
if (!ticketPattern.test(ticketNumber)) {
|
||||
throw badRequest("ticketNumber must match Ticket#XXXXXX");
|
||||
}
|
||||
|
||||
return ticketNumber;
|
||||
}
|
||||
|
||||
export function parseTrackableTicketNumber(value: unknown) {
|
||||
const ticketNumber = requireString(value, "ticketNumber");
|
||||
|
||||
if (!trackableTicketPattern.test(ticketNumber)) {
|
||||
throw badRequest("ticketNumber must match Ticket#XXXXXX or Fix#ID");
|
||||
}
|
||||
|
||||
return ticketNumber;
|
||||
}
|
||||
|
||||
export function parseUsername(value: unknown) {
|
||||
const username = requireString(value, "username");
|
||||
|
||||
if (!usernamePattern.test(username)) {
|
||||
throw badRequest("username must be 3-40 characters without spaces");
|
||||
}
|
||||
|
||||
return username;
|
||||
}
|
||||
|
||||
export function parseMonth(value: string) {
|
||||
if (!monthPattern.test(value)) {
|
||||
throw badRequest("month must use YYYY-MM");
|
||||
}
|
||||
|
||||
const monthStart = `${value}-01`;
|
||||
const date = new Date(`${monthStart}T00:00:00.000Z`);
|
||||
|
||||
if (Number.isNaN(date.getTime()) || date.getUTCMonth() + 1 !== Number(value.slice(5, 7))) {
|
||||
throw badRequest("month is invalid");
|
||||
}
|
||||
|
||||
const next = new Date(date);
|
||||
next.setUTCMonth(next.getUTCMonth() + 1);
|
||||
|
||||
return {
|
||||
label: value,
|
||||
start: monthStart,
|
||||
startIso: date.toISOString(),
|
||||
endIso: next.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
export function parseDay(value: string) {
|
||||
if (!dayPattern.test(value)) {
|
||||
throw badRequest("day must use YYYY-MM-DD");
|
||||
}
|
||||
|
||||
const date = new Date(`${value}T00:00:00.000Z`);
|
||||
|
||||
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) {
|
||||
throw badRequest("day is invalid");
|
||||
}
|
||||
|
||||
const next = new Date(date);
|
||||
next.setUTCDate(next.getUTCDate() + 1);
|
||||
|
||||
return {
|
||||
label: value,
|
||||
start: value,
|
||||
startIso: date.toISOString(),
|
||||
endIso: next.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePositiveInteger(value: unknown, field: string) {
|
||||
const number = Number(value);
|
||||
|
||||
if (!Number.isInteger(number) || number < 0) {
|
||||
throw badRequest(`${field} must be a positive integer`);
|
||||
}
|
||||
|
||||
return number;
|
||||
}
|
||||
|
||||
export function parseWorkType(value: unknown) {
|
||||
if (value !== "support" && value !== "consulting") {
|
||||
throw badRequest("workType must be support or consulting");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseBillingStatus(value: unknown) {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value !== "billed" && value !== "non_billable") {
|
||||
throw badRequest("billingStatus must be billed, non_billable or null");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseIsoDate(value: unknown, field: string) {
|
||||
const raw = requireString(value, field);
|
||||
const date = new Date(raw);
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw badRequest(`${field} must be an ISO date`);
|
||||
}
|
||||
|
||||
return date;
|
||||
}
|
||||
|
||||
export function badRequest(message: string) {
|
||||
const error = new Error(message) as Error & { status?: number };
|
||||
error.status = 400;
|
||||
return error;
|
||||
}
|
||||
Reference in New Issue
Block a user