immortal-js 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +577 -0
- package/docs/CHANGELOG.md +21 -0
- package/docs/CONTRIBUTING.md +41 -0
- package/docs/api/chaos.md +179 -0
- package/docs/api/dashboard.md +109 -0
- package/docs/api/isolation.md +191 -0
- package/docs/api/lifecycle.md +187 -0
- package/docs/api/monitoring.md +313 -0
- package/docs/api/plugins.md +217 -0
- package/docs/api/recovery.md +267 -0
- package/docs/api/safe-zone.md +236 -0
- package/docs/api/supervision.md +285 -0
- package/docs/guides/configuration.md +168 -0
- package/docs/guides/express.md +171 -0
- package/docs/guides/fastify.md +188 -0
- package/docs/guides/koa.md +102 -0
- package/docs/guides/nestjs.md +182 -0
- package/docs/guides/testing.md +91 -0
- package/examples/express-basic/index.ts +462 -0
- package/examples/express-basic/package.json +21 -0
- package/examples/express-basic/tsconfig.json +24 -0
- package/examples/fastify-microservice/index.ts +342 -0
- package/examples/fastify-microservice/package.json +22 -0
- package/examples/fastify-microservice/tsconfig.json +24 -0
- package/examples/invoice-service/data/invoices.db +0 -0
- package/examples/invoice-service/data/invoices.db-shm +0 -0
- package/examples/invoice-service/data/invoices.db-wal +0 -0
- package/examples/invoice-service/package.json +25 -0
- package/examples/invoice-service/public/index.html +5025 -0
- package/examples/invoice-service/src/db.ts +608 -0
- package/examples/invoice-service/src/pdf.ts +358 -0
- package/examples/invoice-service/src/server.ts +527 -0
- package/examples/invoice-service/src/store.ts +159 -0
- package/examples/invoice-service/src/types.ts +193 -0
- package/examples/invoice-service/tsconfig.json +23 -0
- package/examples/nestjs-enterprise/app.module.ts +561 -0
- package/examples/nestjs-enterprise/main.ts +67 -0
- package/examples/nestjs-enterprise/package.json +26 -0
- package/examples/nestjs-enterprise/tsconfig.json +27 -0
- package/immortal-js-1.0.0.tgz +0 -0
- package/package.json +33 -0
- package/packages/adapter-express/package.json +34 -0
- package/packages/adapter-express/src/index.ts +349 -0
- package/packages/adapter-express/tsconfig.json +14 -0
- package/packages/adapter-fastify/package.json +56 -0
- package/packages/adapter-fastify/src/plugin.ts +226 -0
- package/packages/adapter-fastify/tsconfig.json +14 -0
- package/packages/adapter-koa/package.json +55 -0
- package/packages/adapter-koa/src/index.ts +207 -0
- package/packages/adapter-koa/tsconfig.json +14 -0
- package/packages/adapter-nestjs/package.json +61 -0
- package/packages/adapter-nestjs/src/immortal.module.ts +313 -0
- package/packages/adapter-nestjs/src/index.ts +14 -0
- package/packages/adapter-nestjs/tsconfig.json +16 -0
- package/packages/core/package.json +56 -0
- package/packages/core/src/chaos/ChaosEngine.ts +249 -0
- package/packages/core/src/config/defaults.ts +200 -0
- package/packages/core/src/config/schema.ts +199 -0
- package/packages/core/src/event-bus.ts +168 -0
- package/packages/core/src/index.ts +164 -0
- package/packages/core/src/isolation/BulkheadPool.ts +279 -0
- package/packages/core/src/isolation/WorkerSandbox.ts +306 -0
- package/packages/core/src/isolation/index.ts +8 -0
- package/packages/core/src/lifecycle/GracefulShutdown.ts +161 -0
- package/packages/core/src/logger.ts +104 -0
- package/packages/core/src/monitoring/DiagnosticsChannel.ts +248 -0
- package/packages/core/src/monitoring/HealthMonitor.ts +191 -0
- package/packages/core/src/monitoring/MemoryLeakGuard.ts +340 -0
- package/packages/core/src/monitoring/MetricsCollector.ts +219 -0
- package/packages/core/src/monitoring/index.ts +10 -0
- package/packages/core/src/plugins/BuiltinPlugins.ts +269 -0
- package/packages/core/src/recovery/CircuitBreaker.ts +334 -0
- package/packages/core/src/recovery/FallbackCache.ts +328 -0
- package/packages/core/src/recovery/RetryEngine.ts +225 -0
- package/packages/core/src/recovery/Timeout.ts +97 -0
- package/packages/core/src/recovery/index.ts +11 -0
- package/packages/core/src/runtime.ts +242 -0
- package/packages/core/src/safe-zone/AsyncBoundary.ts +114 -0
- package/packages/core/src/safe-zone/ErrorTrap.ts +347 -0
- package/packages/core/src/safe-zone/SafeWrapper.ts +317 -0
- package/packages/core/src/safe-zone/index.ts +23 -0
- package/packages/core/src/supervision/ClusterManager.ts +243 -0
- package/packages/core/src/supervision/RestartStrategy.ts +68 -0
- package/packages/core/src/supervision/Supervisor.ts +311 -0
- package/packages/core/src/supervision/index.ts +11 -0
- package/packages/core/src/types.ts +470 -0
- package/packages/core/test/bulkhead.test.ts +310 -0
- package/packages/core/test/circuit-breaker.test.ts +153 -0
- package/packages/core/test/memory-guard.test.ts +213 -0
- package/packages/core/test/retry.test.ts +110 -0
- package/packages/core/test/safe-zone.test.ts +271 -0
- package/packages/core/test/supervisor.test.ts +310 -0
- package/packages/core/tsconfig.json +13 -0
- package/packages/dashboard/package.json +56 -0
- package/packages/dashboard/server/DashboardServer.ts +454 -0
- package/packages/dashboard/tsconfig.json +14 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/server.ts
|
|
3
|
+
* @description Invoice Service Pro v3 — Ultra Pro Fastify Server
|
|
4
|
+
*
|
|
5
|
+
* Frontend-is-Backend Pattern:
|
|
6
|
+
* Fastify serves static SPA from public/ (dark/light theme, Lucide icons)
|
|
7
|
+
* AND a full REST API at /api/*
|
|
8
|
+
*
|
|
9
|
+
* Resilience (Immortal.js):
|
|
10
|
+
* CircuitBreaker — external email/storage calls
|
|
11
|
+
* BulkheadPool — PDF rendering + API concurrency
|
|
12
|
+
* FallbackCache — dashboard stats (15s TTL), invoice list (30s TTL)
|
|
13
|
+
* withRetry — transient storage errors
|
|
14
|
+
* withTimeout — email + external ops
|
|
15
|
+
* GracefulShutdown — SIGTERM/SIGINT → drain → close
|
|
16
|
+
* MemoryLeakGuard — restart at 1500 MB
|
|
17
|
+
* HealthMonitor — event loop + memory tracking
|
|
18
|
+
*
|
|
19
|
+
* Routes:
|
|
20
|
+
* GET /api/ — API welcome + endpoint map
|
|
21
|
+
* GET /api/health — full system health
|
|
22
|
+
* GET /api/metrics — Prometheus text
|
|
23
|
+
* GET /api/dashboard — real SQLite stats (KPI, monthly, top clients)
|
|
24
|
+
* GET /api/invoices — paginated invoice list (SQLite, filtered, sorted)
|
|
25
|
+
* POST /api/invoices — create invoice
|
|
26
|
+
* GET /api/invoices/:id — get invoice
|
|
27
|
+
* PATCH /api/invoices/:id — update invoice (status, notes, etc.)
|
|
28
|
+
* DELETE /api/invoices/:id — delete invoice
|
|
29
|
+
* GET /api/invoices/:id/pdf — print-ready PDF HTML
|
|
30
|
+
* GET /api/invoices/:id/activity — audit log
|
|
31
|
+
* GET /api/clients — list clients
|
|
32
|
+
* POST /api/clients — create client
|
|
33
|
+
* GET /api/clients/:id — get client
|
|
34
|
+
* PATCH /api/clients/:id — update client
|
|
35
|
+
* DELETE /api/clients/:id — delete client
|
|
36
|
+
* GET /api/stats — quick stats for sidebar KPIs
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import path from "node:path";
|
|
40
|
+
import { fileURLToPath } from "node:url";
|
|
41
|
+
import type { FastifyRequest, FastifyReply } from "fastify";
|
|
42
|
+
import Fastify from "fastify";
|
|
43
|
+
import fastifyStatic from "@fastify/static";
|
|
44
|
+
|
|
45
|
+
import {
|
|
46
|
+
CircuitBreaker, BulkheadPool, FallbackCache, GracefulShutdown,
|
|
47
|
+
MetricsCollector, HealthMonitor, MemoryLeakGuard, ErrorTrap,
|
|
48
|
+
withRetry, withTimeout, getEventBus, mergeWithDefaults,
|
|
49
|
+
} from "@immortal/core";
|
|
50
|
+
|
|
51
|
+
import { InvoiceStore, ClientStore, newId, newItemId, nextNumber, now, today, addDays } from "./db.js";
|
|
52
|
+
import { generatePdfHtml, calculateTotals } from "./pdf.js";
|
|
53
|
+
import type { Invoice, CreateInvoiceDto, UpdateInvoiceDto, CreateClientDto, InvoiceListQuery, InvoiceCurrency } from "./types.js";
|
|
54
|
+
|
|
55
|
+
// ─── Paths ────────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
58
|
+
const PUBLIC_DIR = path.resolve(__dirname, "..", "public");
|
|
59
|
+
|
|
60
|
+
// ─── Error helper ─────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
function sendError(reply: FastifyReply, status: number, message: string, code = "ERROR"): FastifyReply {
|
|
63
|
+
return reply.status(status).send({ error: message, code, timestamp: now() });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── Immortal.js Runtime ──────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
const config = mergeWithDefaults({});
|
|
69
|
+
const bus = getEventBus();
|
|
70
|
+
|
|
71
|
+
new ErrorTrap(bus).install();
|
|
72
|
+
|
|
73
|
+
const metrics = new MetricsCollector();
|
|
74
|
+
const healthMonitor = new HealthMonitor(config.healthMonitor, bus, metrics);
|
|
75
|
+
const memoryGuard = new MemoryLeakGuard({ checkIntervalMs: 30_000, restartThresholdMb: 1_500 }, bus);
|
|
76
|
+
|
|
77
|
+
// Circuit breakers
|
|
78
|
+
const emailCircuit = new CircuitBreaker("email-service", { threshold: 3, cooldownMs: 30_000, slidingWindowSize: 10 });
|
|
79
|
+
const storageCircuit = new CircuitBreaker("storage-service", { threshold: 5, cooldownMs: 15_000, slidingWindowSize: 20 });
|
|
80
|
+
|
|
81
|
+
// Bulkhead pools
|
|
82
|
+
const pdfBulkhead = new BulkheadPool("pdf-rendering", { maxConcurrent: 5, maxQueueSize: 20, queueTimeoutMs: 30_000 });
|
|
83
|
+
const apiBulkhead = new BulkheadPool("api-requests", { maxConcurrent: 50, maxQueueSize: 200, queueTimeoutMs: 10_000 });
|
|
84
|
+
|
|
85
|
+
// Fallback caches (JSON-serialized values)
|
|
86
|
+
const dashboardCache = new FallbackCache<string>({ maxSize: 10, defaultTtlMs: 15_000 });
|
|
87
|
+
const listCache = new FallbackCache<string>({ maxSize: 100, defaultTtlMs: 30_000 });
|
|
88
|
+
const statsCache = new FallbackCache<string>({ maxSize: 5, defaultTtlMs: 10_000 });
|
|
89
|
+
|
|
90
|
+
function bustCaches(): void {
|
|
91
|
+
dashboardCache.invalidate("dashboard");
|
|
92
|
+
statsCache.invalidate("stats");
|
|
93
|
+
for (const k of ["all","draft","sent","paid","overdue","cancelled","refunded"]) {
|
|
94
|
+
listCache.invalidate(`list:${k}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Graceful shutdown
|
|
99
|
+
const shutdown = new GracefulShutdown({
|
|
100
|
+
timeoutMs: 20_000,
|
|
101
|
+
signals: ["SIGTERM", "SIGINT"],
|
|
102
|
+
onShutdown: [
|
|
103
|
+
async () => { pdfBulkhead.drain(); },
|
|
104
|
+
async () => { healthMonitor.stop(); memoryGuard.stop(); },
|
|
105
|
+
async () => { await fastify.close(); },
|
|
106
|
+
],
|
|
107
|
+
}, bus);
|
|
108
|
+
void shutdown;
|
|
109
|
+
|
|
110
|
+
// ─── Fastify ──────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
const fastify = Fastify({ logger: false, bodyLimit: 2 * 1024 * 1024 });
|
|
113
|
+
|
|
114
|
+
await fastify.register(fastifyStatic, { root: PUBLIC_DIR, prefix: "/" });
|
|
115
|
+
|
|
116
|
+
// CORS
|
|
117
|
+
fastify.addHook("onRequest", async (_req: FastifyRequest, reply: FastifyReply) => {
|
|
118
|
+
reply.header("Access-Control-Allow-Origin", "*");
|
|
119
|
+
reply.header("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS");
|
|
120
|
+
reply.header("Access-Control-Allow-Headers", "Content-Type,Authorization");
|
|
121
|
+
reply.header("X-Powered-By", "Immortal.js Invoice Service Pro v3");
|
|
122
|
+
});
|
|
123
|
+
fastify.options("/*", async (_req, reply) => reply.status(204).send());
|
|
124
|
+
|
|
125
|
+
// ─── /api/ ────────────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
fastify.get("/api/", async (_req: FastifyRequest, reply: FastifyReply) => {
|
|
128
|
+
return reply.send({
|
|
129
|
+
service: "Immortal.js Invoice Service Pro",
|
|
130
|
+
version: "3.0.0",
|
|
131
|
+
status: "running",
|
|
132
|
+
timestamp: now(),
|
|
133
|
+
database: "SQLite (better-sqlite3 WAL mode)",
|
|
134
|
+
endpoints: {
|
|
135
|
+
"GET /api/": "API welcome",
|
|
136
|
+
"GET /api/health": "System health",
|
|
137
|
+
"GET /api/metrics": "Prometheus metrics",
|
|
138
|
+
"GET /api/dashboard": "Dashboard KPIs from SQLite",
|
|
139
|
+
"GET /api/stats": "Quick sidebar stats",
|
|
140
|
+
"GET /api/invoices": "List invoices (paginated, filtered, sorted)",
|
|
141
|
+
"POST /api/invoices": "Create invoice",
|
|
142
|
+
"GET /api/invoices/:id": "Get invoice by ID",
|
|
143
|
+
"PATCH /api/invoices/:id": "Update invoice",
|
|
144
|
+
"DELETE /api/invoices/:id": "Delete invoice",
|
|
145
|
+
"GET /api/invoices/:id/pdf": "Render PDF-ready HTML",
|
|
146
|
+
"GET /api/invoices/:id/activity": "Audit log",
|
|
147
|
+
"GET /api/clients": "List clients",
|
|
148
|
+
"POST /api/clients": "Create client",
|
|
149
|
+
"GET /api/clients/:id": "Get client",
|
|
150
|
+
"PATCH /api/clients/:id": "Update client",
|
|
151
|
+
"DELETE /api/clients/:id": "Delete client",
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ─── /api/health ──────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
fastify.get("/api/health", async (_req: FastifyRequest, reply: FastifyReply) => {
|
|
159
|
+
const snap = metrics.getSnapshot();
|
|
160
|
+
const pdfBk = pdfBulkhead.getStatus();
|
|
161
|
+
const apiBk = apiBulkhead.getStatus();
|
|
162
|
+
return reply.send({
|
|
163
|
+
status: "healthy",
|
|
164
|
+
uptime: Math.round(process.uptime()),
|
|
165
|
+
version: "3.0.0",
|
|
166
|
+
memoryMb: {
|
|
167
|
+
heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
|
|
168
|
+
heapTotal: Math.round(process.memoryUsage().heapTotal / 1024 / 1024),
|
|
169
|
+
rss: Math.round(process.memoryUsage().rss / 1024 / 1024),
|
|
170
|
+
},
|
|
171
|
+
eventLoop: snap.eventLoopLag,
|
|
172
|
+
invoiceCount: InvoiceStore.count(),
|
|
173
|
+
clientCount: ClientStore.count(),
|
|
174
|
+
circuits: {
|
|
175
|
+
"email-service": emailCircuit.getStatus(),
|
|
176
|
+
"storage-service": storageCircuit.getStatus(),
|
|
177
|
+
},
|
|
178
|
+
bulkheads: {
|
|
179
|
+
"pdf-rendering": { active: pdfBk.active, queued: pdfBk.queued, max: 5 },
|
|
180
|
+
"api-requests": { active: apiBk.active, queued: apiBk.queued, max: 50 },
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ─── /api/metrics ─────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
fastify.get("/api/metrics", async (_req: FastifyRequest, reply: FastifyReply) => {
|
|
188
|
+
const snap = metrics.getSnapshot();
|
|
189
|
+
const emailS = emailCircuit.getStatus();
|
|
190
|
+
const strgS = storageCircuit.getStatus();
|
|
191
|
+
const pdfBk = pdfBulkhead.getStatus();
|
|
192
|
+
const apiBk = apiBulkhead.getStatus();
|
|
193
|
+
const stateNum = (s: string): number => s === "CLOSED" ? 1 : s === "HALF_OPEN" ? 2 : 3;
|
|
194
|
+
return reply.header("Content-Type", "text/plain; version=0.0.4").send([
|
|
195
|
+
`immortal_circuit_state{name="email-service"} ${stateNum(emailS.state)}`,
|
|
196
|
+
`immortal_circuit_state{name="storage-service"} ${stateNum(strgS.state)}`,
|
|
197
|
+
`immortal_bulkhead_active{pool="pdf-rendering"} ${pdfBk.active}`,
|
|
198
|
+
`immortal_bulkhead_active{pool="api-requests"} ${apiBk.active}`,
|
|
199
|
+
`immortal_heap_used_mb ${Math.round(process.memoryUsage().heapUsed / 1024 / 1024)}`,
|
|
200
|
+
`immortal_eventloop_lag_p99_ms ${snap.eventLoopLag.p99.toFixed(2)}`,
|
|
201
|
+
`invoice_service_total ${InvoiceStore.count()}`,
|
|
202
|
+
`invoice_client_total ${ClientStore.count()}`,
|
|
203
|
+
].join("\n"));
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ─── /api/dashboard ───────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
fastify.get("/api/dashboard", async (_req: FastifyRequest, reply: FastifyReply) => {
|
|
209
|
+
return apiBulkhead.run(async () => {
|
|
210
|
+
const cached = dashboardCache.get("dashboard");
|
|
211
|
+
if (cached) {
|
|
212
|
+
return reply.header("X-Cache", "HIT").header("Content-Type", "application/json").send(cached);
|
|
213
|
+
}
|
|
214
|
+
const stats = InvoiceStore.dashboard();
|
|
215
|
+
const payload = JSON.stringify(stats);
|
|
216
|
+
dashboardCache.set("dashboard", payload);
|
|
217
|
+
return reply.header("X-Cache", "MISS").send(stats);
|
|
218
|
+
}, "medium");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ─── /api/stats ───────────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
fastify.get("/api/stats", async (_req: FastifyRequest, reply: FastifyReply) => {
|
|
224
|
+
const cached = statsCache.get("stats");
|
|
225
|
+
if (cached) return reply.header("X-Cache", "HIT").header("Content-Type", "application/json").send(cached);
|
|
226
|
+
const stats = InvoiceStore.dashboard();
|
|
227
|
+
const quick = {
|
|
228
|
+
total: stats.totalInvoices,
|
|
229
|
+
revenue: stats.totalRevenueCents,
|
|
230
|
+
outstanding: stats.outstandingCents,
|
|
231
|
+
overdue: stats.overdueCents,
|
|
232
|
+
drafts: stats.draftCount,
|
|
233
|
+
clients: stats.totalClients,
|
|
234
|
+
paid: stats.paidCount,
|
|
235
|
+
sent: stats.sentCount,
|
|
236
|
+
};
|
|
237
|
+
statsCache.set("stats", JSON.stringify(quick));
|
|
238
|
+
return reply.send(quick);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ─── /api/invoices (GET — list) ───────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
fastify.get(
|
|
244
|
+
"/api/invoices",
|
|
245
|
+
async (
|
|
246
|
+
request: FastifyRequest<{ Querystring: Record<string, string> }>,
|
|
247
|
+
reply: FastifyReply
|
|
248
|
+
) => {
|
|
249
|
+
return apiBulkhead.run(async () => {
|
|
250
|
+
const q = request.query;
|
|
251
|
+
// Build query object without undefined optional fields (exactOptionalPropertyTypes)
|
|
252
|
+
const query: InvoiceListQuery = {};
|
|
253
|
+
query.page = q["page"] ? parseInt(q["page"], 10) : 1;
|
|
254
|
+
query.limit = q["limit"] ? parseInt(q["limit"], 10) : 25;
|
|
255
|
+
query.sortDir = q["sortDir"] === "asc" ? "asc" : "desc";
|
|
256
|
+
if (q["status"]) query.status = q["status"] as NonNullable<InvoiceListQuery["status"]>;
|
|
257
|
+
if (q["currency"]) query.currency = q["currency"] as NonNullable<InvoiceListQuery["currency"]>;
|
|
258
|
+
if (q["search"]) query.search = q["search"];
|
|
259
|
+
if (q["sortBy"]) query.sortBy = q["sortBy"] as NonNullable<InvoiceListQuery["sortBy"]>;
|
|
260
|
+
if (q["dateFrom"]) query.dateFrom = q["dateFrom"];
|
|
261
|
+
if (q["dateTo"]) query.dateTo = q["dateTo"];
|
|
262
|
+
|
|
263
|
+
const cacheKey = `list:${JSON.stringify(query)}`;
|
|
264
|
+
const cached = listCache.get(cacheKey);
|
|
265
|
+
if (cached) {
|
|
266
|
+
return reply.header("X-Cache", "HIT").header("Content-Type", "application/json").send(cached);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const result = await storageCircuit.execute(
|
|
270
|
+
async () => {
|
|
271
|
+
await simulateStorageLatency();
|
|
272
|
+
return InvoiceStore.list(query);
|
|
273
|
+
},
|
|
274
|
+
() => InvoiceStore.list(query)
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
listCache.set(cacheKey, JSON.stringify(result));
|
|
278
|
+
return reply.header("X-Cache", "MISS").send(result);
|
|
279
|
+
}, "medium");
|
|
280
|
+
}
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
// ─── /api/invoices (POST — create) ────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
fastify.post(
|
|
286
|
+
"/api/invoices",
|
|
287
|
+
async (request: FastifyRequest, reply: FastifyReply) => {
|
|
288
|
+
return apiBulkhead.run(async () => {
|
|
289
|
+
const dto = request.body as CreateInvoiceDto;
|
|
290
|
+
|
|
291
|
+
if (!dto?.from?.name || !dto?.to?.name || !dto?.to?.email) {
|
|
292
|
+
return sendError(reply, 400, "from.name, to.name, to.email are required", "VALIDATION_ERROR");
|
|
293
|
+
}
|
|
294
|
+
if (!dto.lineItems?.length) {
|
|
295
|
+
return sendError(reply, 400, "At least one line item is required", "VALIDATION_ERROR");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const invoice = await withRetry(async () => {
|
|
299
|
+
await simulateStorageLatency();
|
|
300
|
+
const n = now();
|
|
301
|
+
const newInv: Invoice = {
|
|
302
|
+
id: newId(),
|
|
303
|
+
number: nextNumber(),
|
|
304
|
+
status: "draft",
|
|
305
|
+
currency: (dto.currency ?? "USD") as InvoiceCurrency,
|
|
306
|
+
issuedAt: dto.issuedAt ?? today(),
|
|
307
|
+
dueAt: dto.dueAt ?? addDays(30),
|
|
308
|
+
from: dto.from,
|
|
309
|
+
to: dto.to,
|
|
310
|
+
lineItems: dto.lineItems.map(li => ({ ...li, id: newItemId() })),
|
|
311
|
+
createdAt: n,
|
|
312
|
+
updatedAt: n,
|
|
313
|
+
...(dto.notes !== undefined && { notes: dto.notes }),
|
|
314
|
+
...(dto.terms !== undefined && { terms: dto.terms }),
|
|
315
|
+
...(dto.discount !== undefined && { discount: dto.discount }),
|
|
316
|
+
...(dto.reference !== undefined && { reference: dto.reference }),
|
|
317
|
+
...(dto.tags?.length && { tags: dto.tags }),
|
|
318
|
+
};
|
|
319
|
+
return InvoiceStore.create(newInv);
|
|
320
|
+
}, { maxAttempts: 2, baseDelayMs: 100 });
|
|
321
|
+
|
|
322
|
+
bustCaches();
|
|
323
|
+
|
|
324
|
+
// Non-blocking email
|
|
325
|
+
void emailCircuit.execute(
|
|
326
|
+
() => withTimeout(
|
|
327
|
+
() => simulateEmailSend(invoice.to.email, invoice.number),
|
|
328
|
+
5_000, "email"
|
|
329
|
+
),
|
|
330
|
+
() => undefined
|
|
331
|
+
).catch(() => undefined);
|
|
332
|
+
|
|
333
|
+
return reply.status(201).send(invoice);
|
|
334
|
+
}, "high");
|
|
335
|
+
}
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
// ─── /api/invoices/:id (GET) ──────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
fastify.get(
|
|
341
|
+
"/api/invoices/:id",
|
|
342
|
+
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
|
343
|
+
const { id } = request.params;
|
|
344
|
+
const invoice = InvoiceStore.get(id);
|
|
345
|
+
if (!invoice) return sendError(reply, 404, `Invoice '${id}' not found`, "NOT_FOUND");
|
|
346
|
+
return reply.send(invoice);
|
|
347
|
+
}
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
// ─── /api/invoices/:id (PATCH) ────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
fastify.patch(
|
|
353
|
+
"/api/invoices/:id",
|
|
354
|
+
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
|
355
|
+
const { id } = request.params;
|
|
356
|
+
const dto = request.body as UpdateInvoiceDto;
|
|
357
|
+
|
|
358
|
+
if (dto.status) {
|
|
359
|
+
const valid = ["draft", "sent", "paid", "overdue", "cancelled", "refunded"];
|
|
360
|
+
if (!valid.includes(dto.status)) {
|
|
361
|
+
return sendError(reply, 400, `Invalid status. Valid: ${valid.join(", ")}`, "VALIDATION_ERROR");
|
|
362
|
+
}
|
|
363
|
+
if (dto.status === "paid" && !dto.paidAt) {
|
|
364
|
+
dto.paidAt = today();
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const updated = InvoiceStore.update(id, dto);
|
|
369
|
+
if (!updated) return sendError(reply, 404, `Invoice '${id}' not found`, "NOT_FOUND");
|
|
370
|
+
bustCaches();
|
|
371
|
+
return reply.send(updated);
|
|
372
|
+
}
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
// ─── /api/invoices/:id (DELETE) ───────────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
fastify.delete(
|
|
378
|
+
"/api/invoices/:id",
|
|
379
|
+
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
|
380
|
+
const { id } = request.params;
|
|
381
|
+
if (!InvoiceStore.get(id)) return sendError(reply, 404, `Invoice '${id}' not found`, "NOT_FOUND");
|
|
382
|
+
InvoiceStore.delete(id);
|
|
383
|
+
bustCaches();
|
|
384
|
+
return reply.status(204).send();
|
|
385
|
+
}
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
// ─── /api/invoices/:id/pdf ────────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
fastify.get(
|
|
391
|
+
"/api/invoices/:id/pdf",
|
|
392
|
+
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
|
393
|
+
const { id } = request.params;
|
|
394
|
+
return pdfBulkhead.run(async () => {
|
|
395
|
+
const invoice = InvoiceStore.get(id);
|
|
396
|
+
if (!invoice) return sendError(reply, 404, `Invoice '${id}' not found`, "NOT_FOUND");
|
|
397
|
+
|
|
398
|
+
await sleep(20 + Math.random() * 50);
|
|
399
|
+
const html = generatePdfHtml(invoice);
|
|
400
|
+
const totals = calculateTotals(invoice);
|
|
401
|
+
|
|
402
|
+
InvoiceStore.addActivity(id, "viewed", "PDF rendered");
|
|
403
|
+
|
|
404
|
+
return reply
|
|
405
|
+
.header("Content-Type", "text/html; charset=utf-8")
|
|
406
|
+
.header("X-Invoice-Total", totals.total)
|
|
407
|
+
.header("X-Invoice-Number", invoice.number)
|
|
408
|
+
.send(html);
|
|
409
|
+
}, "high");
|
|
410
|
+
}
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
// ─── /api/invoices/:id/activity ───────────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
fastify.get(
|
|
416
|
+
"/api/invoices/:id/activity",
|
|
417
|
+
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
|
418
|
+
const { id } = request.params;
|
|
419
|
+
if (!InvoiceStore.get(id)) return sendError(reply, 404, `Invoice '${id}' not found`, "NOT_FOUND");
|
|
420
|
+
return reply.send(InvoiceStore.getActivity(id));
|
|
421
|
+
}
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
// ─── /api/clients ─────────────────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
fastify.get("/api/clients", async (_req: FastifyRequest, reply: FastifyReply) => {
|
|
427
|
+
return reply.send(ClientStore.list());
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
fastify.post("/api/clients", async (request: FastifyRequest, reply: FastifyReply) => {
|
|
431
|
+
const dto = request.body as CreateClientDto;
|
|
432
|
+
if (!dto?.name || !dto?.email) {
|
|
433
|
+
return sendError(reply, 400, "name and email are required", "VALIDATION_ERROR");
|
|
434
|
+
}
|
|
435
|
+
const client = ClientStore.create(dto);
|
|
436
|
+
bustCaches();
|
|
437
|
+
return reply.status(201).send(client);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
fastify.get("/api/clients/:id",
|
|
441
|
+
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
|
442
|
+
const client = ClientStore.get(request.params.id);
|
|
443
|
+
if (!client) return sendError(reply, 404, "Client not found", "NOT_FOUND");
|
|
444
|
+
return reply.send(client);
|
|
445
|
+
}
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
fastify.patch("/api/clients/:id",
|
|
449
|
+
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
|
450
|
+
const updated = ClientStore.update(request.params.id, request.body as Partial<{ name: string; email: string; phone: string; address: string; city: string; country: string; taxId: string; website: string; notes: string }>);
|
|
451
|
+
if (!updated) return sendError(reply, 404, "Client not found", "NOT_FOUND");
|
|
452
|
+
return reply.send(updated);
|
|
453
|
+
}
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
fastify.delete("/api/clients/:id",
|
|
457
|
+
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
|
458
|
+
if (!ClientStore.delete(request.params.id)) return sendError(reply, 404, "Client not found", "NOT_FOUND");
|
|
459
|
+
return reply.status(204).send();
|
|
460
|
+
}
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
// ─── SPA Fallback ─────────────────────────────────────────────────────────────
|
|
464
|
+
|
|
465
|
+
fastify.setNotFoundHandler(async (request: FastifyRequest, reply: FastifyReply) => {
|
|
466
|
+
if (request.url.startsWith("/api")) {
|
|
467
|
+
return reply.status(404).send({ error: `Route not found: ${request.method} ${request.url}`, code: "NOT_FOUND", timestamp: now() });
|
|
468
|
+
}
|
|
469
|
+
return reply.sendFile("index.html");
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// ─── Global Error Handler ─────────────────────────────────────────────────────
|
|
473
|
+
|
|
474
|
+
fastify.setErrorHandler(async (error, _req: FastifyRequest, reply: FastifyReply) => {
|
|
475
|
+
const status = (error as { statusCode?: number }).statusCode ?? 500;
|
|
476
|
+
console.error(`[ERROR] ${error.name}: ${error.message}`);
|
|
477
|
+
return reply.status(status).send({
|
|
478
|
+
error: status >= 500 ? "Internal Server Error" : error.message,
|
|
479
|
+
code: error.name,
|
|
480
|
+
timestamp: now(),
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// ─── Simulators ───────────────────────────────────────────────────────────────
|
|
485
|
+
|
|
486
|
+
function sleep(ms: number): Promise<void> { return new Promise(r => setTimeout(r, ms)); }
|
|
487
|
+
|
|
488
|
+
async function simulateStorageLatency(): Promise<void> {
|
|
489
|
+
await sleep(2 + Math.random() * 8);
|
|
490
|
+
if (Math.random() < 0.01) throw new Error("Storage transient error");
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function simulateEmailSend(email: string, num: string): Promise<void> {
|
|
494
|
+
await sleep(60 + Math.random() * 100);
|
|
495
|
+
if (Math.random() < 0.04) throw new Error("Email service unavailable");
|
|
496
|
+
console.log(` [email] Confirmation → ${email} [${num}]`);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ─── Start ────────────────────────────────────────────────────────────────────
|
|
500
|
+
|
|
501
|
+
healthMonitor.start();
|
|
502
|
+
memoryGuard.start();
|
|
503
|
+
|
|
504
|
+
const PORT = parseInt(process.env["PORT"] ?? "3000", 10);
|
|
505
|
+
|
|
506
|
+
try {
|
|
507
|
+
await fastify.listen({ port: PORT, host: "0.0.0.0" });
|
|
508
|
+
console.log(`
|
|
509
|
+
+----------------------------------------------------------------------+
|
|
510
|
+
| Immortal.js Invoice Service Pro v3 (Ultra Edition) |
|
|
511
|
+
+----------------------------------------------------------------------+
|
|
512
|
+
| Frontend --> http://localhost:${PORT}/ |
|
|
513
|
+
| API --> http://localhost:${PORT}/api/ |
|
|
514
|
+
| Dashboard --> http://localhost:${PORT}/api/dashboard |
|
|
515
|
+
| Health --> http://localhost:${PORT}/api/health |
|
|
516
|
+
| Metrics --> http://localhost:${PORT}/api/metrics |
|
|
517
|
+
+----------------------------------------------------------------------+
|
|
518
|
+
| DB: SQLite WAL | Invoices: ${InvoiceStore.count()} | Clients: ${ClientStore.count()} |
|
|
519
|
+
| Circuits: email-service, storage-service |
|
|
520
|
+
| Bulkheads: pdf-rendering(5), api-requests(50) |
|
|
521
|
+
| Caches: dashboard(15s), list(30s), stats(10s) |
|
|
522
|
+
+----------------------------------------------------------------------+
|
|
523
|
+
`);
|
|
524
|
+
} catch (err) {
|
|
525
|
+
console.error("Startup failed:", err);
|
|
526
|
+
process.exit(1);
|
|
527
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/store.ts
|
|
3
|
+
* @description In-memory invoice store with atomic operations.
|
|
4
|
+
* In production, replace with PostgreSQL / MongoDB.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Invoice, InvoiceStatus, InvoiceCurrency } from "./types.js";
|
|
8
|
+
|
|
9
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
function newId(): string {
|
|
12
|
+
return `inv_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function newItemId(): string {
|
|
16
|
+
return `li_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let invoiceCounter = 1;
|
|
20
|
+
function nextNumber(): string {
|
|
21
|
+
const n = String(invoiceCounter++).padStart(4, "0");
|
|
22
|
+
return `INV-${new Date().getFullYear()}-${n}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ─── Seed data ────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
function seedInvoices(): Map<string, Invoice> {
|
|
28
|
+
const now = new Date();
|
|
29
|
+
const due = new Date(now);
|
|
30
|
+
due.setDate(due.getDate() + 30);
|
|
31
|
+
|
|
32
|
+
const mkInvoice = (
|
|
33
|
+
status: InvoiceStatus,
|
|
34
|
+
currency: InvoiceCurrency,
|
|
35
|
+
clientName: string,
|
|
36
|
+
items: { desc: string; qty: number; unit: number; tax: number }[]
|
|
37
|
+
): Invoice => {
|
|
38
|
+
const id = newId();
|
|
39
|
+
return {
|
|
40
|
+
id,
|
|
41
|
+
number: nextNumber(),
|
|
42
|
+
status,
|
|
43
|
+
currency,
|
|
44
|
+
issuedAt: now.toISOString().split("T")[0] ?? now.toISOString(),
|
|
45
|
+
dueAt: due.toISOString().split("T")[0] ?? due.toISOString(),
|
|
46
|
+
from: {
|
|
47
|
+
name: "Immortal Corp",
|
|
48
|
+
email: "billing@immortal.dev",
|
|
49
|
+
address: "1 Resilience Boulevard",
|
|
50
|
+
city: "San Francisco, CA 94102",
|
|
51
|
+
country: "US",
|
|
52
|
+
taxId: "US-TAX-99887766",
|
|
53
|
+
},
|
|
54
|
+
to: {
|
|
55
|
+
name: clientName,
|
|
56
|
+
email: `accounts@${clientName.toLowerCase().replace(/\s/g, "")}.com`,
|
|
57
|
+
address: "100 Client Street",
|
|
58
|
+
city: "New York, NY 10001",
|
|
59
|
+
country: "US",
|
|
60
|
+
},
|
|
61
|
+
lineItems: items.map((i) => ({
|
|
62
|
+
id: newItemId(),
|
|
63
|
+
description: i.desc,
|
|
64
|
+
quantity: i.qty,
|
|
65
|
+
unitPrice: i.unit,
|
|
66
|
+
taxRate: i.tax,
|
|
67
|
+
})),
|
|
68
|
+
notes: "Thank you for your business.",
|
|
69
|
+
terms: "Payment due within 30 days of invoice date.",
|
|
70
|
+
createdAt: now.toISOString(),
|
|
71
|
+
updatedAt: now.toISOString(),
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const store = new Map<string, Invoice>();
|
|
76
|
+
const invoices = [
|
|
77
|
+
mkInvoice("paid", "USD", "Acme Technologies", [
|
|
78
|
+
{ desc: "Enterprise License — Immortal.js Pro", qty: 1, unit: 299900, tax: 0.08 },
|
|
79
|
+
{ desc: "Onboarding & Setup (4h)", qty: 4, unit: 20000, tax: 0.08 },
|
|
80
|
+
]),
|
|
81
|
+
mkInvoice("sent", "EUR", "Munich Innovations GmbH", [
|
|
82
|
+
{ desc: "Monthly SaaS Subscription", qty: 3, unit: 49900, tax: 0.19 },
|
|
83
|
+
{ desc: "Priority Support (yearly)", qty: 1, unit: 120000, tax: 0.19 },
|
|
84
|
+
]),
|
|
85
|
+
mkInvoice("overdue", "GBP", "London Fintech Ltd", [
|
|
86
|
+
{ desc: "API Integration Consulting (8h)", qty: 8, unit: 25000, tax: 0.20 },
|
|
87
|
+
]),
|
|
88
|
+
mkInvoice("draft", "AED", "Dubai Digital Solutions", [
|
|
89
|
+
{ desc: "Cloud Infrastructure Setup", qty: 1, unit: 550000, tax: 0.05 },
|
|
90
|
+
{ desc: "Custom Dashboard Development", qty: 1, unit: 180000, tax: 0.05 },
|
|
91
|
+
{ desc: "Training Sessions (6h)", qty: 6, unit: 18000, tax: 0.05 },
|
|
92
|
+
]),
|
|
93
|
+
mkInvoice("paid", "USD", "Startup Valley Inc", [
|
|
94
|
+
{ desc: "Immortal.js Starter License", qty: 1, unit: 9900, tax: 0.08 },
|
|
95
|
+
]),
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
for (const inv of invoices) {
|
|
99
|
+
store.set(inv.id, inv);
|
|
100
|
+
}
|
|
101
|
+
return store;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Store ────────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
export class InvoiceStore {
|
|
107
|
+
private readonly invoices: Map<string, Invoice> = seedInvoices();
|
|
108
|
+
|
|
109
|
+
list(): Invoice[] {
|
|
110
|
+
return [...this.invoices.values()].sort(
|
|
111
|
+
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
get(id: string): Invoice | undefined {
|
|
116
|
+
return this.invoices.get(id);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
create(invoice: Invoice): Invoice {
|
|
120
|
+
this.invoices.set(invoice.id, invoice);
|
|
121
|
+
return invoice;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
update(id: string, patch: Partial<Invoice>): Invoice | undefined {
|
|
125
|
+
const existing = this.invoices.get(id);
|
|
126
|
+
if (!existing) return undefined;
|
|
127
|
+
const updated: Invoice = {
|
|
128
|
+
...existing,
|
|
129
|
+
...patch,
|
|
130
|
+
id, // never overwrite id
|
|
131
|
+
updatedAt: new Date().toISOString(),
|
|
132
|
+
};
|
|
133
|
+
this.invoices.set(id, updated);
|
|
134
|
+
return updated;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
delete(id: string): boolean {
|
|
138
|
+
return this.invoices.delete(id);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
count(): number {
|
|
142
|
+
return this.invoices.size;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
newId(): string {
|
|
146
|
+
return newId();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
newItemId(): string {
|
|
150
|
+
return newItemId();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
nextNumber(): string {
|
|
154
|
+
return nextNumber();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Singleton store
|
|
159
|
+
export const store = new InvoiceStore();
|