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

- 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:
OpenClaw Subagent 2026-03-19 08:12:30 +01:00
parent 4057bd9d91
commit 2e8a240654
7 changed files with 109 additions and 32 deletions

View file

@ -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", () => {

View 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("");
});
});

View file

@ -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" });

View file

@ -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" });
}

View file

@ -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" });
}

View file

@ -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`);

View file

@ -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, "&#39;");
}
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);