fix: remove unnecessary 'as any' casts and add proper types to templates
Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Failing after 4m29s
Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Failing after 4m29s
- Replace (req as any).requestId with req.requestId in index.ts, recover.ts, email-change.ts - Replace (err as any).status with proper Record<string, unknown> narrowing in error handler - Add InvoiceData, ReceiptData, ContactInfo, InvoiceItem, ReceiptItem interfaces to templates.ts - Replace all 'any' params in template functions with proper types - Add type-safety regression tests (grep-based) - 818 tests pass, tsc --noEmit: 0 errors
This commit is contained in:
parent
4057bd9d91
commit
2e8a240654
7 changed files with 109 additions and 32 deletions
|
|
@ -1,10 +1,10 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { renderTemplate, templates } from "../services/templates.js";
|
||||
import { renderTemplate, templates, TemplateData } from "../services/templates.js";
|
||||
|
||||
// Access esc via rendering — test that HTML entities are escaped in output
|
||||
describe("Template rendering", () => {
|
||||
it("throws for unknown template", () => {
|
||||
expect(() => renderTemplate("nonexistent", {})).toThrow("not found");
|
||||
expect(() => renderTemplate("nonexistent", {} as TemplateData)).toThrow("not found");
|
||||
});
|
||||
|
||||
it("invoice renders with correct totals", () => {
|
||||
|
|
|
|||
31
src/__tests__/type-safety.test.ts
Normal file
31
src/__tests__/type-safety.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { execSync } from "child_process";
|
||||
import path from "path";
|
||||
|
||||
describe("Type safety", () => {
|
||||
const srcDir = path.resolve(__dirname, "..");
|
||||
|
||||
it("should not use (req as any).requestId in production code — Express.Request is already augmented", () => {
|
||||
const result = execSync(
|
||||
`grep -r "(req as any)\\.requestId" --include="*.ts" --exclude-dir=__tests__ "${srcDir}" || true`,
|
||||
{ encoding: "utf-8" }
|
||||
);
|
||||
expect(result.trim()).toBe("");
|
||||
});
|
||||
|
||||
it("should not use (err as any) — use proper type narrowing instead", () => {
|
||||
const result = execSync(
|
||||
`grep -r "(err as any)" --include="*.ts" "${srcDir}" --exclude-dir=__tests__ || true`,
|
||||
{ encoding: "utf-8" }
|
||||
);
|
||||
expect(result.trim()).toBe("");
|
||||
});
|
||||
|
||||
it("should not use any types in templates.ts function parameters", () => {
|
||||
const result = execSync(
|
||||
`grep -E "\\(d: any\\)|\\(data: any\\)|\\(item: any\\)" "${srcDir}/services/templates.ts" || true`,
|
||||
{ encoding: "utf-8" }
|
||||
);
|
||||
expect(result.trim()).toBe("");
|
||||
});
|
||||
});
|
||||
|
|
@ -223,10 +223,10 @@ app.use((req, res) => {
|
|||
|
||||
// Global error handler — must be after all routes
|
||||
app.use((err: unknown, req: Request, res: Response, _next: NextFunction) => {
|
||||
const reqId = (req as any).requestId || "unknown";
|
||||
const reqId = req.requestId || "unknown";
|
||||
|
||||
// Check if this is a JSON parse error from express.json()
|
||||
if (err instanceof SyntaxError && 'status' in err && (err as any).status === 400 && 'body' in err) {
|
||||
if (err instanceof SyntaxError && 'status' in err && (err as Record<string, unknown>).status === 400 && 'body' in err) {
|
||||
logger.warn({ err, requestId: reqId, method: req.method, path: req.path }, "Invalid JSON body");
|
||||
if (!res.headersSent) {
|
||||
res.status(400).json({ error: "Invalid JSON in request body" });
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ router.post("/", emailChangeLimiter, async (req: Request, res: Response) => {
|
|||
|
||||
res.json({ status: "verification_sent", message: "A verification code has been sent to your new email address." });
|
||||
} catch (err: unknown) {
|
||||
const reqId = (req as any).requestId || "unknown";
|
||||
const reqId = req.requestId || "unknown";
|
||||
logger.error({ err, requestId: reqId }, "Unhandled error in POST /email-change");
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
|
|
@ -207,7 +207,7 @@ router.post("/verify", async (req: Request, res: Response) => {
|
|||
break;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const reqId = (req as any).requestId || "unknown";
|
||||
const reqId = req.requestId || "unknown";
|
||||
logger.error({ err, requestId: reqId }, "Unhandled error in POST /email-change/verify");
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ router.post("/", recoverLimiter, async (req: Request, res: Response) => {
|
|||
|
||||
res.json({ status: "recovery_sent", message: "If an account exists for this email, a verification code has been sent." });
|
||||
} catch (err: unknown) {
|
||||
const reqId = (req as any).requestId || "unknown";
|
||||
const reqId = req.requestId || "unknown";
|
||||
logger.error({ err, requestId: reqId }, "Unhandled error in POST /recover");
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
|
|
@ -210,7 +210,7 @@ router.post("/verify", recoverLimiter, async (req: Request, res: Response) => {
|
|||
break;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const reqId = (req as any).requestId || "unknown";
|
||||
const reqId = req.requestId || "unknown";
|
||||
logger.error({ err, requestId: reqId }, "Unhandled error in POST /recover/verify");
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Router, Request, Response } from "express";
|
||||
import { renderPdf } from "../services/browser.js";
|
||||
import logger from "../services/logger.js";
|
||||
import { templates, renderTemplate } from "../services/templates.js";
|
||||
import { templates, renderTemplate, TemplateData } from "../services/templates.js";
|
||||
import { sanitizeFilename } from "../utils/sanitize.js";
|
||||
import { validatePdfOptions } from "../utils/pdf-options.js";
|
||||
|
||||
|
|
@ -165,7 +165,7 @@ templatesRouter.post("/:id/render", async (req: Request, res: Response) => {
|
|||
}
|
||||
const sanitizedPdf = { format: "A4" as const, ...validation.sanitized };
|
||||
|
||||
const html = renderTemplate(id, data);
|
||||
const html = renderTemplate(id, data as TemplateData);
|
||||
const { pdf, durationMs } = await renderPdf(html, sanitizedPdf);
|
||||
|
||||
const filename = sanitizeFilename(data._filename || `${id}.pdf`);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,52 @@
|
|||
export interface ContactInfo {
|
||||
name: string;
|
||||
address?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
vatId?: string;
|
||||
}
|
||||
|
||||
export interface InvoiceItem {
|
||||
description: string;
|
||||
quantity?: number;
|
||||
unitPrice?: number;
|
||||
taxRate?: number;
|
||||
}
|
||||
|
||||
export interface InvoiceData {
|
||||
invoiceNumber: string;
|
||||
date: string;
|
||||
dueDate?: string;
|
||||
from: ContactInfo;
|
||||
to: ContactInfo;
|
||||
items: InvoiceItem[];
|
||||
currency?: string;
|
||||
notes?: string;
|
||||
paymentDetails?: string;
|
||||
}
|
||||
|
||||
export interface ReceiptItem {
|
||||
description: string;
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
export interface ReceiptData {
|
||||
receiptNumber: string;
|
||||
date: string;
|
||||
from: ContactInfo;
|
||||
to?: ContactInfo;
|
||||
items: ReceiptItem[];
|
||||
currency?: string;
|
||||
paymentMethod?: string;
|
||||
}
|
||||
|
||||
export type TemplateData = InvoiceData | ReceiptData;
|
||||
|
||||
export interface TemplateDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
fields: { name: string; type: string; required: boolean; description: string }[];
|
||||
render: (data: any) => string;
|
||||
render: (data: TemplateData) => string;
|
||||
}
|
||||
|
||||
export const templates: Record<string, TemplateDefinition> = {
|
||||
|
|
@ -47,14 +91,15 @@ function esc(s: string): string {
|
|||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function renderInvoice(d: any): string {
|
||||
const cur = esc(d.currency || "€");
|
||||
const items = d.items || [];
|
||||
function renderInvoice(d: TemplateData): string {
|
||||
const inv = d as InvoiceData;
|
||||
const cur = esc(inv.currency || "€");
|
||||
const items = inv.items || [];
|
||||
let subtotal = 0;
|
||||
let totalTax = 0;
|
||||
|
||||
const rows = items
|
||||
.map((item: any) => {
|
||||
.map((item: InvoiceItem) => {
|
||||
const qty = Number(item.quantity) || 1;
|
||||
const price = Number(item.unitPrice) || 0;
|
||||
const taxRate = Number(item.taxRate) || 0;
|
||||
|
|
@ -73,8 +118,8 @@ function renderInvoice(d: any): string {
|
|||
.join("");
|
||||
|
||||
const total = subtotal + totalTax;
|
||||
const from = d.from || {};
|
||||
const to = d.to || {};
|
||||
const from = inv.from || ({} as ContactInfo);
|
||||
const to = inv.to || ({} as ContactInfo);
|
||||
|
||||
return `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
|
@ -98,9 +143,9 @@ function renderInvoice(d: any): string {
|
|||
<div class="header">
|
||||
<h1>INVOICE</h1>
|
||||
<div class="meta">
|
||||
<div><strong>#${esc(d.invoiceNumber)}</strong></div>
|
||||
<div>Date: ${esc(d.date)}</div>
|
||||
${d.dueDate ? `<div>Due: ${esc(d.dueDate)}</div>` : ""}
|
||||
<div><strong>#${esc(inv.invoiceNumber)}</strong></div>
|
||||
<div>Date: ${esc(inv.date)}</div>
|
||||
${inv.dueDate ? `<div>Due: ${esc(inv.dueDate)}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="parties">
|
||||
|
|
@ -128,26 +173,27 @@ function renderInvoice(d: any): string {
|
|||
<div>Tax: ${cur}${totalTax.toFixed(2)}</div>
|
||||
<div class="total">Total: ${cur}${total.toFixed(2)}</div>
|
||||
</div>
|
||||
${d.paymentDetails ? `<div class="footer"><strong>Payment Details</strong><br>${esc(d.paymentDetails).replace(/\n/g, "<br>")}</div>` : ""}
|
||||
${d.notes ? `<div class="footer"><strong>Notes</strong><br>${esc(d.notes)}</div>` : ""}
|
||||
${inv.paymentDetails ? `<div class="footer"><strong>Payment Details</strong><br>${esc(inv.paymentDetails).replace(/\n/g, "<br>")}</div>` : ""}
|
||||
${inv.notes ? `<div class="footer"><strong>Notes</strong><br>${esc(inv.notes)}</div>` : ""}
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
function renderReceipt(d: any): string {
|
||||
const cur = esc(d.currency || "€");
|
||||
const items = d.items || [];
|
||||
function renderReceipt(d: TemplateData): string {
|
||||
const rcpt = d as ReceiptData;
|
||||
const cur = esc(rcpt.currency || "€");
|
||||
const items = rcpt.items || [];
|
||||
let total = 0;
|
||||
|
||||
const rows = items
|
||||
.map((item: any) => {
|
||||
.map((item: ReceiptItem) => {
|
||||
const amount = Number(item.amount) || 0;
|
||||
total += amount;
|
||||
return `<tr><td>${esc(item.description)}</td><td style="text-align:right">${cur}${amount.toFixed(2)}</td></tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const from = d.from || {};
|
||||
const to = d.to || {};
|
||||
const from = rcpt.from || ({} as ContactInfo);
|
||||
const to = rcpt.to || ({} as ContactInfo);
|
||||
|
||||
return `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
||||
body { font-family: 'Courier New', monospace; font-size: 13px; max-width: 320px; margin: 0 auto; padding: 30px 20px; }
|
||||
|
|
@ -161,19 +207,19 @@ function renderReceipt(d: any): string {
|
|||
<h1>${esc(from.name)}</h1>
|
||||
${from.address ? `<div class="center">${esc(from.address)}</div>` : ""}
|
||||
<hr>
|
||||
<div>Receipt #${esc(d.receiptNumber)}</div>
|
||||
<div>Date: ${esc(d.date)}</div>
|
||||
<div>Receipt #${esc(rcpt.receiptNumber)}</div>
|
||||
<div>Date: ${esc(rcpt.date)}</div>
|
||||
${to?.name ? `<div>Customer: ${esc(to.name)}</div>` : ""}
|
||||
<hr>
|
||||
<table>${rows}</table>
|
||||
<hr>
|
||||
<table><tr><td class="total">TOTAL</td><td class="total" style="text-align:right">${cur}${total.toFixed(2)}</td></tr></table>
|
||||
${d.paymentMethod ? `<hr><div>Paid via: ${esc(d.paymentMethod)}</div>` : ""}
|
||||
${rcpt.paymentMethod ? `<hr><div>Paid via: ${esc(rcpt.paymentMethod)}</div>` : ""}
|
||||
<hr><div class="center">Thank you!</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
export function renderTemplate(id: string, data: any): string {
|
||||
export function renderTemplate(id: string, data: TemplateData): string {
|
||||
const template = templates[id];
|
||||
if (!template) throw new Error(`Template '${id}' not found`);
|
||||
return template.render(data);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue