diff --git a/backend/openapi.json b/backend/openapi.json index 57c9c96..e69ea55 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -30,6 +30,10 @@ "name": "System", "description": "Health checks, discovery, and generated documentation endpoints" }, + { + "name": "Analytics", + "description": "Payroll expenditure analytics and reporting" + }, { "name": "Assets", "description": "Asset management (ISSUE/CLAWBACK)" @@ -46,6 +50,10 @@ "name": "Bulk Payments", "description": "Multi-operation Stellar transaction batching" }, + { + "name": "Circuit Breakers", + "description": "Circuit-breaker state management and observability (Issue" + }, { "name": "Contract Events", "description": "On-chain contract event indexing" @@ -58,6 +66,10 @@ "name": "Contract Upgrades", "description": "On-chain contract upgrade lifecycle" }, + { + "name": "DB Scaling", + "description": "Database connection pool and performance monitoring (Issue" + }, { "name": "Exports", "description": "Export receipts and payroll reports" @@ -98,6 +110,10 @@ "name": "Throttling", "description": "API request throttling and queuing" }, + { + "name": "Transactions", + "description": "Transaction management with pagination" + }, { "name": "Trustlines", "description": "Stellar asset trustline management" @@ -161,10 +177,6 @@ { "name": "Tenant Config", "description": "Tenant configuration management endpoints" - }, - { - "name": "Webhooks", - "description": "Webhook subscription and delivery endpoints" } ], "paths": { @@ -210,6 +222,20 @@ } } }, + "/api/v1/health": { + "get": { + "tags": [ + "System" + ], + "summary": "Get API v1 health status", + "security": [], + "responses": { + "200": { + "description": "Success" + } + } + } + }, "/health": { "get": { "tags": [ @@ -238,6 +264,178 @@ } } }, + "/api/v1/openapi.json": { + "get": { + "tags": [ + "System" + ], + "summary": "Get the generated OpenAPI specification (v1 endpoint)", + "security": [], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/v1/analytics/payroll": { + "get": { + "summary": "Get payroll expenditure analytics", + "tags": [ + "Analytics" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "query", + "name": "organizationId", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Organization identifier" + }, + { + "in": "query", + "name": "startDate", + "schema": { + "type": "string", + "format": "date" + }, + "description": "Start of the date range (ISO 8601). Defaults to 12 months ago." + }, + { + "in": "query", + "name": "endDate", + "schema": { + "type": "string", + "format": "date" + }, + "description": "End of the date range (ISO 8601). Defaults to today." + } + ], + "responses": { + "200": { + "description": "Payroll analytics data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "type": "object", + "properties": { + "trends": { + "type": "array", + "items": { + "type": "object", + "properties": { + "month": { + "type": "string" + }, + "total": { + "type": "number" + }, + "count": { + "type": "integer" + } + } + } + }, + "currencyBreakdown": { + "type": "array", + "items": { + "type": "object", + "properties": { + "currency": { + "type": "string" + }, + "value": { + "type": "number" + } + } + } + }, + "paymentMetrics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "month": { + "type": "string" + }, + "success": { + "type": "integer" + }, + "failure": { + "type": "integer" + }, + "pending": { + "type": "integer" + } + } + } + }, + "departmentBreakdown": { + "type": "array", + "items": { + "type": "object", + "properties": { + "department": { + "type": "string" + }, + "total": { + "type": "number" + }, + "headcount": { + "type": "integer" + } + } + } + }, + "summary": { + "type": "object", + "properties": { + "totalPayroll": { + "type": "number" + }, + "totalTransactions": { + "type": "integer" + }, + "successRate": { + "type": "number" + }, + "activeEmployees": { + "type": "integer" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Missing or invalid query parameters" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/api/assets/issue": { "post": { "summary": "Issue organization USD", @@ -562,6 +760,178 @@ } } }, + "/api/v1/circuit-breakers": { + "get": { + "summary": "List all registered circuit breakers and their current state", + "tags": [ + "Circuit Breakers" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Array of circuit-breaker snapshots", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CircuitSnapshot" + } + }, + "meta": { + "type": "object", + "properties": { + "count": { + "type": "integer" + } + } + } + } + } + } + } + } + } + } + }, + "/api/v1/circuit-breakers/summary": { + "get": { + "summary": "Aggregated health summary across all circuit breakers", + "tags": [ + "Circuit Breakers" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Summary with open/closed/half-open counts" + } + } + } + }, + "/api/v1/circuit-breakers/{name}": { + "get": { + "summary": "Get the current snapshot for a named circuit breaker", + "tags": [ + "Circuit Breakers" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + }, + "description": "Circuit name (e.g. \"database\", \"redis\", \"stellar-api\")" + } + ], + "responses": { + "200": { + "description": "Circuit snapshot" + }, + "404": { + "description": "Circuit not found" + } + } + } + }, + "/api/v1/circuit-breakers/{name}/events": { + "get": { + "summary": "Return recent state-change and failure events for a circuit", + "tags": [ + "Circuit Breakers" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "default": 50 + } + }, + { + "in": "query", + "name": "sinceHours", + "schema": { + "type": "integer", + "default": 24 + } + } + ], + "responses": { + "200": { + "description": "List of events" + }, + "400": { + "description": "Invalid query parameters" + } + } + } + }, + "/api/v1/circuit-breakers/{name}/reset": { + "post": { + "summary": "Manually reset a circuit breaker to CLOSED state", + "tags": [ + "Circuit Breakers" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Circuit reset successfully" + }, + "404": { + "description": "Circuit not found" + } + } + } + }, "/api/events/{contractId}": { "get": { "summary": "List indexed contract events", @@ -862,56 +1232,310 @@ } } }, - "/api/v1/exports/receipt/{txHash}/pdf": { + "/api/v1/db-scaling/pool": { "get": { - "summary": "Export transaction receipt as PDF", + "summary": "Get current database connection pool stats", "tags": [ - "Exports" + "DB Scaling" ], - "parameters": [ + "security": [ { - "in": "path", - "name": "txHash", - "required": true, - "schema": { - "type": "string" - } + "bearerAuth": [] } ], "responses": { "200": { - "description": "PDF file" + "description": "Pool stats returned successfully" + }, + "500": { + "description": "Internal server error" } } } }, - "/api/v1/exports/payroll/{organizationPublicKey}/{batchId}/excel": { + "/api/v1/db-scaling/health": { "get": { - "summary": "Export payroll report as Excel", + "summary": "Database connectivity health check with latency", "tags": [ - "Exports" + "DB Scaling" + ], + "responses": { + "200": { + "description": "Database is reachable" + }, + "503": { + "description": "Database is unreachable" + } + } + } + }, + "/api/v1/db-scaling/slow-queries": { + "get": { + "summary": "List queries exceeding a mean execution time threshold", + "tags": [ + "DB Scaling" + ], + "security": [ + { + "bearerAuth": [] + } ], "parameters": [ { - "in": "path", - "name": "organizationPublicKey", - "required": true, + "in": "query", + "name": "threshold", "schema": { - "type": "string" - } + "type": "number" + }, + "description": "Mean execution time threshold in ms (default 1000)" }, { - "in": "path", - "name": "batchId", - "required": true, + "in": "query", + "name": "limit", "schema": { - "type": "string" - } + "type": "number" + }, + "description": "Max rows to return (default 20, max 100)" } ], "responses": { "200": { - "description": "Excel file" + "description": "Slow query list" + }, + "400": { + "description": "Invalid query parameters" + } + } + } + }, + "/api/v1/db-scaling/index-usage": { + "get": { + "summary": "Return index usage statistics from pg_stat_user_indexes", + "tags": [ + "DB Scaling" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Index usage data" + } + } + } + }, + "/api/v1/db-scaling/config": { + "get": { + "summary": "Return current connection pool configuration", + "tags": [ + "DB Scaling" + ], + "responses": { + "200": { + "description": "Pool min/max configuration" + } + } + } + }, + "/api/v1/db-scaling/lock-contention": { + "get": { + "summary": "Active lock-wait chains between database backends (Part 39)", + "description": "Returns rows from pg_locks + pg_stat_activity where one backend is blocking another. An empty array means no lock waits are active.\n", + "tags": [ + "DB Scaling" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Lock contention rows (may be empty)" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/api/v1/db-scaling/unused-indexes": { + "get": { + "summary": "Indexes with zero scans since last statistics reset (Part 39)", + "description": "Lists non-primary, non-unique indexes never used by the query planner. These are candidates for removal to reduce write overhead and storage.\n", + "tags": [ + "DB Scaling" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Unused index list" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/api/v1/db-scaling/replication-lag": { + "get": { + "summary": "Streaming replication lag per standby replica (Part 40)", + "description": "Queries pg_stat_replication for LSN distances between primary and each connected standby. Returns an empty array when no replicas are configured.\n", + "tags": [ + "DB Scaling" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Replication lag per replica (may be empty)" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/api/v1/db-scaling/table-sizes": { + "get": { + "summary": "Per-table disk usage including indexes and TOAST (Part 40)", + "description": "Returns total on-disk size for each public-schema table broken down into heap, index, and TOAST segments. Ordered by total size descending.\n", + "tags": [ + "DB Scaling" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "default": 30, + "maximum": 100 + }, + "description": "Maximum number of tables to return" + } + ], + "responses": { + "200": { + "description": "Table size breakdown" + }, + "400": { + "description": "Invalid limit parameter" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/api/v1/db-scaling/bgwriter-stats": { + "get": { + "summary": "Background writer and checkpoint activity statistics (Part 41)", + "description": "Returns a single-row snapshot from pg_stat_bgwriter covering checkpoint frequency (timed vs requested), buffer write counts per writer, backend fsync calls, and checkpoint I/O durations. Useful for tuning checkpoint_completion_target and bgwriter_lru_maxpages.\n", + "tags": [ + "DB Scaling" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "BGWriter stats snapshot" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/api/v1/db-scaling/database-stats": { + "get": { + "summary": "Database-level statistics for the current database (Part 41)", + "description": "Returns a snapshot from pg_stat_database for the active database, including transaction throughput (commits/rollbacks), overall buffer cache hit ratio, temporary file usage, deadlock count, and conflict count since the last stats reset.\n", + "tags": [ + "DB Scaling" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Database-level stats snapshot" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/api/v1/exports/receipt/{txHash}/pdf": { + "get": { + "summary": "Export transaction receipt as PDF", + "tags": [ + "Exports" + ], + "parameters": [ + { + "in": "path", + "name": "txHash", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "PDF file" + } + } + } + }, + "/api/v1/exports/payroll/{organizationPublicKey}/{batchId}/excel": { + "get": { + "summary": "Export payroll report as Excel", + "tags": [ + "Exports" + ], + "parameters": [ + { + "in": "path", + "name": "organizationPublicKey", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "batchId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Excel file" } } } @@ -1298,6 +1922,91 @@ } } }, + "/api/v1/organizations/me": { + "get": { + "tags": [ + "Organizations" + ], + "summary": "Get current organization profile", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Organization profile" + } + } + } + }, + "/api/v1/organizations/me/name": { + "patch": { + "tags": [ + "Organizations" + ], + "summary": "Update organization name", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated organization name" + } + } + } + }, + "/api/v1/organizations/me/issuer": { + "patch": { + "tags": [ + "Organizations" + ], + "summary": "Update organization Stellar issuer account", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "issuerAccount": { + "type": "string", + "description": "Stellar public key (G...)" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated issuer account" + } + } + } + }, "/api/v1/payments/anchor-info": { "get": { "summary": "Get SEP-31 anchor configuration", @@ -1772,18 +2481,75 @@ } } }, - "/api/v1/search/organizations/{organizationId}/employees": { + "/api/v1/scaling/health": { "get": { - "summary": "Search and filter employees", + "summary": "Database connection-pool health snapshot", "tags": [ - "Data Search" + "Scaling" ], - "security": [ - { - "bearerAuth": [] + "responses": { + "200": { + "description": "Current pool utilisation" } - ], - "parameters": [ + } + } + }, + "/api/v1/scaling/query-stats": { + "get": { + "summary": "Recent slow-query statistics (admin only)", + "tags": [ + "Scaling" + ], + "parameters": [ + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "default": 20 + } + }, + { + "in": "query", + "name": "minMs", + "schema": { + "type": "integer", + "default": 200 + } + } + ], + "responses": { + "200": { + "description": "List of recent slow queries" + } + } + } + }, + "/api/v1/scaling/refresh-view": { + "post": { + "summary": "Refresh the daily transaction summary materialised view", + "tags": [ + "Scaling" + ], + "responses": { + "200": { + "description": "View refreshed successfully" + } + } + } + }, + "/api/v1/search/organizations/{organizationId}/employees": { + "get": { + "summary": "Search and filter employees", + "tags": [ + "Data Search" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ { "in": "path", "name": "organizationId", @@ -2126,6 +2892,40 @@ } } }, + "/api/v1/transactions": { + "get": { + "summary": "List transactions with pagination", + "tags": [ + "Transactions" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "query", + "name": "page", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, "/api/v1/trustline/check/{walletAddress}": { "get": { "summary": "Detect trustline status via Horizon", @@ -2868,6 +3668,120 @@ "security": [] } }, + "/auth/social-identities": { + "get": { + "tags": [ + "Auth" + ], + "summary": "Social identity management (requires authenticated user)", + "responses": { + "200": { + "description": "Success" + } + }, + "security": [] + } + }, + "/api/auth/social-identities": { + "get": { + "tags": [ + "Auth" + ], + "summary": "Social identity management (requires authenticated user)", + "responses": { + "200": { + "description": "Success" + } + }, + "security": [] + } + }, + "/api/v1/auth/social-identities": { + "get": { + "tags": [ + "Auth" + ], + "summary": "Social identity management (requires authenticated user)", + "responses": { + "200": { + "description": "Success" + } + }, + "security": [] + } + }, + "/auth/social-identities/{provider}": { + "delete": { + "tags": [ + "Auth" + ], + "summary": "DELETE /social-identities/:provider", + "parameters": [ + { + "in": "path", + "name": "provider", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "security": [] + } + }, + "/api/auth/social-identities/{provider}": { + "delete": { + "tags": [ + "Auth" + ], + "summary": "DELETE /social-identities/:provider", + "parameters": [ + { + "in": "path", + "name": "provider", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "security": [] + } + }, + "/api/v1/auth/social-identities/{provider}": { + "delete": { + "tags": [ + "Auth" + ], + "summary": "DELETE /social-identities/:provider", + "parameters": [ + { + "in": "path", + "name": "provider", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "security": [] + } + }, "/api/v1/balance/{accountId}": { "get": { "tags": [ @@ -2896,7 +3810,7 @@ "tags": [ "Balances" ], - "summary": "tags:", + "summary": "/api/balance/{accountId}:", "responses": { "200": { "description": "Success" @@ -3059,12 +3973,38 @@ } } }, + "/api/employees/bulk-import": { + "post": { + "tags": [ + "Employees" + ], + "summary": "Bulk import employees from CSV", + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/v1/employees/bulk-import": { + "post": { + "tags": [ + "Employees" + ], + "summary": "Bulk import employees from CSV", + "responses": { + "200": { + "description": "Success" + } + } + } + }, "/api/employees": { "post": { "tags": [ "Employees" ], - "summary": "Create a new employee", + "summary": "Bulk import employees from CSV", "responses": { "200": { "description": "Success" @@ -3075,7 +4015,7 @@ "tags": [ "Employees" ], - "summary": "Create a new employee", + "summary": "Bulk import employees from CSV", "responses": { "200": { "description": "Success" @@ -3088,7 +4028,7 @@ "tags": [ "Employees" ], - "summary": "Create a new employee", + "summary": "Bulk import employees from CSV", "responses": { "200": { "description": "Success" @@ -3099,7 +4039,7 @@ "tags": [ "Employees" ], - "summary": "Create a new employee", + "summary": "Bulk import employees from CSV", "responses": { "200": { "description": "Success" @@ -3112,7 +4052,7 @@ "tags": [ "Employees" ], - "summary": "Create a new employee", + "summary": "Bulk import employees from CSV", "parameters": [ { "in": "path", @@ -3133,7 +4073,28 @@ "tags": [ "Employees" ], - "summary": "Create a new employee", + "summary": "Bulk import employees from CSV", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } + }, + "put": { + "tags": [ + "Employees" + ], + "summary": "Bulk import employees from CSV", "parameters": [ { "in": "path", @@ -3177,7 +4138,7 @@ "tags": [ "Employees" ], - "summary": "Create a new employee", + "summary": "Bulk import employees from CSV", "parameters": [ { "in": "path", @@ -3198,7 +4159,7 @@ "tags": [ "Employees" ], - "summary": "Create a new employee", + "summary": "Bulk import employees from CSV", "parameters": [ { "in": "path", @@ -3215,11 +4176,11 @@ } } }, - "delete": { + "put": { "tags": [ "Employees" ], - "summary": "Create a new employee", + "summary": "Bulk import employees from CSV", "parameters": [ { "in": "path", @@ -3235,14 +4196,22 @@ "description": "Success" } } - } - }, - "/api/employees/bulk-import": { - "post": { + }, + "delete": { "tags": [ "Employees" ], - "summary": "POST /bulk-import", + "summary": "Create a new employee", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Success" @@ -3250,25 +4219,12 @@ } } }, - "/api/v1/employees/bulk-import": { - "post": { + "/api/v1/fees/recommendation": { + "get": { "tags": [ - "Employees" + "Fees" ], - "summary": "POST /bulk-import", - "responses": { - "200": { - "description": "Success" - } - } - } - }, - "/api/v1/fees/recommendation": { - "get": { - "tags": [ - "Fees" - ], - "summary": "GET /recommendation", + "summary": "GET /recommendation", "responses": { "200": { "description": "Success" @@ -3377,6 +4333,32 @@ } } }, + "/api/notifications/payment": { + "post": { + "tags": [ + "Notifications" + ], + "summary": "Send payment notification for a completed payroll transaction", + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/v1/notifications/payment": { + "post": { + "tags": [ + "Notifications" + ], + "summary": "Send payment notification for a completed payroll transaction", + "responses": { + "200": { + "description": "Success" + } + } + } + }, "/api/notifications/push-token": { "post": { "tags": [ @@ -3673,7 +4655,7 @@ "tags": [ "Payroll" ], - "summary": "GET /employees/:employeeId/summary", + "summary": "/", "parameters": [ { "in": "path", @@ -3696,7 +4678,7 @@ "tags": [ "Payroll" ], - "summary": "GET /employees/:employeeId/summary", + "summary": "/", "parameters": [ { "in": "path", @@ -4070,6 +5052,34 @@ } } }, + "/rates/convert": { + "get": { + "tags": [ + "Rates" + ], + "summary": "GET /convert", + "responses": { + "200": { + "description": "Success" + } + }, + "security": [] + } + }, + "/api/v1/rates/convert": { + "get": { + "tags": [ + "Rates" + ], + "summary": "GET /convert", + "responses": { + "200": { + "description": "Success" + } + }, + "security": [] + } + }, "/rates": { "get": { "tags": [ @@ -4300,286 +5310,6 @@ } } } - }, - "/webhooks/subscribe": { - "post": { - "tags": [ - "Webhooks" - ], - "summary": "POST /subscribe", - "responses": { - "200": { - "description": "Success" - } - } - } - }, - "/api/v1/webhooks/subscribe": { - "post": { - "tags": [ - "Webhooks" - ], - "summary": "POST /subscribe", - "responses": { - "200": { - "description": "Success" - } - } - } - }, - "/webhooks/subscriptions/{id}": { - "put": { - "tags": [ - "Webhooks" - ], - "summary": "PUT /subscriptions/:id", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Success" - } - } - }, - "get": { - "tags": [ - "Webhooks" - ], - "summary": "GET /subscriptions/:id", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Success" - } - } - }, - "delete": { - "tags": [ - "Webhooks" - ], - "summary": "DELETE /subscriptions/:id", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Success" - } - } - } - }, - "/api/v1/webhooks/subscriptions/{id}": { - "put": { - "tags": [ - "Webhooks" - ], - "summary": "PUT /subscriptions/:id", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Success" - } - } - }, - "get": { - "tags": [ - "Webhooks" - ], - "summary": "GET /subscriptions/:id", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Success" - } - } - }, - "delete": { - "tags": [ - "Webhooks" - ], - "summary": "DELETE /subscriptions/:id", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Success" - } - } - } - }, - "/webhooks/subscriptions": { - "get": { - "tags": [ - "Webhooks" - ], - "summary": "GET /subscriptions", - "responses": { - "200": { - "description": "Success" - } - } - } - }, - "/api/v1/webhooks/subscriptions": { - "get": { - "tags": [ - "Webhooks" - ], - "summary": "GET /subscriptions", - "responses": { - "200": { - "description": "Success" - } - } - } - }, - "/webhooks/subscriptions/{id}/delivery-logs": { - "get": { - "tags": [ - "Webhooks" - ], - "summary": "GET /subscriptions/:id/delivery-logs", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Success" - } - } - } - }, - "/api/v1/webhooks/subscriptions/{id}/delivery-logs": { - "get": { - "tags": [ - "Webhooks" - ], - "summary": "GET /subscriptions/:id/delivery-logs", - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Success" - } - } - } - }, - "/webhooks/events": { - "get": { - "tags": [ - "Webhooks" - ], - "summary": "GET /events", - "responses": { - "200": { - "description": "Success" - } - } - } - }, - "/api/v1/webhooks/events": { - "get": { - "tags": [ - "Webhooks" - ], - "summary": "GET /events", - "responses": { - "200": { - "description": "Success" - } - } - } - }, - "/webhooks/test-trigger": { - "post": { - "tags": [ - "Webhooks" - ], - "summary": "POST /test-trigger", - "responses": { - "200": { - "description": "Success" - } - } - } - }, - "/api/v1/webhooks/test-trigger": { - "post": { - "tags": [ - "Webhooks" - ], - "summary": "POST /test-trigger", - "responses": { - "200": { - "description": "Success" - } - } - } } } } \ No newline at end of file diff --git a/backend/src/__tests__/dbScalingRoutes.test.ts b/backend/src/__tests__/dbScalingRoutes.test.ts index ee62c84..af5b581 100644 --- a/backend/src/__tests__/dbScalingRoutes.test.ts +++ b/backend/src/__tests__/dbScalingRoutes.test.ts @@ -1,10 +1,13 @@ /** + * Integration tests for the DB Scaling endpoints (Parts 39, 40, 41 & 49). * Integration tests for the DB Scaling endpoints (Parts 37, 38, 39, 40, 42 & 50). * * Issues #282 (Part 37) — connection breakdown, db settings * Issues #283 (Part 38) — seq scan stats, WAL stats * Issues #284 (Part 39) — lock contention, unused indexes * Issues #285 (Part 40) — replication lag, table sizes + * Issues #286 (Part 41) — bgwriter stats, database stats + * Issues #294 (Part 49) — table I/O stats, index usage stats * Issues #287 (Part 42) — bgwriter stats, temp file usage * Issues #295 (Part 50) — database stats, block I/O stats * @@ -24,6 +27,10 @@ const mockGetLockContention = jest.fn(); const mockGetUnusedIndexes = jest.fn(); const mockGetReplicationLag = jest.fn(); const mockGetTableSizes = jest.fn(); +const mockGetBgwriterStats = jest.fn(); +const mockGetDatabaseStats = jest.fn(); +const mockGetTableIoStats = jest.fn(); +const mockGetIndexUsageStats = jest.fn(); // Also stub the methods used by existing controller handlers so the mock // implementation is complete (prevents "not a function" errors from other routes @@ -69,6 +76,10 @@ jest.mock('../services/dbScalingService.js', () => ({ getUnusedIndexes: mockGetUnusedIndexes, getReplicationLag: mockGetReplicationLag, getTableSizes: mockGetTableSizes, + getBgwriterStats: mockGetBgwriterStats, + getDatabaseStats: mockGetDatabaseStats, + getTableIoStats: mockGetTableIoStats, + getIndexUsageStats: mockGetIndexUsageStats, })), })); @@ -504,3 +515,213 @@ describe('GET /api/v1/db-scaling/table-sizes', () => { expect(res.status).toBe(500); }); }); + +// ─── Part 41: GET /api/v1/db-scaling/bgwriter-stats ────────────────────────── + +describe('GET /api/v1/db-scaling/bgwriter-stats', () => { + const fakeBgwriter = { + checkpointsTimed: 142, + checkpointsRequested: 3, + buffersCheckpoint: 28500, + buffersClean: 4200, + maxWrittenClean: 1, + buffersBackend: 9300, + buffersBackendFsync: 0, + buffersAlloc: 15000, + checkpointWriteTimeMs: 32500.5, + checkpointSyncTimeMs: 1200.3, + statsResetAt: '2026-01-01T00:00:00.000Z', + }; + + it('returns 200 with bgwriter stats snapshot', async () => { + mockGetBgwriterStats.mockResolvedValue(fakeBgwriter); + + const res = await request(app).get('/api/v1/db-scaling/bgwriter-stats'); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data).toMatchObject({ + checkpointsTimed: 142, + checkpointsRequested: 3, + buffersCheckpoint: 28500, + buffersBackend: 9300, + }); + }); + + it('returns 200 with zero-valued snapshot when pg returns no row', async () => { + mockGetBgwriterStats.mockResolvedValue({ + checkpointsTimed: 0, checkpointsRequested: 0, + buffersCheckpoint: 0, buffersClean: 0, maxWrittenClean: 0, + buffersBackend: 0, buffersBackendFsync: 0, buffersAlloc: 0, + checkpointWriteTimeMs: 0, checkpointSyncTimeMs: 0, statsResetAt: null, + }); + + const res = await request(app).get('/api/v1/db-scaling/bgwriter-stats'); + + expect(res.status).toBe(200); + expect(res.body.data.checkpointsTimed).toBe(0); + expect(res.body.data.statsResetAt).toBeNull(); + }); + + it('returns 500 when the service throws', async () => { + mockGetBgwriterStats.mockRejectedValue(new Error('pg error')); + + const res = await request(app).get('/api/v1/db-scaling/bgwriter-stats'); + + expect(res.status).toBe(500); + }); +}); + +// ─── Part 41: GET /api/v1/db-scaling/database-stats ────────────────────────── + +describe('GET /api/v1/db-scaling/database-stats', () => { + const fakeDbStats = { + dbName: 'payd_production', + numBackends: 12, + xactCommit: 5000000, + xactRollback: 3200, + blksRead: 800000, + blksHit: 9200000, + cacheHitRatio: 0.92, + tempFiles: 14, + tempBytes: 104857600, + deadlocks: 2, + conflictsTotal: 0, + statsResetAt: '2026-01-01T00:00:00.000Z', + }; + + it('returns 200 with database-level stats', async () => { + mockGetDatabaseStats.mockResolvedValue(fakeDbStats); + + const res = await request(app).get('/api/v1/db-scaling/database-stats'); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data).toMatchObject({ + dbName: 'payd_production', + numBackends: 12, + xactCommit: 5000000, + deadlocks: 2, + cacheHitRatio: 0.92, + }); + }); + + it('returns a cacheHitRatio of 1 when no blocks have been read or hit', async () => { + mockGetDatabaseStats.mockResolvedValue({ + ...fakeDbStats, + blksRead: 0, + blksHit: 0, + cacheHitRatio: 1, + }); + + const res = await request(app).get('/api/v1/db-scaling/database-stats'); + + expect(res.status).toBe(200); + expect(res.body.data.cacheHitRatio).toBe(1); + }); + + it('returns 500 when the service throws', async () => { + mockGetDatabaseStats.mockRejectedValue(new Error('pg error')); + + const res = await request(app).get('/api/v1/db-scaling/database-stats'); + + expect(res.status).toBe(500); + }); +}); + +// ─── Part 49: GET /api/v1/db-scaling/table-io-stats ────────────────────────── + +describe('GET /api/v1/db-scaling/table-io-stats', () => { + const fakeTableIo = [ + { + table: 'payroll_items', + heapBlksRead: 12000, + heapBlksHit: 980000, + heapCacheHitRatio: 0.9878, + idxBlksRead: 3000, + idxBlksHit: 450000, + toastBlksRead: 0, + toastBlksHit: 0, + }, + ]; + + it('returns 200 with per-table I/O snapshot', async () => { + mockGetTableIoStats.mockResolvedValue(fakeTableIo); + + const res = await request(app).get('/api/v1/db-scaling/table-io-stats'); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data[0]).toMatchObject({ + table: 'payroll_items', + heapBlksRead: 12000, + heapCacheHitRatio: 0.9878, + }); + }); + + it('returns 400 when limit is invalid', async () => { + const res = await request(app).get('/api/v1/db-scaling/table-io-stats?limit=0'); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it('returns 500 when the service throws', async () => { + mockGetTableIoStats.mockRejectedValue(new Error('pg error')); + + const res = await request(app).get('/api/v1/db-scaling/table-io-stats'); + + expect(res.status).toBe(500); + }); +}); + +// ─── Part 49: GET /api/v1/db-scaling/index-usage-stats ─────────────────────── + +describe('GET /api/v1/db-scaling/index-usage-stats', () => { + const fakeIndexUsage = [ + { + table: 'payroll_items', + index: 'payroll_items_employee_id_idx', + idxScan: 82000, + idxTupRead: 1640000, + idxTupFetch: 1580000, + }, + { + table: 'employees', + index: 'employees_org_id_idx', + idxScan: 0, + idxTupRead: 0, + idxTupFetch: 0, + }, + ]; + + it('returns 200 with per-index usage snapshot', async () => { + mockGetIndexUsageStats.mockResolvedValue(fakeIndexUsage); + + const res = await request(app).get('/api/v1/db-scaling/index-usage-stats'); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data[0]).toMatchObject({ + table: 'payroll_items', + index: 'payroll_items_employee_id_idx', + idxScan: 82000, + }); + expect(res.body.data[1].idxScan).toBe(0); + }); + + it('returns 400 when limit is invalid', async () => { + const res = await request(app).get('/api/v1/db-scaling/index-usage-stats?limit=abc'); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); + + it('returns 500 when the service throws', async () => { + mockGetIndexUsageStats.mockRejectedValue(new Error('pg error')); + + const res = await request(app).get('/api/v1/db-scaling/index-usage-stats'); + + expect(res.status).toBe(500); + }); +}); diff --git a/backend/src/controllers/dbScalingController.ts b/backend/src/controllers/dbScalingController.ts index 1231e8e..b56f38c 100644 --- a/backend/src/controllers/dbScalingController.ts +++ b/backend/src/controllers/dbScalingController.ts @@ -242,6 +242,64 @@ export class DbScalingController { } } + // ── Part 41 (#286) ───────────────────────────────────────────────────── + + /** #286a — Background writer and checkpoint statistics. */ + async getBgwriterStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const data = await service.getBgwriterStats(); + res.json({ success: true, data }); + } catch (err) { + logger.error({ err }, 'Failed to fetch bgwriter stats'); + next(err); + } + } + + /** #286b — Database-level statistics (transactions, cache hit ratio, deadlocks, temp files). */ + async getDatabaseStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const data = await service.getDatabaseStats(); + res.json({ success: true, data }); + } catch (err) { + logger.error({ err }, 'Failed to fetch database stats'); + next(err); + } + } + + // ── Part 49 (#294) ───────────────────────────────────────────────────── + + /** #294a — Per-table I/O stats (heap, index, TOAST blocks read vs hit). */ + async getTableIoStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const limit = Math.min(Number(req.query['limit'] ?? 30), 100); + if (isNaN(limit) || limit < 1) { + res.status(400).json({ success: false, error: 'limit must be a positive integer' }); + return; + } + const data = await service.getTableIoStats(limit); + res.json({ success: true, data }); + } catch (err) { + logger.error({ err }, 'Failed to fetch table I/O stats'); + next(err); + } + } + + /** #294b — Per-index access stats (scan count, rows read/fetched). */ + async getIndexUsageStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const limit = Math.min(Number(req.query['limit'] ?? 30), 100); + if (isNaN(limit) || limit < 1) { + res.status(400).json({ success: false, error: 'limit must be a positive integer' }); + return; + } + const data = await service.getIndexUsageStats(limit); + res.json({ success: true, data }); + } catch (err) { + logger.error({ err }, 'Failed to fetch index usage stats'); + next(err); + } + } + /** #285b — Per-table disk usage (table + indexes + TOAST). */ async getTableSizes(req: Request, res: Response, next: NextFunction): Promise { try { diff --git a/backend/src/routes/dbScalingRoutes.ts b/backend/src/routes/dbScalingRoutes.ts index be28e7e..88b6505 100644 --- a/backend/src/routes/dbScalingRoutes.ts +++ b/backend/src/routes/dbScalingRoutes.ts @@ -366,4 +366,110 @@ router.get('/replication-lag', (req, res, next) => ctrl.getReplicationLag(req, r */ router.get('/table-sizes', (req, res, next) => ctrl.getTableSizes(req, res, next)); +// ── Part 41 (#286) ────────────────────────────────────────────────────────── + +/** + * @swagger + * /api/v1/db-scaling/bgwriter-stats: + * get: + * summary: Background writer and checkpoint activity statistics (Part 41) + * description: > + * Returns a single-row snapshot from pg_stat_bgwriter covering checkpoint + * frequency (timed vs requested), buffer write counts per writer, backend + * fsync calls, and checkpoint I/O durations. Useful for tuning + * checkpoint_completion_target and bgwriter_lru_maxpages. + * tags: [DB Scaling] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: BGWriter stats snapshot + * 500: + * description: Internal server error + */ +router.get('/bgwriter-stats', (req, res, next) => ctrl.getBgwriterStats(req, res, next)); + +/** + * @swagger + * /api/v1/db-scaling/database-stats: + * get: + * summary: Database-level statistics for the current database (Part 41) + * description: > + * Returns a snapshot from pg_stat_database for the active database, + * including transaction throughput (commits/rollbacks), overall buffer + * cache hit ratio, temporary file usage, deadlock count, and conflict + * count since the last stats reset. + * tags: [DB Scaling] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Database-level stats snapshot + * 500: + * description: Internal server error + */ +router.get('/database-stats', (req, res, next) => ctrl.getDatabaseStats(req, res, next)); + +// ── Part 49 (#294) ────────────────────────────────────────────────────────── + +/** + * @swagger + * /api/v1/db-scaling/table-io-stats: + * get: + * summary: Per-table I/O statistics from pg_statio_user_tables (Part 49) + * description: > + * Returns heap, index, and TOAST block read/hit counts per table ordered + * by total disk reads descending. The computed heapCacheHitRatio reveals + * tables that are cache-cold and driving physical I/O. + * tags: [DB Scaling] + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: limit + * schema: + * type: integer + * default: 30 + * maximum: 100 + * description: Maximum number of tables to return + * responses: + * 200: + * description: Per-table I/O snapshot + * 400: + * description: Invalid limit parameter + * 500: + * description: Internal server error + */ +router.get('/table-io-stats', (req, res, next) => ctrl.getTableIoStats(req, res, next)); + +/** + * @swagger + * /api/v1/db-scaling/index-usage-stats: + * get: + * summary: Per-index access statistics from pg_stat_user_indexes (Part 49) + * description: > + * Returns scan count, rows read, and rows fetched per index ordered by + * scan frequency descending. Indexes with zero idx_scan are candidates + * for removal; hot indexes with high idx_tup_read inform cache sizing. + * tags: [DB Scaling] + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: limit + * schema: + * type: integer + * default: 30 + * maximum: 100 + * description: Maximum number of indexes to return + * responses: + * 200: + * description: Per-index usage snapshot + * 400: + * description: Invalid limit parameter + * 500: + * description: Internal server error + */ +router.get('/index-usage-stats', (req, res, next) => ctrl.getIndexUsageStats(req, res, next)); + export default router; diff --git a/backend/src/services/dbScalingService.ts b/backend/src/services/dbScalingService.ts index 99bf8e7..2def175 100644 --- a/backend/src/services/dbScalingService.ts +++ b/backend/src/services/dbScalingService.ts @@ -692,6 +692,242 @@ export class DbScalingService { })); } + // ── Part 41 (#286) ─────────────────────────────────────────────────────── + + /** + * #286a — Background writer / checkpoint activity from pg_stat_bgwriter. + * Surfaces checkpoint frequency, buffer write counts, and stall time so + * operators can tune checkpoint_completion_target and bgwriter settings. + */ + async getBgwriterStats(): Promise<{ + checkpointsTimed: number; + checkpointsRequested: number; + buffersCheckpoint: number; + buffersClean: number; + maxWrittenClean: number; + buffersBackend: number; + buffersBackendFsync: number; + buffersAlloc: number; + checkpointWriteTimeMs: number; + checkpointSyncTimeMs: number; + statsResetAt: string | null; + }> { + const rows = await this.prisma.$queryRaw>` + SELECT + checkpoints_timed, + checkpoints_req, + buffers_checkpoint, + buffers_clean, + maxwritten_clean, + buffers_backend, + buffers_backend_fsync, + buffers_alloc, + checkpoint_write_time, + checkpoint_sync_time, + stats_reset + FROM pg_stat_bgwriter + `; + const r = rows[0]; + if (!r) { + return { + checkpointsTimed: 0, checkpointsRequested: 0, + buffersCheckpoint: 0, buffersClean: 0, maxWrittenClean: 0, + buffersBackend: 0, buffersBackendFsync: 0, buffersAlloc: 0, + checkpointWriteTimeMs: 0, checkpointSyncTimeMs: 0, statsResetAt: null, + }; + } + return { + checkpointsTimed: Number(r.checkpoints_timed), + checkpointsRequested: Number(r.checkpoints_req), + buffersCheckpoint: Number(r.buffers_checkpoint), + buffersClean: Number(r.buffers_clean), + maxWrittenClean: Number(r.maxwritten_clean), + buffersBackend: Number(r.buffers_backend), + buffersBackendFsync: Number(r.buffers_backend_fsync), + buffersAlloc: Number(r.buffers_alloc), + checkpointWriteTimeMs: r.checkpoint_write_time, + checkpointSyncTimeMs: r.checkpoint_sync_time, + statsResetAt: r.stats_reset ? r.stats_reset.toISOString() : null, + }; + } + + /** + * #286b — Database-level statistics from pg_stat_database for the current DB. + * Provides transaction throughput, cache hit ratio, deadlock counts, and + * temporary file usage in a single snapshot. + */ + async getDatabaseStats(): Promise<{ + dbName: string; + numBackends: number; + xactCommit: number; + xactRollback: number; + blksRead: number; + blksHit: number; + cacheHitRatio: number; + tempFiles: number; + tempBytes: number; + deadlocks: number; + conflictsTotal: number; + statsResetAt: string | null; + }> { + const rows = await this.prisma.$queryRaw>` + SELECT + datname, + numbackends, + xact_commit, + xact_rollback, + blks_read, + blks_hit, + temp_files, + temp_bytes, + deadlocks, + conflicts, + stats_reset + FROM pg_stat_database + WHERE datname = current_database() + `; + const r = rows[0]; + if (!r) { + return { + dbName: '', numBackends: 0, xactCommit: 0, xactRollback: 0, + blksRead: 0, blksHit: 0, cacheHitRatio: 1, tempFiles: 0, + tempBytes: 0, deadlocks: 0, conflictsTotal: 0, statsResetAt: null, + }; + } + const blksRead = Number(r.blks_read); + const blksHit = Number(r.blks_hit); + return { + dbName: r.datname, + numBackends: r.numbackends, + xactCommit: Number(r.xact_commit), + xactRollback: Number(r.xact_rollback), + blksRead, + blksHit, + cacheHitRatio: blksRead + blksHit > 0 ? blksHit / (blksRead + blksHit) : 1, + tempFiles: Number(r.temp_files), + tempBytes: Number(r.temp_bytes), + deadlocks: Number(r.deadlocks), + conflictsTotal: Number(r.conflicts), + statsResetAt: r.stats_reset ? r.stats_reset.toISOString() : null, + }; + } + + // ── Part 49 (#294) ─────────────────────────────────────────────────────── + + /** + * #294a — Per-table I/O statistics from pg_statio_user_tables. + * Shows heap, index, and TOAST block reads from disk vs buffer-cache hits + * for each table. High disk-read ratios signal cache pressure or cold data. + */ + async getTableIoStats(limit = 30): Promise<{ + table: string; + heapBlksRead: number; + heapBlksHit: number; + heapCacheHitRatio: number; + idxBlksRead: number; + idxBlksHit: number; + toastBlksRead: number; + toastBlksHit: number; + }[]> { + const rows = await this.prisma.$queryRaw>` + SELECT + relname, + heap_blks_read, + heap_blks_hit, + COALESCE(idx_blks_read, 0) AS idx_blks_read, + COALESCE(idx_blks_hit, 0) AS idx_blks_hit, + COALESCE(toast_blks_read, 0) AS toast_blks_read, + COALESCE(toast_blks_hit, 0) AS toast_blks_hit + FROM pg_statio_user_tables + ORDER BY heap_blks_read + COALESCE(idx_blks_read, 0) DESC + LIMIT ${limit} + `; + return rows.map(r => { + const heapRead = Number(r.heap_blks_read); + const heapHit = Number(r.heap_blks_hit); + return { + table: r.relname, + heapBlksRead: heapRead, + heapBlksHit: heapHit, + heapCacheHitRatio: heapRead + heapHit > 0 ? heapHit / (heapRead + heapHit) : 1, + idxBlksRead: Number(r.idx_blks_read), + idxBlksHit: Number(r.idx_blks_hit), + toastBlksRead: Number(r.toast_blks_read), + toastBlksHit: Number(r.toast_blks_hit), + }; + }); + } + + /** + * #294b — Per-index access statistics from pg_stat_user_indexes. + * Surfaces scan counts, rows read, and rows fetched per index so operators + * can identify cold (never-scanned) indexes and highly-used ones. + */ + async getIndexUsageStats(limit = 30): Promise<{ + table: string; + index: string; + idxScan: number; + idxTupRead: number; + idxTupFetch: number; + }[]> { + const rows = await this.prisma.$queryRaw>` + SELECT + relname, + indexrelname, + idx_scan, + idx_tup_read, + idx_tup_fetch + FROM pg_stat_user_indexes + ORDER BY idx_scan DESC + LIMIT ${limit} + `; + return rows.map(r => ({ + table: r.relname, + index: r.indexrelname, + idxScan: Number(r.idx_scan), + idxTupRead: Number(r.idx_tup_read), + idxTupFetch: Number(r.idx_tup_fetch), + })); + } + /** * #285b — Table sizes: total on-disk size (table + indexes + TOAST) per table, * ordered largest first. Useful for capacity planning and spotting unexpected growth. diff --git a/backend/src/services/webhook.service.ts b/backend/src/services/webhook.service.ts new file mode 100644 index 0000000..f70347a --- /dev/null +++ b/backend/src/services/webhook.service.ts @@ -0,0 +1,90 @@ +import { pool } from '../config/database.js'; +import axios from 'axios'; + +export const WEBHOOK_EVENTS = { + PAYROLL_COMPLETED: 'payroll.completed', + PAYROLL_FAILED: 'payroll.failed', + PAYROLL_STARTED: 'payroll.started', + EMPLOYEE_ADDED: 'employee.added', + EMPLOYEE_UPDATED: 'employee.updated', + EMPLOYEE_REMOVED: 'employee.removed', + EMPLOYEE_DELETED: 'employee.deleted', + BALANCE_LOW: 'balance.low', + TRANSACTION_COMPLETED: 'transaction.completed', + TRANSACTION_FAILED: 'transaction.failed', + CONTRACT_UPGRADED: 'contract.upgraded', + MULTISIG_CREATED: 'multisig.created', + MULTISIG_EXECUTED: 'multisig.executed', + PAYMENT_COMPLETED: 'payment.completed', + PAYMENT_FAILED: 'payment.failed', + CLAIMABLE_BALANCE_CREATED: 'claimable_balance.created', + CLAIMABLE_BALANCE_CLAIMED: 'claimable_balance.claimed', +} as const; + +const MAX_ATTEMPTS = 4; + +function backoffMs(attempt: number): number { + return 1000 * Math.pow(2, attempt - 1); +} + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export class WebhookService { + static async dispatch( + eventType: string, + organizationId: number, + payload: object, + ): Promise { + const result = await pool.query( + `SELECT * FROM webhook_subscriptions + WHERE organization_id = $1 AND is_active = true + AND (events @> ARRAY[$2] OR events @> ARRAY['*'])`, + [organizationId, eventType], + ); + + if (result.rows.length === 0) return; + + const payloadStr = JSON.stringify(payload); + + await Promise.allSettled( + result.rows.map((sub) => this.deliverWithRetry(sub, eventType, payloadStr, 1)), + ); + } + + private static async deliverWithRetry( + subscription: { id: string | number; url: string }, + eventType: string, + payloadStr: string, + attempt: number, + ): Promise { + try { + await axios.post(subscription.url, payloadStr, { + headers: { 'Content-Type': 'application/json' }, + timeout: 10000, + }); + + await pool.query( + `INSERT INTO webhook_delivery_logs + (subscription_id, event_type, payload, response_status, response_body, error_message, attempt_number) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [subscription.id, eventType, payloadStr, null, null, null, attempt], + ); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + + await pool.query( + `INSERT INTO webhook_delivery_logs + (subscription_id, event_type, payload, response_status, response_body, error_message, attempt_number) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [subscription.id, eventType, payloadStr, null, null, errorMessage, attempt], + ); + + if (attempt < MAX_ATTEMPTS) { + await wait(backoffMs(attempt)); + await this.deliverWithRetry(subscription, eventType, payloadStr, attempt + 1); + } + } + } +} diff --git a/frontend/src/__tests__/PayrollAnalytics.test.tsx b/frontend/src/__tests__/PayrollAnalytics.test.tsx index 3ae3981..9eed2dc 100644 --- a/frontend/src/__tests__/PayrollAnalytics.test.tsx +++ b/frontend/src/__tests__/PayrollAnalytics.test.tsx @@ -31,9 +31,7 @@ vi.mock('recharts', () => { CartesianGrid: () => null, Tooltip: () => null, Legend: () => null, - ResponsiveContainer: ({ children }: { children?: React.ReactNode }) => ( -
{children}
- ), + ResponsiveContainer: ({ children }: { children?: React.ReactNode }) =>
{children}
, }; }); @@ -63,7 +61,7 @@ function renderComponent() { - , + ); } @@ -141,7 +139,7 @@ describe('PayrollAnalytics', () => { () => { expect(screen.getByText('Total Payroll')).toBeInTheDocument(); }, - { timeout: 3000 }, + { timeout: 3000 } ); }); diff --git a/frontend/src/__tests__/hooks/useWalletManager.test.ts b/frontend/src/__tests__/hooks/useWalletManager.test.ts index de7d57f..77fc756 100644 --- a/frontend/src/__tests__/hooks/useWalletManager.test.ts +++ b/frontend/src/__tests__/hooks/useWalletManager.test.ts @@ -1,7 +1,6 @@ import { renderHook, act, waitFor } from '@testing-library/react'; import { describe, expect, it, vi, beforeEach } from 'vitest'; import { useWalletManager } from '../../hooks/useWalletManager'; -import { StellarWalletsKit } from '@creit.tech/stellar-wallets-kit'; const mockSetWallet = vi.fn(); const mockGetAddress = vi.fn(); diff --git a/frontend/src/__tests__/transactionHistoryApi.test.ts b/frontend/src/__tests__/transactionHistoryApi.test.ts index efec73b..0238561 100644 --- a/frontend/src/__tests__/transactionHistoryApi.test.ts +++ b/frontend/src/__tests__/transactionHistoryApi.test.ts @@ -4,7 +4,6 @@ * Tests the normalization functions and API service layer */ -/* eslint-disable @typescript-eslint/no-base-to-string */ import { normalizeAuditRecord, normalizeContractEvent } from '../services/transactionHistoryApi'; import type { AuditRecord, ContractEvent } from '../types/transactionHistory'; import axiosInstance from '../api/axiosInstance'; diff --git a/frontend/src/__tests__/useContractMetrics.test.ts b/frontend/src/__tests__/useContractMetrics.test.ts index 14c619d..0e53eee 100644 --- a/frontend/src/__tests__/useContractMetrics.test.ts +++ b/frontend/src/__tests__/useContractMetrics.test.ts @@ -18,7 +18,10 @@ vi.mock('@stellar/stellar-sdk', () => ({ }, Contract: vi.fn(), TransactionBuilder: vi.fn(), - Networks: { TESTNET: 'Test SDF Network ; September 2015', PUBLIC: 'Public Global Stellar Network ; September 2015' }, + Networks: { + TESTNET: 'Test SDF Network ; September 2015', + PUBLIC: 'Public Global Stellar Network ; September 2015', + }, BASE_FEE: '100', xdr: {}, nativeToScVal: vi.fn(), @@ -33,9 +36,7 @@ describe('useContractMetrics', () => { }); it('starts with loading metrics', () => { - const { result } = renderHook(() => - useContractMetrics('GABC1234567890', 'testnet') - ); + const { result } = renderHook(() => useContractMetrics('GABC1234567890', 'testnet')); const allMetricGroups = [ result.current.metrics.bulk_payment, @@ -60,9 +61,7 @@ describe('useContractMetrics', () => { }); it('uses "warn" status for unconfigured contracts', async () => { - const { result } = renderHook(() => - useContractMetrics('GABC1234567890', 'testnet') - ); + const { result } = renderHook(() => useContractMetrics('GABC1234567890', 'testnet')); await waitFor(() => { expect(result.current.isLoading).toBe(false); @@ -74,16 +73,12 @@ describe('useContractMetrics', () => { }); it('exposes a refresh function', () => { - const { result } = renderHook(() => - useContractMetrics('GABC1234567890', 'testnet') - ); + const { result } = renderHook(() => useContractMetrics('GABC1234567890', 'testnet')); expect(typeof result.current.refresh).toBe('function'); }); it('records lastRefreshed after fetch completes', async () => { - const { result } = renderHook(() => - useContractMetrics('GABC1234567890', 'testnet') - ); + const { result } = renderHook(() => useContractMetrics('GABC1234567890', 'testnet')); await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.metrics.lastRefreshed).toBeInstanceOf(Date); diff --git a/frontend/src/components/Avatar.tsx b/frontend/src/components/Avatar.tsx index fc4198b..509c04a 100644 --- a/frontend/src/components/Avatar.tsx +++ b/frontend/src/components/Avatar.tsx @@ -67,7 +67,7 @@ export const Avatar: React.FC = ({ className="w-full h-full text-white font-semibold flex items-center justify-center" style={{ background: `linear-gradient(135deg, var(--accent), hsl(${Math.abs( - name.charCodeAt(0) * 12, + name.charCodeAt(0) * 12 )} 70% 50%))`, }} > diff --git a/frontend/src/components/CSVUploader.tsx b/frontend/src/components/CSVUploader.tsx index 52d74d9..b89c6b4 100644 --- a/frontend/src/components/CSVUploader.tsx +++ b/frontend/src/components/CSVUploader.tsx @@ -272,9 +272,7 @@ export const CSVUploader: React.FC = ({ )}

- {hasData - ? 'Drop a new file to replace' - : 'Drag and drop your CSV file here'} + {hasData ? 'Drop a new file to replace' : 'Drag and drop your CSV file here'}

or{' '} @@ -396,9 +394,7 @@ export const CSVUploader: React.FC = ({ className="px-4 py-3 text-sm text-[var(--text)] truncate max-w-[200px]" title={value} > - {value || ( - empty - )} + {value || empty} ))} @@ -406,10 +402,7 @@ export const CSVUploader: React.FC = ({ {row.errors.length > 0 ? (

    {row.errors.map((error) => ( -
  • +
  • {error}
  • ))} diff --git a/frontend/src/components/ComponentErrorBoundary.tsx b/frontend/src/components/ComponentErrorBoundary.tsx index 77c4817..bc52032 100644 --- a/frontend/src/components/ComponentErrorBoundary.tsx +++ b/frontend/src/components/ComponentErrorBoundary.tsx @@ -44,7 +44,10 @@ export default class ComponentErrorBoundary extends React.Component< this.setState({ hasError: false, error: null }); }; - componentDidUpdate(_prevProps: ComponentErrorBoundaryProps, prevState: ComponentErrorBoundaryState) { + componentDidUpdate( + _prevProps: ComponentErrorBoundaryProps, + prevState: ComponentErrorBoundaryState + ) { if (prevState.hasError && !this.state.hasError) { this.resetButtonRef.current?.focus(); } diff --git a/frontend/src/components/ContractMetricsPanel.tsx b/frontend/src/components/ContractMetricsPanel.tsx index 8577070..1d235b2 100644 --- a/frontend/src/components/ContractMetricsPanel.tsx +++ b/frontend/src/components/ContractMetricsPanel.tsx @@ -12,9 +12,7 @@ function MetricRow({ metric }: MetricRowProps) { ok: , warn: , error: , - loading: ( - - ), + loading: , }[metric.status]; return ( @@ -47,9 +45,7 @@ interface ContractCardProps { function ContractCard({ title, metrics }: ContractCardProps) { return (
    -

    - {title} -

    +

    {title}

    {metrics.map((m) => ( ))} @@ -102,10 +98,7 @@ export function ContractMetricsPanel({ aria-label="Refresh contract metrics" className="rounded-md p-1.5 text-zinc-400 hover:bg-zinc-800 hover:text-white disabled:opacity-40 transition-colors" > - +
    diff --git a/frontend/src/components/EmployeeList.tsx b/frontend/src/components/EmployeeList.tsx index c97276f..f48b107 100644 --- a/frontend/src/components/EmployeeList.tsx +++ b/frontend/src/components/EmployeeList.tsx @@ -421,11 +421,10 @@ export const EmployeeList: React.FC = ({ ) : null}
    - {!isLoading && ( - debouncedSearch || statusFilter !== 'All' + {!isLoading && + (debouncedSearch || statusFilter !== 'All' ? `${displayedEmployees.length} employee${displayedEmployees.length === 1 ? '' : 's'} found` - : '' - )} + : '')}
    {showEmptyState ?
    {renderEmptyState}
    : null} @@ -447,356 +446,371 @@ export const EmployeeList: React.FC = ({ {...provided.droppableProps} aria-label="Employee list — drag to reorder" > - {displayedEmployees.map((employee, index) => ( - - {(dragProvided, dragSnapshot) => ( -
    - {reorderMode && ( -
    - -
    - )} -
    - - -
    -
    - - - {employee.status || 'Active'} - -
    - -
    -
    -

    - Role -

    -

    - {employee.position} -

    -
    -
    -

    - Salary -

    -

    - ${(employee.salary ?? 0).toLocaleString()} / month -

    -
    -
    - -
    -
    -
    -

    - Wallet -

    - - {employee.wallet || 'No wallet assigned'} - -
    - {employee.wallet ? ( - - ) : null} -
    -
    - -
    - {onEditEmployee ? ( - - ) : null} - {onRemoveEmployee ? ( - - ) : null} -
    -
    -
    -
    - )} -
    - ))} - {provided.placeholder} - - )} - - - )} - - -
    - - - - - {reorderMode && ( - - ))} - - - - - {(provided) => ( - - {isLoading - ? Array.from({ length: SKELETON_ROW_COUNT }, (_, index) => ( - - )) - : displayedEmployees.map((employee, index) => ( - - {(dragProvided, dragSnapshot) => ( - - {reorderMode && ( - - )} - - - - - - - - )} - - ))} - {provided.placeholder} - - )} - -
    - )} - {[ - { key: 'name' as const, label: 'Name', width: 'w-[28%]' }, - { key: 'position' as const, label: 'Role', width: 'w-[18%]' }, - { key: 'wallet' as const, label: 'Wallet', width: 'w-[18%]' }, - { key: 'salary' as const, label: 'Salary', width: 'w-[14%]' }, - { key: 'status' as const, label: 'Status', width: '' }, - ].map((column) => ( - - - - Actions -
    -
    - -
    -
    -
    + {reorderMode && ( +
    + +
    + )} +
    + + +
    +
    - -
    - - +

    +

    {employee.email} - -

    -
    -
    -
    - - {employee.position} - - - Position +

    + + + {employee.status || 'Active'}
    -
    -
    - - {employee.wallet ? shortenWallet(employee.wallet) : 'No wallet'} - - {employee.wallet ? ( - - ) : null} + +
    +
    +

    + Role +

    +

    + {employee.position} +

    +
    +
    +

    + Salary +

    +

    + ${(employee.salary ?? 0).toLocaleString()} / month +

    +
    -
    -
    - {onEditEmployee ? ( - - ) : ( - - ${(employee.salary ?? 0).toLocaleString()} - - )} - - per month - + +
    +
    +
    +

    + Wallet +

    + + {employee.wallet || 'No wallet assigned'} + +
    + {employee.wallet ? ( + + ) : null} +
    -
    - - {employee.status || 'Active'} - - -
    + +
    {onEditEmployee ? ( ) : null} {onRemoveEmployee ? ( ) : null}
    -
    +
    + + + )} + + ))} + {provided.placeholder} + + )} + + + )} + + +
    + + + + + {reorderMode && + ))} + + + + + {(provided) => ( + + {isLoading + ? Array.from({ length: SKELETON_ROW_COUNT }, (_, index) => ( + + )) + : displayedEmployees.map((employee, index) => ( + + {(dragProvided, dragSnapshot) => ( + + {reorderMode && ( + + )} + + + + + + + + )} + + ))} + {provided.placeholder} + + )} + +
    } + {[ + { key: 'name' as const, label: 'Name', width: 'w-[28%]' }, + { key: 'position' as const, label: 'Role', width: 'w-[18%]' }, + { key: 'wallet' as const, label: 'Wallet', width: 'w-[18%]' }, + { key: 'salary' as const, label: 'Salary', width: 'w-[14%]' }, + { key: 'status' as const, label: 'Status', width: '' }, + ].map((column) => ( + + + + Actions +
    +
    + +
    +
    +
    + + +
    + + + {employee.email} + +
    +
    +
    +
    + + {employee.position} + + + Position + +
    +
    +
    + + {employee.wallet ? shortenWallet(employee.wallet) : 'No wallet'} + + {employee.wallet ? ( + + ) : null} +
    +
    +
    + {onEditEmployee ? ( + + ) : ( + + ${(employee.salary ?? 0).toLocaleString()} + + )} + + per month + +
    +
    + + {employee.status || 'Active'} + + +
    + {onEditEmployee ? ( + + ) : null} + {onRemoveEmployee ? ( + + ) : null} +
    +
    diff --git a/frontend/src/components/EmployeeProfileModal.tsx b/frontend/src/components/EmployeeProfileModal.tsx index 1dd19d9..7eb392d 100644 --- a/frontend/src/components/EmployeeProfileModal.tsx +++ b/frontend/src/components/EmployeeProfileModal.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { X, User, Mail, Phone, MapPin, Briefcase, CreditCard } from 'lucide-react'; +import { X, User, Mail, Phone, Briefcase, CreditCard } from 'lucide-react'; import { FormField } from './FormField'; export interface EmployeeProfileData { diff --git a/frontend/src/components/FormField.tsx b/frontend/src/components/FormField.tsx index 5e65514..9d5972a 100644 --- a/frontend/src/components/FormField.tsx +++ b/frontend/src/components/FormField.tsx @@ -43,11 +43,7 @@ export const FormField: React.FC = ({ required, 'aria-required': required, 'aria-invalid': hasError || undefined, - 'aria-describedby': error - ? errorId - : helpText - ? descriptionId - : undefined, + 'aria-describedby': error ? errorId : helpText ? descriptionId : undefined, className: [ typeof (children.props as Record).className === 'string' ? (children.props as Record).className diff --git a/frontend/src/components/SkeletonLoader.tsx b/frontend/src/components/SkeletonLoader.tsx index d3726b8..faef635 100644 --- a/frontend/src/components/SkeletonLoader.tsx +++ b/frontend/src/components/SkeletonLoader.tsx @@ -66,8 +66,7 @@ const WIDTH_MAP: Record, string> = { // ── Base shimmer element ────────────────────────────────────────────────────── -const SHIMMER_BASE = - 'animate-pulse rounded bg-zinc-800/70 relative overflow-hidden'; +const SHIMMER_BASE = 'animate-pulse rounded bg-zinc-800/70 relative overflow-hidden'; // ── Renderers ───────────────────────────────────────────────────────────────── @@ -107,11 +106,7 @@ function CardSkeleton({ count = 1, height = 32, className = '' }: SkeletonCardPr ); } -function TableRowSkeleton({ - count = 3, - columns = 4, - className = '', -}: SkeletonTableRowProps) { +function TableRowSkeleton({ count = 3, columns = 4, className = '' }: SkeletonTableRowProps) { return ( <> {Array.from({ length: count }, (_, rowIdx) => ( diff --git a/frontend/src/components/ThemeToggle.tsx b/frontend/src/components/ThemeToggle.tsx index 8fb3120..08c96ac 100644 --- a/frontend/src/components/ThemeToggle.tsx +++ b/frontend/src/components/ThemeToggle.tsx @@ -13,7 +13,11 @@ export const ThemeToggle = () => { aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'} aria-pressed={isDark} > - {isDark ?
diff --git a/frontend/src/components/payroll/ScheduleSummaryCard.tsx b/frontend/src/components/payroll/ScheduleSummaryCard.tsx index 99edf37..3c2b6a0 100644 --- a/frontend/src/components/payroll/ScheduleSummaryCard.tsx +++ b/frontend/src/components/payroll/ScheduleSummaryCard.tsx @@ -10,7 +10,14 @@ interface Props { group: string; } -export const ScheduleSummaryCard: React.FC = ({ name, frequency, startDate, time, timezone, group }) => { +export const ScheduleSummaryCard: React.FC = ({ + name, + frequency, + startDate, + time, + timezone, + group, +}) => { return (
@@ -20,21 +27,28 @@ export const ScheduleSummaryCard: React.FC = ({ name, frequency, startDat
Name
-
{name || '-'}
+
+ {name || '-'} +
Frequency
-
{frequency}
+
+ {frequency} +
First Run
- {startDate ? new Date(startDate).toLocaleDateString() : '-'} at {time || '-'} ({timezone}) + {startDate ? new Date(startDate).toLocaleDateString() : '-'} at {time || '-'} ( + {timezone})
Group
-
{group.replace('-', ' ') || '-'}
+
+ {group.replace('-', ' ') || '-'} +
diff --git a/frontend/src/hooks/useContractMetrics.ts b/frontend/src/hooks/useContractMetrics.ts index 24a3941..e70cd69 100644 --- a/frontend/src/hooks/useContractMetrics.ts +++ b/frontend/src/hooks/useContractMetrics.ts @@ -84,8 +84,9 @@ async function simulateReadCall( sourceAccount: string, network: NetworkType ): Promise { - const { rpc, Contract, TransactionBuilder, Networks, BASE_FEE, xdr, nativeToScVal, scValToNative } = - await import('@stellar/stellar-sdk'); + const { rpc, Contract, TransactionBuilder, Networks, BASE_FEE, scValToNative } = await import( + '@stellar/stellar-sdk' + ); const rpcUrl = network === 'mainnet' @@ -176,7 +177,9 @@ export function useContractMetrics( ? val.toString() : val == null ? '—' - : String(val); + : typeof val === 'object' + ? JSON.stringify(val) + : String(val as string | number | symbol); return { label, value: display, status: 'ok' }; } catch { return errorMetric(label); diff --git a/frontend/src/pages/CrossAssetPayment.tsx b/frontend/src/pages/CrossAssetPayment.tsx index 63ba812..b078a75 100644 --- a/frontend/src/pages/CrossAssetPayment.tsx +++ b/frontend/src/pages/CrossAssetPayment.tsx @@ -206,7 +206,9 @@ export default function CrossAssetPayment() { Selected route

- {selectedPath ? selectedPath.hops.join(' -> ') : 'Choose a path after entering an amount'} + {selectedPath + ? selectedPath.hops.join(' -> ') + : 'Choose a path after entering an amount'}

diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 420d444..5c432c4 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -74,7 +74,9 @@ export default function Home() {

Employee ops

-

Onboard without context loss.

+

+ Onboard without context loss. +

@@ -112,7 +114,9 @@ export default function Home() {

Payments

-

Single-transaction flow

+

+ Single-transaction flow +

@@ -125,7 +129,9 @@ export default function Home() {

Roster

-

Keep employee data current

+

+ Keep employee data current +

diff --git a/frontend/src/pages/PayrollAnalytics.tsx b/frontend/src/pages/PayrollAnalytics.tsx index 334e30c..81cfd69 100644 --- a/frontend/src/pages/PayrollAnalytics.tsx +++ b/frontend/src/pages/PayrollAnalytics.tsx @@ -104,12 +104,12 @@ function presetDates(months: number): { start: string; end: string } { async function fetchAnalytics( startDate: string, endDate: string, - organizationId: number, + organizationId: number ): Promise { try { const { data } = await axiosInstance.get<{ success: boolean; data: AnalyticsData }>( '/api/v1/analytics/payroll', - { params: { organizationId, startDate, endDate } }, + { params: { organizationId, startDate, endDate } } ); if (data.success) return data.data; throw new Error('API returned success: false'); @@ -215,15 +215,12 @@ export default function PayrollAnalytics() { staleTime: 5 * 60 * 1000, }); - const applyPreset = useCallback( - (months: number, label: string) => { - const { start, end } = presetDates(months); - setStartDate(start); - setEndDate(end); - setActivePreset(label); - }, - [], - ); + const applyPreset = useCallback((months: number, label: string) => { + const { start, end } = presetDates(months); + setStartDate(start); + setEndDate(end); + setActivePreset(label); + }, []); const handleStartChange = (e: React.ChangeEvent) => { setStartDate(e.target.value); @@ -244,7 +241,6 @@ export default function PayrollAnalytics() { return (
- {/* Header */}

@@ -619,7 +615,12 @@ export default function PayrollAnalytics() { - + @@ -639,13 +640,20 @@ export default function PayrollAnalytics() {

Total expenditure and headcount per department

- + - + { if (name === 'Total ($)') { const num = Number(Array.isArray(v) ? v[0] : (v ?? 0)); - return [`$${Math.round(num).toLocaleString()}`, name]; + return [`$${Math.round(num).toLocaleString()}`, name]; } return [v, name]; }} contentStyle={tooltipStyle} /> - - + +
diff --git a/frontend/src/pages/PayrollScheduler.tsx b/frontend/src/pages/PayrollScheduler.tsx index 8bf1c38..6621b5a 100644 --- a/frontend/src/pages/PayrollScheduler.tsx +++ b/frontend/src/pages/PayrollScheduler.tsx @@ -13,7 +13,6 @@ import { AutosaveIndicator } from '../components/AutosaveIndicator'; import { BulkPaymentStatusTracker } from '../components/BulkPaymentStatusTracker'; import { CountdownTimer } from '../components/CountdownTimer'; import { FormField } from '../components/FormField'; -import { SchedulingWizard } from '../components/SchedulingWizard'; import { PayrollScheduleForm } from '../components/payroll/PayrollScheduleForm'; import { TransactionSimulationPanel } from '../components/TransactionSimulationPanel'; import { useAutosave } from '../hooks/useAutosave'; @@ -144,20 +143,6 @@ export default function PayrollScheduler() { } }, []); - const handleScheduleComplete = (config: SchedulingConfig) => { - setActiveSchedule(config); - setIsWizardOpen(false); - notifySuccess( - 'Payroll schedule configured!', - `Frequency: ${config.frequency}, time: ${config.timeOfDay}` - ); - - // Persist config so the countdown survives refresh. - localStorage.setItem(scheduleStorageKey, JSON.stringify(config)); - - setNextRunDate(computeNextRunDate(config, new Date())); - }; - const handleChange = ( e: React.ChangeEvent ) => { diff --git a/frontend/src/pages/RevenueSplitDashboard.tsx b/frontend/src/pages/RevenueSplitDashboard.tsx index 13b28e9..79f6778 100644 --- a/frontend/src/pages/RevenueSplitDashboard.tsx +++ b/frontend/src/pages/RevenueSplitDashboard.tsx @@ -291,7 +291,9 @@ export default function RevenueSplitDashboard() { {totalAllocation.toFixed(2)}%

- {isAllocationTotalValid ? 'Balanced and ready to submit.' : 'Adjust entries to hit 100%.'} + {isAllocationTotalValid + ? 'Balanced and ready to submit.' + : 'Adjust entries to hit 100%.'}

@@ -464,7 +466,9 @@ export default function RevenueSplitDashboard() { disabled={isSaving || !isAllocationTotalValid} className="rounded-xl bg-accent px-4 py-2 font-bold text-black disabled:opacity-70" > - {isSaving ? t('revenueSplitDashboard.submitting') : t('revenueSplitDashboard.editAllocations')} + {isSaving + ? t('revenueSplitDashboard.submitting') + : t('revenueSplitDashboard.editAllocations')}
@@ -510,7 +514,9 @@ export default function RevenueSplitDashboard() { ) : null}
{events.length === 0 ? ( -

No backend indexed distribution events found.

+

+ No backend indexed distribution events found. +

) : (
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 5bb740f..a9ea63e 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,11 +1,8 @@ -import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Globe, Check } from 'lucide-react'; export default function Settings() { const { t, i18n } = useTranslation(); - const { theme, toggleTheme } = useTheme(); - const [languageLoading, setLanguageLoading] = useState(false); const languages = [ { code: 'en', name: t('settings.languageEnglish'), nativeName: 'English' }, @@ -97,6 +94,6 @@ export default function Settings() { - + ); } diff --git a/frontend/src/providers/ToastProvider.tsx b/frontend/src/providers/ToastProvider.tsx index e52c9ed..c345113 100644 --- a/frontend/src/providers/ToastProvider.tsx +++ b/frontend/src/providers/ToastProvider.tsx @@ -23,13 +23,31 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre }); }, []); - const showSuccess = useCallback((message: string, title?: string, duration?: number) => showToast({ type: 'success', message, title, duration }), [showToast]); - const showError = useCallback((message: string, title?: string, duration?: number) => showToast({ type: 'error', message, title, duration }), [showToast]); - const showWarning = useCallback((message: string, title?: string, duration?: number) => showToast({ type: 'warning', message, title, duration }), [showToast]); - const showInfo = useCallback((message: string, title?: string, duration?: number) => showToast({ type: 'info', message, title, duration }), [showToast]); + const showSuccess = useCallback( + (message: string, title?: string, duration?: number) => + showToast({ type: 'success', message, title, duration }), + [showToast] + ); + const showError = useCallback( + (message: string, title?: string, duration?: number) => + showToast({ type: 'error', message, title, duration }), + [showToast] + ); + const showWarning = useCallback( + (message: string, title?: string, duration?: number) => + showToast({ type: 'warning', message, title, duration }), + [showToast] + ); + const showInfo = useCallback( + (message: string, title?: string, duration?: number) => + showToast({ type: 'info', message, title, duration }), + [showToast] + ); return ( - + {children} diff --git a/frontend/src/services/transactionHistoryApi.ts b/frontend/src/services/transactionHistoryApi.ts index 8736c8c..cddd5de 100644 --- a/frontend/src/services/transactionHistoryApi.ts +++ b/frontend/src/services/transactionHistoryApi.ts @@ -23,7 +23,6 @@ import type { ContractEvent, } from '../types/transactionHistory'; import axiosInstance from '../api/axiosInstance'; -import { AxiosError } from 'axios'; // ============================================================================ // Configuration @@ -41,12 +40,7 @@ import { AxiosError } from 'axios'; */ export function categorizeError(error: unknown): ErrorState { // Network errors (fetch failures, timeouts, DNS issues) - if ( - error instanceof TypeError || - (error as any).name === 'TypeError' || - (error instanceof Error && error.message?.includes('fetch')) || - (error as any).message?.includes('fetch') - ) { + if (error instanceof TypeError || (error instanceof Error && error.message?.includes('fetch'))) { return { type: 'network', message: 'Unable to connect. Please check your internet connection.', @@ -128,7 +122,7 @@ export async function fetchAuditRecords( return response.data; } catch (error) { - if ((error as any).name === 'CanceledError') { + if (error instanceof Error && error.name === 'CanceledError') { throw new Error('The operation was aborted.'); } @@ -174,7 +168,7 @@ export async function fetchContractEvents( return response.data; } catch (error) { - if ((error as any).name === 'CanceledError') { + if (error instanceof Error && error.name === 'CanceledError') { throw new Error('The operation was aborted.'); }