From 2e7a0196a00dea10263b503d8a2e02b839efd03c Mon Sep 17 00:00:00 2001 From: Muhammad Moiz Date: Wed, 10 Sep 2025 07:33:31 -0400 Subject: [PATCH] chore: production prep (format, lint rules, small fixes); build passes across workspace; add env examples and ignore .env.local --- .eslintrc.cjs | 20 +- .github/workflows/ci.yml | 4 +- .github/workflows/codeql.yml | 7 +- .gitignore | 2 + .prettierrc | 1 - .vscode/extensions.json | 1 - apps/api-gateway/jest.config.ts | 1 - apps/api-gateway/package.json | 6 +- apps/api-gateway/src/main.ts | 50 ++ .../src/modules/analytics.controller.ts | 103 +++ .../src/modules/analytics.resolver.ts | 613 ++++++++++++++++++ apps/api-gateway/src/modules/app.module.ts | 29 +- apps/api-gateway/src/modules/app.resolver.ts | 27 +- apps/api-gateway/src/modules/auth.resolver.ts | 8 +- .../src/modules/labels.resolver.ts | 5 +- .../src/modules/metrics.controller.ts | 3 +- .../src/modules/notifications.resolver.ts | 155 +++++ .../src/modules/orders.resolver.ts | 5 +- apps/api-gateway/src/modules/pg.ts | 18 + .../api-gateway/src/modules/rates.resolver.ts | 37 ++ .../src/modules/refunds.resolver.ts | 72 ++ .../src/modules/returns.resolver.ts | 4 +- .../src/modules/security/jwt.context.ts | 10 +- .../src/modules/security/jwt.guard.ts | 26 +- .../src/modules/security/roles.decorator.ts | 5 + .../src/modules/security/roles.guard.ts | 22 + .../src/modules/subscriptions.resolver.ts | 1 - .../src/modules/subscriptions.service.ts | 43 +- .../src/modules/users-admin.resolver.ts | 123 ++++ .../api-gateway/src/modules/users.resolver.ts | 65 ++ apps/api-gateway/src/types.d.ts | 2 - apps/api-gateway/test/orders.resolver.spec.ts | 10 +- apps/api-gateway/tsconfig.build.json | 1 - apps/api-gateway/tsconfig.json | 9 +- apps/merchant-dashboard/Dockerfile | 46 ++ apps/merchant-dashboard/next.config.js | 53 +- apps/merchant-dashboard/package.json | 17 +- apps/merchant-dashboard/postcss.config.js | 6 + .../src/components/Layout.tsx | 145 +++++ .../src/components/charts.tsx | 286 ++++++++ apps/merchant-dashboard/src/components/ui.tsx | 81 +++ apps/merchant-dashboard/src/lib/graphql.ts | 45 +- .../src/lib/subscriptions.ts | 14 +- apps/merchant-dashboard/src/pages/_app.tsx | 43 ++ .../src/pages/_document.tsx | 17 + .../src/pages/analytics.tsx | 119 ++++ .../src/pages/analytics/labels.tsx | 49 ++ .../src/pages/analytics/orders.tsx | 49 ++ .../src/pages/analytics/refunds.tsx | 67 ++ .../src/pages/analytics/returns.tsx | 68 ++ .../src/pages/analytics/webhooks.tsx | 56 ++ .../src/pages/api/health.ts | 1 - apps/merchant-dashboard/src/pages/index.tsx | 194 ++++-- .../src/pages/integrations.tsx | 110 ++++ apps/merchant-dashboard/src/pages/login.tsx | 72 +- .../src/pages/notifications.tsx | 188 ++++++ apps/merchant-dashboard/src/pages/orders.tsx | 201 ++++-- apps/merchant-dashboard/src/pages/returns.tsx | 406 +++++++++++- apps/merchant-dashboard/src/pages/rules.tsx | 188 ++++-- .../merchant-dashboard/src/pages/settings.tsx | 131 ++++ .../src/pages/shipments.tsx | 207 ++++++ apps/merchant-dashboard/src/pages/signup.tsx | 89 ++- apps/merchant-dashboard/src/pages/users.tsx | 75 +++ .../merchant-dashboard/src/pages/webhooks.tsx | 179 +++-- .../merchant-dashboard/src/styles/globals.css | 54 ++ apps/merchant-dashboard/tailwind.config.js | 24 + apps/merchant-dashboard/tsconfig.json | 16 +- apps/returns-portal/next.config.js | 1 - apps/returns-portal/package.json | 4 + apps/returns-portal/src/lib/subscriptions.ts | 14 +- apps/returns-portal/src/pages/_app.tsx | 13 + apps/returns-portal/src/pages/index.tsx | 16 +- apps/returns-portal/src/pages/return/[id].tsx | 65 +- apps/returns-portal/tsconfig.json | 16 +- apps/test-store/.env.example | 12 + apps/test-store/next-env.d.ts | 5 + apps/test-store/next.config.js | 5 + apps/test-store/package.json | 22 + apps/test-store/src/lib/config.ts | 7 + apps/test-store/src/pages/_app.tsx | 38 ++ apps/test-store/src/pages/api/_util.ts | 27 + apps/test-store/src/pages/api/bulk.ts | 37 ++ apps/test-store/src/pages/api/order.ts | 60 ++ apps/test-store/src/pages/api/orders.ts | 22 + apps/test-store/src/pages/checkout.tsx | 134 ++++ apps/test-store/src/pages/index.tsx | 79 +++ apps/test-store/src/pages/orders.tsx | 78 +++ apps/test-store/tsconfig.json | 14 + apps/warehouse/next-env.d.ts | 3 +- apps/warehouse/next.config.js | 1 - apps/warehouse/package.json | 5 +- apps/warehouse/src/pages/_app.tsx | 13 + apps/warehouse/src/pages/index.tsx | 7 +- apps/warehouse/src/pages/scan.tsx | 50 +- apps/warehouse/tsconfig.json | 4 +- docker-compose.yml | 134 +++- e2e/tests/auth.spec.ts | 1 - e2e/tests/labels.spec.ts | 35 +- e2e/tests/orders.spec.ts | 15 +- e2e/tests/returns.spec.ts | 21 +- e2e/tests/rules.spec.ts | 6 +- packages/config/package.json | 1 - packages/config/src/index.js | 30 +- packages/config/src/index.ts | 1 - packages/idempotency/src/index.js | 159 ++--- packages/idempotency/src/index.ts | 5 +- packages/logger/package.json | 1 - packages/logger/src/index.js | 30 +- packages/logger/src/index.ts | 1 - packages/messaging/src/index.ts | 1 - packages/telemetry/package.json | 1 - packages/telemetry/src/index.ts | 14 +- packages/ui/package.json | 26 + packages/ui/src/components/Card.tsx | 25 + packages/ui/src/components/Chip.tsx | 15 + packages/ui/src/components/Stepper.tsx | 20 + packages/ui/src/components/Table.tsx | 77 +++ packages/ui/src/components/Tooltip.tsx | 9 + packages/ui/src/index.ts | 6 + packages/ui/src/theme/ThemeProvider.tsx | 102 +++ packages/ui/styles/components.css | 135 ++++ packages/ui/styles/themes.css | 77 +++ packages/ui/styles/tokens.css | 37 ++ packages/ui/tsconfig.build.json | 13 + packages/webhooks-core/package.json | 1 - packages/webhooks-core/src/index.ts | 1 - packages/workflows/package.json | 1 - packages/workflows/src/index.ts | 1 - packages/workflows/src/orders.machine.ts | 1 - packages/workflows/src/returns.machine.ts | 3 +- pnpm-workspace.yaml | 1 - scripts/seed-orders.mjs | 19 +- services/analytics/Dockerfile | 44 ++ services/analytics/jest.config.ts | 15 + services/analytics/package.json | 35 + services/analytics/src/backfill.controller.ts | 22 + services/analytics/src/backfill.service.ts | 225 +++++++ services/analytics/src/clickhouse.ts | 35 + services/analytics/src/ingest.worker.ts | 264 ++++++++ services/analytics/src/main.ts | 31 + services/analytics/src/metrics.controller.ts | 42 ++ services/analytics/src/pg.ts | 18 + services/analytics/src/spool.ts | 51 ++ services/analytics/test/unit/mapper.spec.ts | 15 + services/analytics/tsconfig.build.json | 9 + services/analytics/tsconfig.json | 12 + services/notifications/Dockerfile | 46 ++ services/notifications/jest.config.ts | 10 + services/notifications/package.json | 50 ++ services/notifications/prisma/schema.prisma | 55 ++ services/notifications/src/main.ts | 83 +++ .../src/notifications.controller.ts | 85 +++ .../src/preferences.controller.ts | 53 ++ services/notifications/src/prisma.service.ts | 27 + .../src/provider-webhooks.controller.ts | 67 ++ services/notifications/src/providers.ts | 132 ++++ .../notifications/src/templates.controller.ts | 64 ++ .../notifications/src/templates.service.ts | 19 + .../unit/provider-webhooks.controller.spec.ts | 33 + .../test/unit/templates.render.spec.ts | 26 + services/notifications/tsconfig.build.json | 9 + services/notifications/tsconfig.json | 13 + services/orders/jest.config.ts | 1 - services/orders/prisma/schema.prisma | 81 ++- services/orders/src/carriers.ts | 58 ++ services/orders/src/labels.controller.ts | 34 +- services/orders/src/main.ts | 29 +- services/orders/src/metrics.controller.ts | 15 + services/orders/src/orders.controller.ts | 12 +- services/orders/src/orders.dto.ts | 1 - services/orders/src/orders.service.ts | 46 +- services/orders/src/outbox.worker.ts | 22 +- services/orders/src/payments.providers.ts | 75 +++ services/orders/src/prisma.service.ts | 16 +- .../src/provider-webhooks.controller.ts | 45 ++ services/orders/src/rates.controller.ts | 32 + services/orders/src/refunds.controller.ts | 116 ++++ services/orders/src/refunds.worker.ts | 95 +++ services/orders/src/returns.controller.ts | 110 +++- services/orders/src/storage.ts | 17 +- .../test/int/labels.controller.int.spec.ts | 36 +- .../test/int/orders.controller.int.spec.ts | 30 +- services/orders/test/int/outbox.int.spec.ts | 1 - .../test/int/rates.controller.int.spec.ts | 34 + .../test/int/refunds.controller.int.spec.ts | 94 +++ .../test/int/returns.controller.int.spec.ts | 9 +- .../test/int/returns.metrics.int.spec.ts | 5 +- .../test/int/returns.scan.auth.int.spec.ts | 27 +- .../test/int/returns.transitions.int.spec.ts | 1 - .../orders/test/unit/orders.service.spec.ts | 53 +- services/orders/test/unit/storage.spec.ts | 13 +- services/orders/tsconfig.json | 15 +- services/rules/src/evaluator.ts | 24 +- services/rules/src/main.ts | 8 +- services/rules/src/outbox.worker.ts | 22 +- services/rules/src/prisma.service.ts | 16 +- services/rules/src/rules.controller.ts | 31 +- services/rules/src/rules.service.ts | 98 ++- services/rules/test/unit/evaluator.spec.ts | 9 +- .../test/unit/rules.service.actions.spec.ts | 11 +- services/rules/tsconfig.build.json | 1 - services/shopify/Dockerfile | 47 ++ services/shopify/jest.config.ts | 11 + services/shopify/package.json | 48 ++ services/shopify/prisma/schema.prisma | 24 + services/shopify/src/main.ts | 56 ++ services/shopify/src/metrics.controller.ts | 27 + services/shopify/src/prisma.service.ts | 27 + services/shopify/src/shopify.controller.ts | 304 +++++++++ .../test/unit/shopify.controller.spec.ts | 17 + services/shopify/tsconfig.build.json | 10 + services/shopify/tsconfig.json | 11 + services/users/Dockerfile | 2 +- services/users/package.json | 2 +- services/users/prisma/schema.prisma | 34 + services/users/src/api-keys.controller.ts | 74 +++ services/users/src/audit.controller.ts | 53 ++ services/users/src/auth.controller.ts | 35 +- services/users/src/auth.dto.ts | 1 - services/users/src/auth.service.ts | 17 +- services/users/src/devices.controller.ts | 18 +- services/users/src/jwt.ts | 5 +- services/users/src/main.ts | 16 +- services/users/src/prisma.service.ts | 18 +- services/users/src/users.controller.ts | 48 ++ .../test/int/auth.controller.int.spec.ts | 20 +- .../test/int/auth.controller.pg.int.spec.ts | 12 +- services/users/test/unit/auth.service.spec.ts | 22 +- services/users/test/unit/jwt.spec.ts | 6 +- services/users/tsconfig.build.json | 1 - services/users/tsconfig.json | 4 +- services/webhooks/jest.config.ts | 1 - services/webhooks/src/main.ts | 8 +- services/webhooks/src/outbox.worker.ts | 22 +- services/webhooks/src/prisma.service.ts | 16 +- services/webhooks/src/webhooks.controller.ts | 26 +- services/webhooks/src/webhooks.dto.ts | 1 - services/webhooks/src/webhooks.service.ts | 131 +++- services/webhooks/src/worker.ts | 20 +- services/webhooks/test/unit/backoff.spec.ts | 1 - services/webhooks/tsconfig.build.json | 1 - test-results/.last-run.json | 2 +- tsconfig.json | 9 +- 243 files changed, 9847 insertions(+), 856 deletions(-) create mode 100644 apps/api-gateway/src/modules/analytics.controller.ts create mode 100644 apps/api-gateway/src/modules/analytics.resolver.ts create mode 100644 apps/api-gateway/src/modules/notifications.resolver.ts create mode 100644 apps/api-gateway/src/modules/pg.ts create mode 100644 apps/api-gateway/src/modules/rates.resolver.ts create mode 100644 apps/api-gateway/src/modules/refunds.resolver.ts create mode 100644 apps/api-gateway/src/modules/security/roles.decorator.ts create mode 100644 apps/api-gateway/src/modules/security/roles.guard.ts create mode 100644 apps/api-gateway/src/modules/users-admin.resolver.ts create mode 100644 apps/api-gateway/src/modules/users.resolver.ts create mode 100644 apps/merchant-dashboard/Dockerfile create mode 100644 apps/merchant-dashboard/postcss.config.js create mode 100644 apps/merchant-dashboard/src/components/Layout.tsx create mode 100644 apps/merchant-dashboard/src/components/charts.tsx create mode 100644 apps/merchant-dashboard/src/components/ui.tsx create mode 100644 apps/merchant-dashboard/src/pages/_app.tsx create mode 100644 apps/merchant-dashboard/src/pages/_document.tsx create mode 100644 apps/merchant-dashboard/src/pages/analytics.tsx create mode 100644 apps/merchant-dashboard/src/pages/analytics/labels.tsx create mode 100644 apps/merchant-dashboard/src/pages/analytics/orders.tsx create mode 100644 apps/merchant-dashboard/src/pages/analytics/refunds.tsx create mode 100644 apps/merchant-dashboard/src/pages/analytics/returns.tsx create mode 100644 apps/merchant-dashboard/src/pages/analytics/webhooks.tsx create mode 100644 apps/merchant-dashboard/src/pages/integrations.tsx create mode 100644 apps/merchant-dashboard/src/pages/notifications.tsx create mode 100644 apps/merchant-dashboard/src/pages/settings.tsx create mode 100644 apps/merchant-dashboard/src/pages/shipments.tsx create mode 100644 apps/merchant-dashboard/src/pages/users.tsx create mode 100644 apps/merchant-dashboard/src/styles/globals.css create mode 100644 apps/merchant-dashboard/tailwind.config.js create mode 100644 apps/returns-portal/src/pages/_app.tsx create mode 100644 apps/test-store/.env.example create mode 100644 apps/test-store/next-env.d.ts create mode 100644 apps/test-store/next.config.js create mode 100644 apps/test-store/package.json create mode 100644 apps/test-store/src/lib/config.ts create mode 100644 apps/test-store/src/pages/_app.tsx create mode 100644 apps/test-store/src/pages/api/_util.ts create mode 100644 apps/test-store/src/pages/api/bulk.ts create mode 100644 apps/test-store/src/pages/api/order.ts create mode 100644 apps/test-store/src/pages/api/orders.ts create mode 100644 apps/test-store/src/pages/checkout.tsx create mode 100644 apps/test-store/src/pages/index.tsx create mode 100644 apps/test-store/src/pages/orders.tsx create mode 100644 apps/test-store/tsconfig.json create mode 100644 apps/warehouse/src/pages/_app.tsx create mode 100644 packages/ui/package.json create mode 100644 packages/ui/src/components/Card.tsx create mode 100644 packages/ui/src/components/Chip.tsx create mode 100644 packages/ui/src/components/Stepper.tsx create mode 100644 packages/ui/src/components/Table.tsx create mode 100644 packages/ui/src/components/Tooltip.tsx create mode 100644 packages/ui/src/index.ts create mode 100644 packages/ui/src/theme/ThemeProvider.tsx create mode 100644 packages/ui/styles/components.css create mode 100644 packages/ui/styles/themes.css create mode 100644 packages/ui/styles/tokens.css create mode 100644 packages/ui/tsconfig.build.json create mode 100644 services/analytics/Dockerfile create mode 100644 services/analytics/jest.config.ts create mode 100644 services/analytics/package.json create mode 100644 services/analytics/src/backfill.controller.ts create mode 100644 services/analytics/src/backfill.service.ts create mode 100644 services/analytics/src/clickhouse.ts create mode 100644 services/analytics/src/ingest.worker.ts create mode 100644 services/analytics/src/main.ts create mode 100644 services/analytics/src/metrics.controller.ts create mode 100644 services/analytics/src/pg.ts create mode 100644 services/analytics/src/spool.ts create mode 100644 services/analytics/test/unit/mapper.spec.ts create mode 100644 services/analytics/tsconfig.build.json create mode 100644 services/analytics/tsconfig.json create mode 100644 services/notifications/Dockerfile create mode 100644 services/notifications/jest.config.ts create mode 100644 services/notifications/package.json create mode 100644 services/notifications/prisma/schema.prisma create mode 100644 services/notifications/src/main.ts create mode 100644 services/notifications/src/notifications.controller.ts create mode 100644 services/notifications/src/preferences.controller.ts create mode 100644 services/notifications/src/prisma.service.ts create mode 100644 services/notifications/src/provider-webhooks.controller.ts create mode 100644 services/notifications/src/providers.ts create mode 100644 services/notifications/src/templates.controller.ts create mode 100644 services/notifications/src/templates.service.ts create mode 100644 services/notifications/test/unit/provider-webhooks.controller.spec.ts create mode 100644 services/notifications/test/unit/templates.render.spec.ts create mode 100644 services/notifications/tsconfig.build.json create mode 100644 services/notifications/tsconfig.json create mode 100644 services/orders/src/carriers.ts create mode 100644 services/orders/src/payments.providers.ts create mode 100644 services/orders/src/provider-webhooks.controller.ts create mode 100644 services/orders/src/rates.controller.ts create mode 100644 services/orders/src/refunds.controller.ts create mode 100644 services/orders/src/refunds.worker.ts create mode 100644 services/orders/test/int/rates.controller.int.spec.ts create mode 100644 services/orders/test/int/refunds.controller.int.spec.ts create mode 100644 services/shopify/Dockerfile create mode 100644 services/shopify/jest.config.ts create mode 100644 services/shopify/package.json create mode 100644 services/shopify/prisma/schema.prisma create mode 100644 services/shopify/src/main.ts create mode 100644 services/shopify/src/metrics.controller.ts create mode 100644 services/shopify/src/prisma.service.ts create mode 100644 services/shopify/src/shopify.controller.ts create mode 100644 services/shopify/test/unit/shopify.controller.spec.ts create mode 100644 services/shopify/tsconfig.build.json create mode 100644 services/shopify/tsconfig.json create mode 100644 services/users/src/api-keys.controller.ts create mode 100644 services/users/src/audit.controller.ts create mode 100644 services/users/src/users.controller.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index c25fd54..c67f486 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -2,14 +2,22 @@ module.exports = { root: true, parser: '@typescript-eslint/parser', plugins: ['@typescript-eslint'], - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'prettier', + extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], + ignorePatterns: [ + 'dist', + 'node_modules', + '**/generated/**', + '**/src/generated/**', + '**/*.d.ts', + '**/test/**', ], - ignorePatterns: ['dist', 'node_modules'], rules: { '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_', ignoreRestSiblings: true }, + ], + 'no-empty': ['error', { allowEmptyCatch: true }], + '@typescript-eslint/no-var-requires': 'off', }, }; - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f2fb3c..f45a4df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: ci on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] jobs: build: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index fc28e2b..69b5d13 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,9 +2,9 @@ name: codeql on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] schedule: - cron: '0 8 * * 1' @@ -19,7 +19,7 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'javascript' ] + language: ['javascript'] steps: - name: Checkout repository uses: actions/checkout@v4 @@ -33,4 +33,3 @@ jobs: uses: github/codeql-action/analyze@v3 with: category: '/language:${{matrix.language}}' - diff --git a/.gitignore b/.gitignore index 6bbd81f..f8c7e96 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ coverage services/*/src/generated/ /.env **/.env +**/.env.local +**/.env.*.local *.log .DS_Store .idea diff --git a/.prettierrc b/.prettierrc index 0c2fde4..e5ce635 100644 --- a/.prettierrc +++ b/.prettierrc @@ -4,4 +4,3 @@ "printWidth": 100, "semi": true } - diff --git a/.vscode/extensions.json b/.vscode/extensions.json index d713168..91dbdfc 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -6,4 +6,3 @@ "GraphQL.vscode-graphql" ] } - diff --git a/apps/api-gateway/jest.config.ts b/apps/api-gateway/jest.config.ts index 7ad2565..fd58773 100644 --- a/apps/api-gateway/jest.config.ts +++ b/apps/api-gateway/jest.config.ts @@ -9,4 +9,3 @@ const config: Config = { }; export default config; - diff --git a/apps/api-gateway/package.json b/apps/api-gateway/package.json index 6c4723c..7c23c16 100644 --- a/apps/api-gateway/package.json +++ b/apps/api-gateway/package.json @@ -11,11 +11,14 @@ "test": "jest --runInBand" }, "dependencies": { + "@clickhouse/client": "^1.12.1", "@dispatch/config": "workspace:*", "@dispatch/logger": "workspace:*", "@dispatch/telemetry": "workspace:*", "@dispatch/messaging": "workspace:*", - "@nestjs/jwt": "^10.2.0", + "pg": "^8.12.0", + "@nestjs/jwt": "^10.2.0", + "ioredis": "^5.4.1", "@nestjs/apollo": "^13.1.0", "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", @@ -34,6 +37,7 @@ "devDependencies": { "@types/express": "^4.17.21", "@types/jest": "^29.5.12", + "@types/pg": "^8.11.10", "jest": "^29.7.0", "ts-jest": "^29.1.1", "ts-node-dev": "^2.0.0" diff --git a/apps/api-gateway/src/main.ts b/apps/api-gateway/src/main.ts index 407b9b6..d8e23ce 100644 --- a/apps/api-gateway/src/main.ts +++ b/apps/api-gateway/src/main.ts @@ -5,6 +5,9 @@ import { Logger } from '@nestjs/common'; import { AppModule } from './modules/app.module'; import { initTelemetry } from '@dispatch/telemetry'; import { loadConfig } from '@dispatch/config'; +import type { Request, Response, NextFunction } from 'express'; +import Redis from 'ioredis'; +import jwt from 'jsonwebtoken'; async function bootstrap() { // Initialize OpenTelemetry (no-op if deps not installed) @@ -23,6 +26,53 @@ async function bootstrap() { maxAge: 3600, }, }); + // Basic Redis fixed-window rate limiter (60 sec) + try { + const redisUrl = process.env.REDIS_URL; + if (redisUrl) { + const redis = new Redis(redisUrl); + const limit = Number(process.env.RATE_LIMIT_PER_MINUTE || 600); + app.use(async (req: Request, res: Response, next: NextFunction) => { + try { + const auth = req.headers['authorization'] as string | undefined; + const apiKeyHdr = req.headers['x-api-key'] as string | undefined; + let tenantId: string | undefined; + if (auth && auth.startsWith('Bearer ')) { + try { + const decoded: any = jwt.verify( + auth.split(' ')[1], + process.env.JWT_SECRET || 'dev-secret', + ); + tenantId = decoded?.tenantId; + } catch (err) { + // Ignore JWT parse/verify errors for rate limiting context + } + } + // If using API key via header, parse prefix from Authorization: ApiKey or X-API-Key + let apiKeyPrefix: string | undefined; + if (auth && auth.startsWith('ApiKey ')) { + apiKeyPrefix = (auth.split(' ')[1] || '').split('.')[0]; + } else if (apiKeyHdr) { + apiKeyPrefix = (apiKeyHdr || '').split('.')[0]; + } + const id = apiKeyPrefix ? `k:${apiKeyPrefix}` : tenantId ? `t:${tenantId}` : 'anon'; + const window = Math.floor(Date.now() / 60000); + const key = `rl:${id}:${window}`; + const current = await redis.incr(key); + if (current === 1) await redis.expire(key, 65); + if (current > limit) { + res.status(429).json({ message: 'Rate limit exceeded' }); + return; + } + } catch (err) { + // Non-fatal rate limiter middleware error + } + next(); + }); + } + } catch (err) { + // Ignore Redis initialization failures; API still functions without rate limiting + } await app.listen(config.PORT); Logger.log(`api-gateway listening on :${config.PORT}`); } diff --git a/apps/api-gateway/src/modules/analytics.controller.ts b/apps/api-gateway/src/modules/analytics.controller.ts new file mode 100644 index 0000000..e848d33 --- /dev/null +++ b/apps/api-gateway/src/modules/analytics.controller.ts @@ -0,0 +1,103 @@ +import { Controller, Get, Query, Res, Headers, UnauthorizedException } from '@nestjs/common'; +import type { Response } from 'express'; +import { createClient } from '@clickhouse/client'; +import jwt from 'jsonwebtoken'; + +function getCH() { + return createClient({ + host: process.env.CLICKHOUSE_URL || 'http://localhost:8123', + username: process.env.CLICKHOUSE_USER || 'default', + password: process.env.CLICKHOUSE_PASSWORD || '', + }); +} + +@Controller('/v1/analytics') +export class AnalyticsController { + private getTenantIdFromAuth(auth?: string): string { + if (!auth) throw new UnauthorizedException('Missing Authorization'); + const [scheme, token] = auth.split(' '); + if ((scheme || '').toLowerCase() !== 'bearer' || !token) + throw new UnauthorizedException('Invalid Authorization'); + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET || 'dev-secret') as any; + const tenantId = decoded?.tenantId; + if (!tenantId) throw new UnauthorizedException('Invalid token'); + return tenantId; + } catch { + throw new UnauthorizedException('Invalid token'); + } + } + + @Get('/returns.csv') + async returnsCsv( + @Query() q: any, + @Headers('authorization') auth: string | undefined, + @Res() res: Response, + ) { + const tenantId = this.getTenantIdFromAuth(auth); + const { from, to, channel, reason } = q; + const ch = getCH(); + const whereParts = [`tenant_id = {tenantId:String}`]; + if (channel) whereParts.push(`channel = {channel:String}`); + if (reason) whereParts.push(`reason = {reason:String}`); + const where = whereParts.join(' AND '); + const rsp = await ch.query({ + query: ` + SELECT d as date, channel, reason, initiated, label_generated, in_transit, delivered, inspected, refunded, + if(approvals>0, sum_approval_ms/approvals, 0) as avg_approval_ms, + if(refunds>0, sum_refund_ms/refunds, 0) as avg_refund_ms + FROM analytics.agg_returns_by_day + WHERE ${where} AND d BETWEEN toDate({from:String}) AND toDate({to:String}) + ORDER BY d FORMAT CSVWithNames`, + query_params: { tenantId, channel, reason, from, to }, + }); + res.setHeader('Content-Type', 'text/csv'); + res.send(await rsp.text()); + } + + @Get('/refunds.csv') + async refundsCsv( + @Query() q: any, + @Headers('authorization') auth: string | undefined, + @Res() res: Response, + ) { + const tenantId = this.getTenantIdFromAuth(auth); + const { from, to } = q; + const ch = getCH(); + const rsp = await ch.query({ + query: ` + SELECT d as date, provider, status, count, amount_cents_sum, if(count>0, sum_latency_ms/count, 0) as avg_latency_ms + FROM analytics.agg_refunds_by_day + WHERE tenant_id = {tenantId:String} AND d BETWEEN toDate({from:String}) AND toDate({to:String}) + ORDER BY d FORMAT CSVWithNames`, + query_params: { tenantId, from, to }, + }); + res.setHeader('Content-Type', 'text/csv'); + res.send(await rsp.text()); + } + + @Get('/labels.csv') + async labelsCsv( + @Query() q: any, + @Headers('authorization') auth: string | undefined, + @Res() res: Response, + ) { + const tenantId = this.getTenantIdFromAuth(auth); + const { from, to, carrier, service } = q; + const whereParts = [`tenant_id = {tenantId:String}`]; + if (carrier) whereParts.push(`carrier = {carrier:String}`); + if (service) whereParts.push(`service = {service:String}`); + const where = whereParts.join(' AND '); + const ch = getCH(); + const rsp = await ch.query({ + query: ` + SELECT d as date, carrier, service, labels, total_cost_cents, if(labels>0, total_cost_cents/labels, 0) as avg_cost_cents + FROM analytics.agg_label_costs_by_day + WHERE ${where} AND d BETWEEN toDate({from:String}) AND toDate({to:String}) + ORDER BY d FORMAT CSVWithNames`, + query_params: { tenantId, from, to, carrier, service }, + }); + res.setHeader('Content-Type', 'text/csv'); + res.send(await rsp.text()); + } +} diff --git a/apps/api-gateway/src/modules/analytics.resolver.ts b/apps/api-gateway/src/modules/analytics.resolver.ts new file mode 100644 index 0000000..6a925ef --- /dev/null +++ b/apps/api-gateway/src/modules/analytics.resolver.ts @@ -0,0 +1,613 @@ +import { + Args, + Field, + InputType, + ObjectType, + Query, + Resolver, + Context, + Float, +} from '@nestjs/graphql'; +import type { AuthContext } from './security/jwt.context'; +import { createClient, ClickHouseClient } from '@clickhouse/client'; +import { pgQuery } from './pg'; + +@ObjectType() +class TimeSeriesPoint { + @Field() + t!: string; + @Field() + v!: number; +} + +@ObjectType() +class KPISet { + @Field() + returnRatePct!: number; + @Field() + returnsCount!: number; + @Field() + refundsAmountCents!: number; + @Field(() => Float, { nullable: true }) + avgApprovalMs?: number | null; + @Field(() => Float, { nullable: true }) + avgRefundMs?: number | null; +} + +@ObjectType() +class BreakdownItem { + @Field() + key!: string; + @Field() + value!: number; +} + +@ObjectType() +class ReturnsOverview { + @Field(() => KPISet) + kpis!: KPISet; + @Field(() => [TimeSeriesPoint]) + returnsByDay!: TimeSeriesPoint[]; + @Field(() => [BreakdownItem]) + reasons!: BreakdownItem[]; + @Field(() => [BreakdownItem]) + channels!: BreakdownItem[]; +} + +@ObjectType() +class RefundsOverview { + @Field(() => [TimeSeriesPoint]) + amountByDay!: TimeSeriesPoint[]; + @Field(() => [TimeSeriesPoint]) + successRateByDay!: TimeSeriesPoint[]; + @Field(() => [BreakdownItem]) + providers!: BreakdownItem[]; +} + +@ObjectType() +class LabelsOverview { + @Field(() => [TimeSeriesPoint]) + costByDay!: TimeSeriesPoint[]; + @Field(() => [BreakdownItem]) + carrierMix!: BreakdownItem[]; +} + +@ObjectType() +class OrdersOverview { + @Field(() => [TimeSeriesPoint]) + ordersByDay!: TimeSeriesPoint[]; + @Field(() => [BreakdownItem]) + channels!: BreakdownItem[]; +} + +@ObjectType() +class WebhooksOverview { + @Field(() => [TimeSeriesPoint]) + successRateByDay!: TimeSeriesPoint[]; + @Field(() => [BreakdownItem]) + deliveriesByStatus!: BreakdownItem[]; +} + +@InputType() +class AnalyticsRange { + @Field() + from!: string; + @Field() + to!: string; + @Field({ defaultValue: 'day' }) + interval?: string; +} + +@InputType() +class AnalyticsFilters { + @Field({ nullable: true }) + channel?: string; + @Field({ nullable: true }) + sku?: string; + @Field({ nullable: true }) + reason?: string; +} + +function getCH(): ClickHouseClient { + return createClient({ + host: process.env.CLICKHOUSE_URL || 'http://localhost:8123', + username: process.env.CLICKHOUSE_USER || 'default', + password: process.env.CLICKHOUSE_PASSWORD || '', + }); +} + +@Resolver() +export class AnalyticsResolver { + @Query(() => ReturnsOverview) + async analyticsReturns( + @Args('range') range: AnalyticsRange, + @Args('filters', { nullable: true }) filters?: AnalyticsFilters, + @Context() ctx?: AuthContext, + ): Promise { + const tenantId = ctx?.user?.tenantId || ''; + const ch = getCH(); + const whereParts = [`tenant_id = {tenantId:String}`]; + if (filters?.channel) whereParts.push(`channel = {channel:String}`); + if (filters?.reason) whereParts.push(`reason = {reason:String}`); + const where = whereParts.join(' AND '); + const params: any = { tenantId, channel: filters?.channel, reason: filters?.reason }; + + let rows: { d: string; returns: number }[] = []; + let orders: { d: string; orders: number }[] = []; + let reasons: BreakdownItem[] = [] as any; + let channels: BreakdownItem[] = [] as any; + try { + rows = await ( + await ch.query({ + query: ` + SELECT d, sum(initiated) AS returns + FROM analytics.agg_returns_by_day + WHERE ${where} AND d BETWEEN toDate({from:String}) AND toDate({to:String}) + GROUP BY d ORDER BY d`, + format: 'JSONEachRow', + query_params: { ...params, from: range.from, to: range.to }, + }) + ).json<{ d: string; returns: number }>(); + + orders = await ( + await ch.query({ + query: ` + SELECT d, sum(orders) AS orders + FROM analytics.agg_orders_by_day + WHERE tenant_id = {tenantId:String} AND d BETWEEN toDate({from:String}) AND toDate({to:String}) + GROUP BY d ORDER BY d`, + format: 'JSONEachRow', + query_params: { tenantId, from: range.from, to: range.to }, + }) + ).json<{ d: string; orders: number }>(); + + reasons = (await ( + await ch.query({ + query: ` + SELECT reason AS key, sum(initiated) AS value + FROM analytics.agg_returns_by_day + WHERE ${where} AND d BETWEEN toDate({from:String}) AND toDate({to:String}) + GROUP BY reason ORDER BY value DESC LIMIT 10`, + format: 'JSONEachRow', + query_params: { ...params, from: range.from, to: range.to }, + }) + ).json()) as any; + + channels = (await ( + await ch.query({ + query: ` + SELECT channel AS key, sum(initiated) AS value + FROM analytics.agg_returns_by_day + WHERE ${where} AND d BETWEEN toDate({from:String}) AND toDate({to:String}) + GROUP BY channel ORDER BY value DESC`, + format: 'JSONEachRow', + query_params: { ...params, from: range.from, to: range.to }, + }) + ).json()) as any; + } catch { + // ignore, will fallback below + } + + // Fallback to Postgres if ClickHouse has no data (or errored) + if (!rows.length && !orders.length) { + const pgFrom = `${range.from}T00:00:00.000Z`; + const pgTo = `${range.to}T23:59:59.999Z`; + const whereChan = filters?.channel ? 'AND o."channel" = $4' : ''; + const paramsOrders = filters?.channel + ? [tenantId, pgFrom, pgTo, filters.channel] + : [tenantId, pgFrom, pgTo]; + const paramsReturns = paramsOrders; + + const ordersSql = ` + SELECT to_char(date_trunc('day', o."createdAt"), 'YYYY-MM-DD') AS d, count(*)::int AS orders + FROM orders."Order" o + WHERE o."tenantId" = $1 AND o."createdAt" BETWEEN $2 AND $3 ${whereChan} + GROUP BY 1 ORDER BY 1`; + const returnsSql = ` + SELECT to_char(date_trunc('day', r."createdAt"), 'YYYY-MM-DD') AS d, count(*)::int AS returns + FROM orders."Return" r + JOIN orders."Order" o ON o."id" = r."orderId" + WHERE r."tenantId" = $1 AND r."createdAt" BETWEEN $2 AND $3 ${whereChan} + GROUP BY 1 ORDER BY 1`; + const reasonsSql = ` + SELECT coalesce(r."reason", '') AS key, count(*)::int AS value + FROM orders."Return" r + JOIN orders."Order" o ON o."id" = r."orderId" + WHERE r."tenantId" = $1 AND r."createdAt" BETWEEN $2 AND $3 ${whereChan} + GROUP BY 1 ORDER BY 2 DESC LIMIT 10`; + const channelsSql = ` + SELECT coalesce(o."channel", '') AS key, count(*)::int AS value + FROM orders."Return" r + JOIN orders."Order" o ON o."id" = r."orderId" + WHERE r."tenantId" = $1 AND r."createdAt" BETWEEN $2 AND $3 ${whereChan} + GROUP BY 1 ORDER BY 2 DESC`; + + orders = await pgQuery<{ d: string; orders: number }>(ordersSql, paramsOrders as any); + rows = await pgQuery<{ d: string; returns: number }>(returnsSql, paramsReturns as any); + reasons = await pgQuery(reasonsSql, paramsReturns as any); + channels = await pgQuery(channelsSql, paramsReturns as any); + } + + const returnsByDay: TimeSeriesPoint[] = rows.map((r) => ({ + t: r.d, + v: Number.isFinite(r.returns) ? r.returns : 0, + })); + const totalReturns = rows.reduce( + (acc, curr) => acc + (Number.isFinite(curr.returns) ? curr.returns : 0), + 0, + ); + const totalOrders = orders.reduce( + (acc, curr) => acc + (Number.isFinite(curr.orders) ? curr.orders : 0), + 0, + ); + const returnRatePct = totalOrders > 0 ? (totalReturns / totalOrders) * 100 : 0; + + // Latencies and refund $ from returns agg and refunds agg + let lat: { + sum_approval_ms: number; + approvals: number; + sum_refund_ms: number; + refunds: number; + }[] = []; + let refunds: { amount: number }[] = []; + try { + lat = await ( + await ch.query({ + query: ` + SELECT sum(sum_approval_ms) AS sum_approval_ms, sum(approvals) AS approvals, sum(sum_refund_ms) AS sum_refund_ms, sum(refunds) AS refunds + FROM analytics.agg_returns_by_day WHERE ${where} AND d BETWEEN toDate({from:String}) AND toDate({to:String})`, + format: 'JSONEachRow', + query_params: { ...params, from: range.from, to: range.to }, + }) + ).json<{ + sum_approval_ms: number; + approvals: number; + sum_refund_ms: number; + refunds: number; + }>(); + + refunds = await ( + await ch.query({ + query: `SELECT sum(amount_cents_sum) AS amount FROM analytics.agg_refunds_by_day WHERE tenant_id = {tenantId:String} AND d BETWEEN toDate({from:String}) AND toDate({to:String})`, + format: 'JSONEachRow', + query_params: { tenantId, from: range.from, to: range.to }, + }) + ).json<{ amount: number }>(); + } catch { + // Fallback to PG for latencies and refunds amount + const pgFrom = `${range.from}T00:00:00.000Z`; + const pgTo = `${range.to}T23:59:59.999Z`; + const whereChan = filters?.channel ? 'AND o."channel" = $4' : ''; + const params = filters?.channel + ? [tenantId, pgFrom, pgTo, filters.channel] + : [tenantId, pgFrom, pgTo]; + const approvalSql = ` + SELECT coalesce(sum(EXTRACT(EPOCH FROM (l."createdAt" - r."createdAt"))*1000)::bigint,0) as sum_approval_ms, + count(l."id")::int as approvals, + 0::bigint as sum_refund_ms, + 0::int as refunds + FROM orders."Return" r + JOIN orders."Order" o ON o."id" = r."orderId" + JOIN orders."Label" l ON l."returnId" = r."id" + WHERE r."tenantId" = $1 AND r."createdAt" BETWEEN $2 AND $3 ${whereChan}`; + const refundLatSql = ` + SELECT coalesce(sum(EXTRACT(EPOCH FROM (f."updatedAt" - r."createdAt"))*1000)::bigint,0) as sum_refund_ms, + count(f."id")::int as refunds + FROM orders."Refund" f + JOIN orders."Return" r ON r."id" = f."returnId" + JOIN orders."Order" o ON o."id" = r."orderId" + WHERE f."tenantId" = $1 AND f."status" = 'succeeded' AND f."updatedAt" BETWEEN $2 AND $3 ${whereChan}`; + const amtSql = ` + SELECT coalesce(sum(f."amountCents")::bigint,0) as amount + FROM orders."Refund" f + WHERE f."tenantId" = $1 AND f."updatedAt" BETWEEN $2 AND $3`; + const a = await pgQuery(approvalSql, params as any); + const rlat = await pgQuery(refundLatSql, params as any); + const am = await pgQuery(amtSql, [tenantId, pgFrom, pgTo] as any); + lat = [ + { + sum_approval_ms: Number(a[0]?.sum_approval_ms || 0), + approvals: Number(a[0]?.approvals || 0), + sum_refund_ms: Number(rlat[0]?.sum_refund_ms || 0), + refunds: Number(rlat[0]?.refunds || 0), + }, + ]; + refunds = [{ amount: Number(am[0]?.amount || 0) }]; + } + + const avgApprovalMsCalc = lat[0]?.approvals + ? (lat[0].sum_approval_ms as any) / (lat[0].approvals as any) + : null; + const avgRefundMsCalc = lat[0]?.refunds + ? (lat[0].sum_refund_ms as any) / (lat[0].refunds as any) + : null; + const avgApprovalMs = Number.isFinite(avgApprovalMsCalc as any) + ? (avgApprovalMsCalc as number) + : null; + const avgRefundMs = Number.isFinite(avgRefundMsCalc as any) + ? (avgRefundMsCalc as number) + : null; + const refundsAmountCents = refunds[0]?.amount || 0; + + const safeReasons: BreakdownItem[] = (reasons as any[]).map((r) => ({ + key: (r.key ?? '') as string, + value: Number.isFinite(r.value) ? (r.value as number) : 0, + })); + const safeChannels: BreakdownItem[] = (channels as any[]).map((r) => ({ + key: (r.key ?? '') as string, + value: Number.isFinite(r.value) ? (r.value as number) : 0, + })); + + return { + kpis: { + returnRatePct: Number.isFinite(returnRatePct) ? returnRatePct : 0, + returnsCount: Number.isFinite(totalReturns) ? totalReturns : 0, + refundsAmountCents: Number.isFinite(refundsAmountCents) ? refundsAmountCents : 0, + avgApprovalMs: avgApprovalMs ?? null, + avgRefundMs: avgRefundMs ?? null, + }, + returnsByDay, + reasons: safeReasons, + channels: safeChannels, + }; + } + + @Query(() => OrdersOverview) + async analyticsOrders( + @Args('range') range: AnalyticsRange, + @Args('filters', { nullable: true }) filters?: AnalyticsFilters, + @Context() ctx?: AuthContext, + ): Promise { + const tenantId = ctx?.user?.tenantId || ''; + const ch = getCH(); + const whereParts = [`tenant_id = {tenantId:String}`]; + if (filters?.channel) whereParts.push(`channel = {channel:String}`); + const where = whereParts.join(' AND '); + const params: any = { tenantId, channel: filters?.channel }; + + let byDay: { d: string; orders: number }[] = []; + let byChannel: BreakdownItem[] = [] as any; + try { + byDay = await ( + await ch.query({ + query: ` + SELECT d, sum(orders) AS orders + FROM analytics.agg_orders_by_day + WHERE ${where} AND d BETWEEN toDate({from:String}) AND toDate({to:String}) + GROUP BY d ORDER BY d`, + format: 'JSONEachRow', + query_params: { ...params, from: range.from, to: range.to }, + }) + ).json<{ d: string; orders: number }>(); + + byChannel = (await ( + await ch.query({ + query: ` + SELECT channel AS key, sum(orders) AS value + FROM analytics.agg_orders_by_day + WHERE ${where} AND d BETWEEN toDate({from:String}) AND toDate({to:String}) + GROUP BY channel ORDER BY value DESC`, + format: 'JSONEachRow', + query_params: { ...params, from: range.from, to: range.to }, + }) + ).json()) as any; + } catch { + // ignore errors and fallback + } + + if (!byDay.length) { + const pgFrom = `${range.from}T00:00:00.000Z`; + const pgTo = `${range.to}T23:59:59.999Z`; + const whereChan = filters?.channel ? 'AND o."channel" = $4' : ''; + const paramsList = filters?.channel + ? [tenantId, pgFrom, pgTo, filters.channel] + : [tenantId, pgFrom, pgTo]; + const byDaySql = ` + SELECT to_char(date_trunc('day', o."createdAt"), 'YYYY-MM-DD') AS d, count(*)::int AS orders + FROM orders."Order" o + WHERE o."tenantId" = $1 AND o."createdAt" BETWEEN $2 AND $3 ${whereChan} + GROUP BY 1 ORDER BY 1`; + const byChanSql = ` + SELECT coalesce(o."channel", '') AS key, count(*)::int AS value + FROM orders."Order" o + WHERE o."tenantId" = $1 AND o."createdAt" BETWEEN $2 AND $3 ${whereChan} + GROUP BY 1 ORDER BY 2 DESC`; + byDay = await pgQuery<{ d: string; orders: number }>(byDaySql, paramsList as any); + byChannel = await pgQuery(byChanSql, paramsList as any); + } + + const ordersByDay: TimeSeriesPoint[] = byDay.map((r) => ({ + t: r.d, + v: Number.isFinite(r.orders) ? r.orders : 0, + })); + const channels: BreakdownItem[] = (byChannel as any[]).map((r) => ({ + key: (r.key ?? '') as string, + value: Number.isFinite(r.value) ? (r.value as number) : 0, + })); + + return { ordersByDay, channels }; + } + + @Query(() => RefundsOverview) + async analyticsRefunds( + @Args('range') range: AnalyticsRange, + @Context() ctx?: AuthContext, + ): Promise { + const tenantId = ctx?.user?.tenantId || ''; + const ch = getCH(); + let amountRows: { d: string; amt: number }[] = []; + let succRows: { d: string; succ: number; total: number }[] = []; + let providers: BreakdownItem[] = [] as any; + try { + amountRows = await ( + await ch.query({ + query: `SELECT d, sum(amount_cents_sum) AS amt FROM analytics.agg_refunds_by_day WHERE tenant_id = {tenantId:String} AND d BETWEEN toDate({from:String}) AND toDate({to:String}) GROUP BY d ORDER BY d`, + format: 'JSONEachRow', + query_params: { tenantId, from: range.from, to: range.to }, + }) + ).json<{ d: string; amt: number }>(); + succRows = await ( + await ch.query({ + query: `SELECT d, sumIf(count, status='succeeded') AS succ, sum(count) AS total FROM analytics.agg_refunds_by_day WHERE tenant_id = {tenantId:String} AND d BETWEEN toDate({from:String}) AND toDate({to:String}) GROUP BY d ORDER BY d`, + format: 'JSONEachRow', + query_params: { tenantId, from: range.from, to: range.to }, + }) + ).json<{ d: string; succ: number; total: number }>(); + providers = (await ( + await ch.query({ + query: `SELECT provider AS key, sum(count) AS value FROM analytics.agg_refunds_by_day WHERE tenant_id = {tenantId:String} AND d BETWEEN toDate({from:String}) AND toDate({to:String}) GROUP BY provider ORDER BY value DESC`, + format: 'JSONEachRow', + query_params: { tenantId, from: range.from, to: range.to }, + }) + ).json()) as any; + } catch { + const pgFrom = `${range.from}T00:00:00.000Z`; + const pgTo = `${range.to}T23:59:59.999Z`; + const amountSql = ` + SELECT to_char(date_trunc('day', f."updatedAt"), 'YYYY-MM-DD') AS d, coalesce(sum(f."amountCents")::bigint,0) AS amt + FROM orders."Refund" f + WHERE f."tenantId" = $1 AND f."updatedAt" BETWEEN $2 AND $3 + GROUP BY 1 ORDER BY 1`; + const succSql = ` + SELECT to_char(date_trunc('day', f."updatedAt"), 'YYYY-MM-DD') AS d, + count(*) FILTER (WHERE f."status" = 'succeeded')::int AS succ, + count(*)::int AS total + FROM orders."Refund" f + WHERE f."tenantId" = $1 AND f."updatedAt" BETWEEN $2 AND $3 + GROUP BY 1 ORDER BY 1`; + const provSql = ` + SELECT coalesce(f."provider", '') AS key, count(*)::int AS value + FROM orders."Refund" f + WHERE f."tenantId" = $1 AND f."updatedAt" BETWEEN $2 AND $3 + GROUP BY 1 ORDER BY 2 DESC`; + amountRows = await pgQuery(amountSql, [tenantId, pgFrom, pgTo] as any); + succRows = await pgQuery(succSql, [tenantId, pgFrom, pgTo] as any); + providers = await pgQuery(provSql, [tenantId, pgFrom, pgTo] as any); + } + const amountByDay = amountRows.map((r) => ({ t: r.d, v: Number.isFinite(r.amt) ? r.amt : 0 })); + const successRateByDay = succRows.map((r) => { + const delivered = Number.isFinite(r.succ) ? r.succ : 0; + const total = Number.isFinite(r.total) ? r.total : 0; + const v = total > 0 ? (delivered / total) * 100 : 0; + return { t: r.d, v: Number.isFinite(v) ? v : 0 }; + }); + const safeProviders: BreakdownItem[] = (providers as any[]).map((p) => ({ + key: (p.key ?? '') as string, + value: Number.isFinite(p.value) ? (p.value as number) : 0, + })); + return { + amountByDay, + successRateByDay, + providers: safeProviders, + }; + } + + @Query(() => LabelsOverview) + async analyticsLabels( + @Args('range') range: AnalyticsRange, + @Context() ctx?: AuthContext, + ): Promise { + const tenantId = ctx?.user?.tenantId || ''; + const ch = getCH(); + let costRows: { d: string; cost: number }[] = []; + let mix: BreakdownItem[] = [] as any; + try { + costRows = await ( + await ch.query({ + query: `SELECT d, sum(total_cost_cents) AS cost FROM analytics.agg_label_costs_by_day WHERE tenant_id = {tenantId:String} AND d BETWEEN toDate({from:String}) AND toDate({to:String}) GROUP BY d ORDER BY d`, + format: 'JSONEachRow', + query_params: { tenantId, from: range.from, to: range.to }, + }) + ).json<{ d: string; cost: number }>(); + mix = (await ( + await ch.query({ + query: `SELECT concat(carrier, ' ', service) AS key, sum(labels) AS value FROM analytics.agg_label_costs_by_day WHERE tenant_id = {tenantId:String} AND d BETWEEN toDate({from:String}) AND toDate({to:String}) GROUP BY carrier, service ORDER BY value DESC`, + format: 'JSONEachRow', + query_params: { tenantId, from: range.from, to: range.to }, + }) + ).json()) as any; + } catch { + const pgFrom = `${range.from}T00:00:00.000Z`; + const pgTo = `${range.to}T23:59:59.999Z`; + const costSql = ` + SELECT to_char(date_trunc('day', l."createdAt"), 'YYYY-MM-DD') AS d, coalesce(sum(l."costCents")::bigint,0) AS cost + FROM orders."Label" l + WHERE l."tenantId" = $1 AND l."createdAt" BETWEEN $2 AND $3 + GROUP BY 1 ORDER BY 1`; + const mixSql = ` + SELECT (l."carrier" || ' ' || l."service") AS key, count(*)::int AS value + FROM orders."Label" l + WHERE l."tenantId" = $1 AND l."createdAt" BETWEEN $2 AND $3 + GROUP BY 1 ORDER BY 2 DESC`; + costRows = await pgQuery(costSql, [tenantId, pgFrom, pgTo] as any); + mix = await pgQuery(mixSql, [tenantId, pgFrom, pgTo] as any); + } + const costByDay = costRows.map((r) => ({ t: r.d, v: Number.isFinite(r.cost) ? r.cost : 0 })); + const carrierMix: BreakdownItem[] = (mix as any[]).map((m) => ({ + key: (m.key ?? '') as string, + value: Number.isFinite(m.value) ? (m.value as number) : 0, + })); + return { + costByDay, + carrierMix, + }; + } + + @Query(() => WebhooksOverview) + async analyticsWebhooks( + @Args('range') range: AnalyticsRange, + @Context() ctx?: AuthContext, + ): Promise { + const tenantId = ctx?.user?.tenantId || ''; + const ch = getCH(); + let succ: { d: string; delivered: number; total: number }[] = []; + let breakdown: BreakdownItem[] = [] as any; + try { + succ = await ( + await ch.query({ + query: `SELECT d, sumIf(deliveries, status='delivered') AS delivered, sum(deliveries) AS total FROM analytics.agg_webhooks_by_day WHERE tenant_id = {tenantId:String} AND d BETWEEN toDate({from:String}) AND toDate({to:String}) GROUP BY d ORDER BY d`, + format: 'JSONEachRow', + query_params: { tenantId, from: range.from, to: range.to }, + }) + ).json<{ d: string; delivered: number; total: number }>(); + breakdown = (await ( + await ch.query({ + query: `SELECT status AS key, sum(deliveries) AS value FROM analytics.agg_webhooks_by_day WHERE tenant_id = {tenantId:String} AND d BETWEEN toDate({from:String}) AND toDate({to:String}) GROUP BY status ORDER BY value DESC`, + format: 'JSONEachRow', + query_params: { tenantId, from: range.from, to: range.to }, + }) + ).json()) as any; + } catch { + const pgFrom = `${range.from}T00:00:00.000Z`; + const pgTo = `${range.to}T23:59:59.999Z`; + const succSql = ` + SELECT to_char(date_trunc('day', d."updatedAt"), 'YYYY-MM-DD') AS d, + count(*) FILTER (WHERE d."status" = 'delivered')::int AS delivered, + count(*)::int AS total + FROM webhooks."Delivery" d + WHERE d."tenantId" = $1 AND d."updatedAt" BETWEEN $2 AND $3 + GROUP BY 1 ORDER BY 1`; + const brSql = ` + SELECT coalesce(d."status", '') AS key, count(*)::int AS value + FROM webhooks."Delivery" d + WHERE d."tenantId" = $1 AND d."updatedAt" BETWEEN $2 AND $3 + GROUP BY 1 ORDER BY 2 DESC`; + succ = await pgQuery(succSql, [tenantId, pgFrom, pgTo] as any); + breakdown = await pgQuery(brSql, [tenantId, pgFrom, pgTo] as any); + } + const successRateByDay = succ.map((r) => { + const delivered = Number.isFinite(r.delivered) ? r.delivered : 0; + const total = Number.isFinite(r.total) ? r.total : 0; + const v = total > 0 ? (delivered / total) * 100 : 0; + return { t: r.d, v: Number.isFinite(v) ? v : 0 }; + }); + const deliveriesByStatus: BreakdownItem[] = (breakdown as any[]).map((b) => ({ + key: (b.key ?? '') as string, + value: Number.isFinite(b.value) ? (b.value as number) : 0, + })); + return { + successRateByDay, + deliveriesByStatus, + }; + } +} diff --git a/apps/api-gateway/src/modules/app.module.ts b/apps/api-gateway/src/modules/app.module.ts index fa18fdc..543cbff 100644 --- a/apps/api-gateway/src/modules/app.module.ts +++ b/apps/api-gateway/src/modules/app.module.ts @@ -7,13 +7,21 @@ import { OrdersResolver } from './orders.resolver'; import { ReturnsResolver } from './returns.resolver'; import { WebhooksResolver } from './webhooks.resolver'; import { LabelsResolver } from './labels.resolver'; +import { RatesResolver } from './rates.resolver'; +import { RefundsResolver } from './refunds.resolver'; import { APP_GUARD } from '@nestjs/core'; import { GqlModuleOptions } from '@nestjs/graphql'; import { JwtAuthGuard } from './security/jwt.guard'; import { getContext } from './security/jwt.context'; +import { RolesGuard } from './security/roles.guard'; import { SubscriptionsResolver } from './subscriptions.resolver'; +import { NotificationsResolver } from './notifications.resolver'; +import { UsersAdminResolver } from './users-admin.resolver'; +import { UsersResolver } from './users.resolver'; +import { AnalyticsResolver } from './analytics.resolver'; import { SubscriptionsService } from './subscriptions.service'; import { subscriptionClientsGauge, MetricsController } from './metrics.controller'; +import { AnalyticsController } from './analytics.controller'; @Module({ imports: [ @@ -26,10 +34,18 @@ import { subscriptionClientsGauge, MetricsController } from './metrics.controlle 'graphql-ws': { path: '/graphql', onConnect: async (_ctx: any) => { - try { subscriptionClientsGauge.inc(); } catch {} + try { + subscriptionClientsGauge.inc(); + } catch (err) { + // Ignore metrics failures + } }, onDisconnect: async (_ctx: any, _code: any) => { - try { subscriptionClientsGauge.dec(); } catch {} + try { + subscriptionClientsGauge.dec(); + } catch (err) { + // Ignore metrics failures + } }, } as any, }, @@ -42,10 +58,17 @@ import { subscriptionClientsGauge, MetricsController } from './metrics.controlle ReturnsResolver, WebhooksResolver, LabelsResolver, + RatesResolver, + RefundsResolver, SubscriptionsResolver, + NotificationsResolver, + UsersAdminResolver, + UsersResolver, + AnalyticsResolver, SubscriptionsService, { provide: APP_GUARD, useClass: JwtAuthGuard }, + { provide: APP_GUARD, useClass: RolesGuard }, ], - controllers: [MetricsController], + controllers: [MetricsController, AnalyticsController], }) export class AppModule {} diff --git a/apps/api-gateway/src/modules/app.resolver.ts b/apps/api-gateway/src/modules/app.resolver.ts index 7407769..6091637 100644 --- a/apps/api-gateway/src/modules/app.resolver.ts +++ b/apps/api-gateway/src/modules/app.resolver.ts @@ -1,4 +1,7 @@ -import { Context, Field, ObjectType, Query, Resolver } from '@nestjs/graphql'; +import { Args, Context, Field, ObjectType, Query, Resolver, Mutation } from '@nestjs/graphql'; +import { Roles } from './security/roles.decorator'; + +const tenantThemes = new Map(); import type { AuthContext } from './security/jwt.context'; @ObjectType() @@ -7,6 +10,10 @@ export class WhoAmI { userId?: string | null; @Field(() => String, { nullable: true }) tenantId?: string | null; + @Field(() => String, { nullable: true }) + role?: string | null; + @Field(() => String, { nullable: true }) + theme?: string | null; } @Resolver() @@ -18,6 +25,22 @@ export class AppResolver { @Query(() => WhoAmI, { description: 'Returns the authenticated user info if available' }) whoami(@Context() ctx: AuthContext): WhoAmI { - return { userId: ctx?.user?.userId ?? null, tenantId: ctx?.user?.tenantId ?? null }; + const tid = ctx?.user?.tenantId ?? null; + const theme = tid ? tenantThemes.get(tid) || null : null; + return { + userId: ctx?.user?.userId ?? null, + tenantId: tid, + role: ctx?.user?.role ?? null, + theme, + }; + } + + @Mutation(() => Boolean, { description: 'Set tenant theme (brand), admin only' }) + @Roles('owner', 'admin') + setTenantTheme(@Args('theme') theme: string, @Context() ctx: AuthContext): boolean { + const tid = ctx?.user?.tenantId; + if (!tid) return false; + tenantThemes.set(tid, theme); + return true; } } diff --git a/apps/api-gateway/src/modules/auth.resolver.ts b/apps/api-gateway/src/modules/auth.resolver.ts index c08543f..6cfa91f 100644 --- a/apps/api-gateway/src/modules/auth.resolver.ts +++ b/apps/api-gateway/src/modules/auth.resolver.ts @@ -35,14 +35,18 @@ export class AuthResolver { @Mutation(() => AuthResult) async signup(@Args('input') input: SignupInput): Promise { - const res = await axios.post(`${this.usersBase}/v1/auth/signup`, input, { validateStatus: () => true }); + const res = await axios.post(`${this.usersBase}/v1/auth/signup`, input, { + validateStatus: () => true, + }); if (res.status >= 400) throw mapHttpToGqlError('SIGNUP_FAILED', res.status, res.data); return res.data; } @Mutation(() => AuthResult) async login(@Args('input') input: LoginInput): Promise { - const res = await axios.post(`${this.usersBase}/v1/auth/login`, input, { validateStatus: () => true }); + const res = await axios.post(`${this.usersBase}/v1/auth/login`, input, { + validateStatus: () => true, + }); if (res.status >= 400) throw mapHttpToGqlError('LOGIN_FAILED', res.status, res.data); return res.data; } diff --git a/apps/api-gateway/src/modules/labels.resolver.ts b/apps/api-gateway/src/modules/labels.resolver.ts index b1dccee..e5e8058 100644 --- a/apps/api-gateway/src/modules/labels.resolver.ts +++ b/apps/api-gateway/src/modules/labels.resolver.ts @@ -50,7 +50,9 @@ export class LabelsResolver { @Query(() => LabelGql, { nullable: true }) async returnLabel(@Args('id') id: string): Promise { - const res = await axios.get(`${this.ordersBase}/v1/returns/${id}/label`, { validateStatus: () => true }); + const res = await axios.get(`${this.ordersBase}/v1/returns/${id}/label`, { + validateStatus: () => true, + }); if (res.status === 404) return null; if (res.status >= 400) throw mapHttpToGqlError('LABEL_FETCH_FAILED', res.status, res.data); const l = res.data; @@ -62,4 +64,3 @@ export class LabelsResolver { return this.returnLabel(returnId); } } - diff --git a/apps/api-gateway/src/modules/metrics.controller.ts b/apps/api-gateway/src/modules/metrics.controller.ts index 1a346d6..7496531 100644 --- a/apps/api-gateway/src/modules/metrics.controller.ts +++ b/apps/api-gateway/src/modules/metrics.controller.ts @@ -1,6 +1,6 @@ import { Controller, Get, Res } from '@nestjs/common'; import type { Response } from 'express'; -import { Counter, Gauge, Registry, collectDefaultMetrics } from 'prom-client'; +import { Gauge, Registry, collectDefaultMetrics } from 'prom-client'; export const registry = new Registry(); if (process.env.NODE_ENV !== 'test') { @@ -21,4 +21,3 @@ export class MetricsController { return res.send(await registry.metrics()); } } - diff --git a/apps/api-gateway/src/modules/notifications.resolver.ts b/apps/api-gateway/src/modules/notifications.resolver.ts new file mode 100644 index 0000000..ed30e60 --- /dev/null +++ b/apps/api-gateway/src/modules/notifications.resolver.ts @@ -0,0 +1,155 @@ +import { Args, Field, ObjectType, Query, Resolver, Mutation, Context } from '@nestjs/graphql'; +import { Roles } from './security/roles.decorator'; +import axios from 'axios'; +import type { AuthContext } from './security/jwt.context'; + +@ObjectType() +class TemplateGql { + @Field() + id!: string; + @Field() + tenantId!: string; + @Field() + name!: string; + @Field() + event!: string; + @Field() + channel!: string; + @Field({ nullable: true }) + subject?: string; + @Field() + body!: string; +} + +@ObjectType() +class TemplatesListGql { + @Field(() => [TemplateGql]) + items!: TemplateGql[]; +} + +@ObjectType() +class PreferenceGql { + @Field() + id!: string; + @Field() + tenantId!: string; + @Field({ nullable: true }) + userId?: string; + @Field() + event!: string; + @Field() + emailEnabled!: boolean; + @Field() + smsEnabled!: boolean; + @Field({ nullable: true }) + emailTemplateId?: string; + @Field({ nullable: true }) + smsTemplateId?: string; +} + +@Resolver() +export class NotificationsResolver { + private base = process.env.NOTIFICATIONS_URL || 'http://127.0.0.1:4006'; + + @Query(() => TemplatesListGql) + async notificationTemplates( + @Args('event', { type: () => String, nullable: true }) event: string | undefined, + @Args('channel', { type: () => String, nullable: true }) channel: string | undefined, + @Context() ctx: AuthContext, + ): Promise { + const auth = ctx?.req?.headers?.authorization as string | undefined; + const tenantId = ctx?.user?.tenantId; + const res = await axios.get(`${this.base}/v1/templates`, { + params: { tenantId, event, channel }, + headers: auth ? { Authorization: auth } : undefined, + validateStatus: () => true, + }); + if (res.status >= 400) throw new Error(res.data?.message || 'Failed to fetch templates'); + return res.data as TemplatesListGql; + } + + @Mutation(() => TemplateGql) + @Roles('owner', 'admin') + async createNotificationTemplate( + @Args('tenantId', { type: () => String }) tenantId: string, + @Args('name', { type: () => String }) name: string, + @Args('event', { type: () => String }) event: string, + @Args('channel', { type: () => String }) channel: string, + @Args('body', { type: () => String }) body: string, + @Args('subject', { type: () => String, nullable: true }) subject?: string, + @Context() ctx?: AuthContext, + ): Promise { + const auth = ctx?.req?.headers?.authorization as string | undefined; + const res = await axios.post( + `${this.base}/v1/templates`, + { tenantId, name, event, channel, body, subject }, + { headers: auth ? { Authorization: auth } : undefined, validateStatus: () => true }, + ); + if (res.status >= 400) throw new Error(res.data?.message || 'Failed to create template'); + try { + await axios.post( + `${process.env.USERS_URL || 'http://127.0.0.1:4001'}/v1/audit`, + { + action: 'template.create', + resource: 'notification_template', + resourceId: (res.data as any).id, + }, + { + headers: ctx?.req?.headers?.authorization + ? { Authorization: ctx!.req!.headers!.authorization } + : undefined, + }, + ); + } catch (err) { + // Audit logging is best-effort; ignore failures + } + return res.data as TemplateGql; + } + + @Mutation(() => PreferenceGql) + @Roles('owner', 'admin') + async upsertNotificationPreference( + @Args('tenantId', { type: () => String }) tenantId: string, + @Args('event', { type: () => String }) event: string, + @Args('userId', { type: () => String, nullable: true }) userId?: string, + @Args('emailEnabled', { type: () => Boolean, nullable: true }) emailEnabled?: boolean, + @Args('smsEnabled', { type: () => Boolean, nullable: true }) smsEnabled?: boolean, + @Args('emailTemplateId', { type: () => String, nullable: true }) emailTemplateId?: string, + @Args('smsTemplateId', { type: () => String, nullable: true }) smsTemplateId?: string, + @Context() ctx?: AuthContext, + ): Promise { + const auth = ctx?.req?.headers?.authorization as string | undefined; + const res = await axios.post( + `${this.base}/v1/preferences`, + { + tenantId, + event, + userId: userId || null, + emailEnabled, + smsEnabled, + emailTemplateId, + smsTemplateId, + }, + { headers: auth ? { Authorization: auth } : undefined, validateStatus: () => true }, + ); + if (res.status >= 400) throw new Error(res.data?.message || 'Failed to upsert preference'); + try { + await axios.post( + `${process.env.USERS_URL || 'http://127.0.0.1:4001'}/v1/audit`, + { + action: 'preference.upsert', + resource: 'notification_preference', + resourceId: (res.data as any).id, + }, + { + headers: ctx?.req?.headers?.authorization + ? { Authorization: ctx!.req!.headers!.authorization } + : undefined, + }, + ); + } catch (err) { + // Audit logging is best-effort; ignore failures + } + return res.data as PreferenceGql; + } +} diff --git a/apps/api-gateway/src/modules/orders.resolver.ts b/apps/api-gateway/src/modules/orders.resolver.ts index 9936775..2496d8a 100644 --- a/apps/api-gateway/src/modules/orders.resolver.ts +++ b/apps/api-gateway/src/modules/orders.resolver.ts @@ -58,7 +58,10 @@ export class OrdersResolver { }); if (res.status >= 400) throw mapHttpToGqlError('ORDERS_FETCH_FAILED', res.status, res.data); // Normalize createdAt to ISO string - const items = (res.data.items || []).map((o: any) => ({ ...o, createdAt: new Date(o.createdAt).toISOString() })); + const items = (res.data.items || []).map((o: any) => ({ + ...o, + createdAt: new Date(o.createdAt).toISOString(), + })); return { ...res.data, items }; } } diff --git a/apps/api-gateway/src/modules/pg.ts b/apps/api-gateway/src/modules/pg.ts new file mode 100644 index 0000000..7cd59dc --- /dev/null +++ b/apps/api-gateway/src/modules/pg.ts @@ -0,0 +1,18 @@ +import { Pool } from 'pg'; + +let pool: Pool | null = null; + +export function getPg(): Pool { + if (!pool) { + const url = + process.env.DATABASE_URL || 'postgresql://dispatch:dispatch@localhost:5432/dispatch'; + pool = new Pool({ connectionString: url }); + } + return pool; +} + +export async function pgQuery(sql: string, params?: any[]): Promise { + const p = getPg(); + const res = await p.query(sql, params); + return res.rows as unknown as T[]; +} diff --git a/apps/api-gateway/src/modules/rates.resolver.ts b/apps/api-gateway/src/modules/rates.resolver.ts new file mode 100644 index 0000000..f40bf5a --- /dev/null +++ b/apps/api-gateway/src/modules/rates.resolver.ts @@ -0,0 +1,37 @@ +import { Args, Field, ObjectType, Query, Resolver } from '@nestjs/graphql'; +import axios from 'axios'; + +@ObjectType() +class RateQuoteGql { + @Field() + carrier!: string; + @Field() + service!: string; + @Field() + costCents!: number; + @Field() + currency!: string; + @Field({ nullable: true }) + etaDays?: number; +} + +@Resolver() +export class RatesResolver { + private ordersBase = process.env.ORDERS_URL || 'http://127.0.0.1:4002'; + + @Query(() => [RateQuoteGql]) + async returnRates(@Args('returnId') returnId: string): Promise { + const res = await axios.get(`${this.ordersBase}/v1/returns/${returnId}/rates`, { + validateStatus: () => true, + }); + if (res.status >= 400) throw mapHttpToGqlError('RATES_FETCH_FAILED', res.status, res.data); + return (res.data?.items || []) as RateQuoteGql[]; + } +} + +function mapHttpToGqlError(code: string, status: number, data: any): Error { + const message = data?.message || data?.error || 'Request failed'; + const err = new Error(message); + (err as any).extensions = { code, httpStatus: status, details: data }; + return err; +} diff --git a/apps/api-gateway/src/modules/refunds.resolver.ts b/apps/api-gateway/src/modules/refunds.resolver.ts new file mode 100644 index 0000000..f0dd8e9 --- /dev/null +++ b/apps/api-gateway/src/modules/refunds.resolver.ts @@ -0,0 +1,72 @@ +import { Args, Field, Mutation, ObjectType, Query, Resolver } from '@nestjs/graphql'; +import axios from 'axios'; + +@ObjectType() +class RefundGql { + @Field() + id!: string; + @Field() + returnId!: string; + @Field() + provider!: string; + @Field() + amountCents!: number; + @Field() + currency!: string; + @Field() + status!: string; + @Field({ nullable: true }) + externalRefundId?: string; + @Field() + createdAt!: string; + @Field() + updatedAt!: string; +} + +@Resolver() +export class RefundsResolver { + private ordersBase = process.env.ORDERS_URL || 'http://127.0.0.1:4002'; + + @Mutation(() => RefundGql) + async refundReturn( + @Args('returnId') returnId: string, + @Args('amountCents', { nullable: true }) amountCents?: number, + @Args('reason', { nullable: true }) reason?: string, + @Args('provider', { nullable: true }) provider?: string, + ): Promise { + const res = await axios.post( + `${this.ordersBase}/v1/returns/${returnId}/refund`, + { amountCents, reason, provider }, + { validateStatus: () => true }, + ); + if (res.status >= 400) throw mapHttpToGqlError('REFUND_CREATE_FAILED', res.status, res.data); + const r = res.data; + return { + ...r, + createdAt: new Date(r.createdAt).toISOString(), + updatedAt: new Date(r.updatedAt).toISOString(), + }; + } + + @Query(() => RefundGql, { nullable: true }) + async refundByReturn(@Args('returnId') returnId: string): Promise { + const res = await axios.get(`${this.ordersBase}/v1/returns/${returnId}/refund`, { + validateStatus: () => true, + }); + if (res.status === 404) return null; + if (res.status >= 400) throw mapHttpToGqlError('REFUND_FETCH_FAILED', res.status, res.data); + const r = res.data; + return { + ...r, + createdAt: new Date(r.createdAt).toISOString(), + updatedAt: new Date(r.updatedAt).toISOString(), + }; + } +} + +function mapHttpToGqlError(code: string, status: number, data: any): Error { + const message = data?.message || data?.error || 'Request failed'; + const err = new Error(message); + (err as any).extensions = { code, httpStatus: status, details: data }; + return err; +} diff --git a/apps/api-gateway/src/modules/returns.resolver.ts b/apps/api-gateway/src/modules/returns.resolver.ts index 371fd28..2ab53ff 100644 --- a/apps/api-gateway/src/modules/returns.resolver.ts +++ b/apps/api-gateway/src/modules/returns.resolver.ts @@ -21,7 +21,9 @@ export class ReturnsResolver { @Query(() => ReturnGql, { nullable: true }) async returnById(@Args('id') id: string): Promise { - const res = await axios.get(`${this.ordersBase}/v1/returns/${id}`, { validateStatus: () => true }); + const res = await axios.get(`${this.ordersBase}/v1/returns/${id}`, { + validateStatus: () => true, + }); if (res.status === 404) return null; if (res.status >= 400) throw mapHttpToGqlError('RETURN_FETCH_FAILED', res.status, res.data); const r = res.data; diff --git a/apps/api-gateway/src/modules/security/jwt.context.ts b/apps/api-gateway/src/modules/security/jwt.context.ts index 803bdcb..ccce172 100644 --- a/apps/api-gateway/src/modules/security/jwt.context.ts +++ b/apps/api-gateway/src/modules/security/jwt.context.ts @@ -1,10 +1,9 @@ -import type { GqlContextType } from '@nestjs/graphql'; import jwt from 'jsonwebtoken'; export type AuthContext = { req: any; res: any; - user?: { userId: string; tenantId: string; email?: string } | null; + user?: { userId: string; tenantId: string; email?: string; role?: string } | null; }; export function getContext({ req, res }: any): AuthContext { @@ -18,8 +17,11 @@ export function getContext({ req, res }: any): AuthContext { // Support tokens that use either `userId` or standard JWT `sub` claim const userId = decoded?.userId || decoded?.sub; const tenantId = decoded?.tenantId; - if (userId && tenantId) user = { userId, tenantId, email: decoded.email }; - } catch {} + const role = decoded?.role; + if (userId && tenantId) user = { userId, tenantId, email: decoded.email, role }; + } catch (err) { + // Ignore verification errors; user remains unauthenticated + } } } return { req, res, user }; diff --git a/apps/api-gateway/src/modules/security/jwt.guard.ts b/apps/api-gateway/src/modules/security/jwt.guard.ts index ba0f789..7931557 100644 --- a/apps/api-gateway/src/modules/security/jwt.guard.ts +++ b/apps/api-gateway/src/modules/security/jwt.guard.ts @@ -11,9 +11,20 @@ export class JwtAuthGuard implements CanActivate { const info = gqlCtx.getInfo(); const fieldName = info?.fieldName; const parentType = info?.parentType?.name; - if (parentType === 'Mutation' && (fieldName === 'login' || fieldName === 'signup' || fieldName === 'initiateReturn')) return true; + if ( + parentType === 'Mutation' && + (fieldName === 'login' || fieldName === 'signup' || fieldName === 'initiateReturn') + ) + return true; // Public queries - if (parentType === 'Query' && (fieldName === 'returnById' || fieldName === 'labelByReturn' || fieldName === 'returnLabel' || fieldName === 'health')) return true; + if ( + parentType === 'Query' && + (fieldName === 'returnById' || + fieldName === 'labelByReturn' || + fieldName === 'returnLabel' || + fieldName === 'health') + ) + return true; // Public subscriptions (returns portal) if (parentType === 'Subscription' && fieldName === 'returnUpdated') return true; // Fast-path if context already has a verified user @@ -23,14 +34,19 @@ export class JwtAuthGuard implements CanActivate { const auth = ctx?.req?.headers?.authorization as string | undefined; if (!auth) return false; const [scheme, token] = auth.split(' '); - if ((scheme || '').toLowerCase() !== 'bearer' || !token) return false; + const lower = (scheme || '').toLowerCase(); + if (lower === 'apikey') return true; // allow API key calls; downstream can enforce scopes later + if (lower !== 'bearer' || !token) return false; // Optionally try to decode to populate ctx.user, but do not block if verify fails try { const decoded = jwt.verify(token, process.env.JWT_SECRET || 'dev-secret') as any; const userId = decoded?.userId || decoded?.sub; const tenantId = decoded?.tenantId; - if (userId && tenantId) ctx.user = { userId, tenantId, email: decoded?.email }; - } catch {} + if (userId && tenantId) + ctx.user = { userId, tenantId, email: decoded?.email, role: decoded?.role }; + } catch (err) { + // Ignore verification errors; downstream services enforce auth + } return true; } } diff --git a/apps/api-gateway/src/modules/security/roles.decorator.ts b/apps/api-gateway/src/modules/security/roles.decorator.ts new file mode 100644 index 0000000..df8506d --- /dev/null +++ b/apps/api-gateway/src/modules/security/roles.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ROLES_KEY = 'roles'; +export type Role = 'owner' | 'admin' | 'operator' | 'viewer'; +export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); diff --git a/apps/api-gateway/src/modules/security/roles.guard.ts b/apps/api-gateway/src/modules/security/roles.guard.ts new file mode 100644 index 0000000..2ac59c2 --- /dev/null +++ b/apps/api-gateway/src/modules/security/roles.guard.ts @@ -0,0 +1,22 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { ROLES_KEY, Role } from './roles.decorator'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const required = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (!required || required.length === 0) return true; + const gqlCtx = GqlExecutionContext.create(context); + const ctx = gqlCtx.getContext(); + const role = ctx?.user?.role as Role | undefined; + if (!role) return false; + return required.includes(role); + } +} diff --git a/apps/api-gateway/src/modules/subscriptions.resolver.ts b/apps/api-gateway/src/modules/subscriptions.resolver.ts index cfa5b16..1ccced0 100644 --- a/apps/api-gateway/src/modules/subscriptions.resolver.ts +++ b/apps/api-gateway/src/modules/subscriptions.resolver.ts @@ -75,4 +75,3 @@ export class SubscriptionsResolver { return this.subs.asyncIterator('return.updated'); } } - diff --git a/apps/api-gateway/src/modules/subscriptions.service.ts b/apps/api-gateway/src/modules/subscriptions.service.ts index 2b86b3c..532ee49 100644 --- a/apps/api-gateway/src/modules/subscriptions.service.ts +++ b/apps/api-gateway/src/modules/subscriptions.service.ts @@ -35,33 +35,51 @@ export class SubscriptionsService implements OnModuleInit, OnModuleDestroy { async onModuleInit() { // Bridge RMQ -> PubSub topics try { - await this.mq.subscribe('order.created', async (msg: OrderCreatedEvent) => { - await this.pubsub.publish('order.created', { orderCreated: msg }); - }); + await this.mq.subscribe( + 'order.created', + async (msg: OrderCreatedEvent) => { + await this.pubsub.publish('order.created', { orderCreated: msg }); + }, + ); } catch (e) { this.logger.error(`Failed to subscribe to order.created: ${(e as any)?.message || e}`); } try { - await this.mq.subscribe('return.state_changed', async (msg: ReturnUpdatedEvent) => { - await this.pubsub.publish('return.updated', { returnUpdated: msg }); - }); + await this.mq.subscribe( + 'return.state_changed', + async (msg: ReturnUpdatedEvent) => { + await this.pubsub.publish('return.updated', { returnUpdated: msg }); + }, + ); } catch (e) { this.logger.error(`Failed to subscribe to return.state_changed: ${(e as any)?.message || e}`); } try { await this.mq.subscribe('return.label_generated', async (msg: any) => { - const event: ReturnUpdatedEvent = { returnId: msg.returnId, state: 'label_generated', at: msg.at, tenantId: msg.tenantId }; + const event: ReturnUpdatedEvent = { + returnId: msg.returnId, + state: 'label_generated', + at: msg.at, + tenantId: msg.tenantId, + }; await this.pubsub.publish('return.updated', { returnUpdated: event }); }); } catch (e) { - this.logger.error(`Failed to subscribe to return.label_generated: ${(e as any)?.message || e}`); + this.logger.error( + `Failed to subscribe to return.label_generated: ${(e as any)?.message || e}`, + ); } try { - await this.mq.subscribe('webhook.delivery_updated', async (msg: WebhookDeliveryUpdatedEvent) => { - await this.pubsub.publish('webhook.delivery_updated', { webhookDeliveryUpdated: msg }); - }); + await this.mq.subscribe( + 'webhook.delivery_updated', + async (msg: WebhookDeliveryUpdatedEvent) => { + await this.pubsub.publish('webhook.delivery_updated', { webhookDeliveryUpdated: msg }); + }, + ); } catch (e) { - this.logger.error(`Failed to subscribe to webhook.delivery_updated: ${(e as any)?.message || e}`); + this.logger.error( + `Failed to subscribe to webhook.delivery_updated: ${(e as any)?.message || e}`, + ); } } @@ -73,4 +91,3 @@ export class SubscriptionsService implements OnModuleInit, OnModuleDestroy { return this.pubsub.asyncIterator(trigger); } } - diff --git a/apps/api-gateway/src/modules/users-admin.resolver.ts b/apps/api-gateway/src/modules/users-admin.resolver.ts new file mode 100644 index 0000000..3bd027d --- /dev/null +++ b/apps/api-gateway/src/modules/users-admin.resolver.ts @@ -0,0 +1,123 @@ +import { Args, Field, ObjectType, Query, Resolver, Mutation, Context } from '@nestjs/graphql'; +import axios from 'axios'; +import type { AuthContext } from './security/jwt.context'; +import { Roles } from './security/roles.decorator'; + +@ObjectType() +class ApiKeyGql { + @Field() + id!: string; + @Field() + name!: string; + @Field() + keyPrefix!: string; + @Field() + createdAt!: string; + @Field({ nullable: true }) + lastUsedAt?: string; + @Field({ nullable: true }) + key?: string; // only on create +} + +@ObjectType() +class ApiKeysListGql { + @Field(() => [ApiKeyGql]) + items!: ApiKeyGql[]; +} + +@ObjectType() +class AuditLogGql { + @Field() + id!: string; + @Field() + tenantId!: string; + @Field() + action!: string; + @Field() + resource!: string; + @Field({ nullable: true }) + resourceId?: string; + @Field({ nullable: true }) + ip?: string; + @Field({ nullable: true }) + metadata?: string; + @Field() + createdAt!: string; +} + +@ObjectType() +class AuditListGql { + @Field(() => [AuditLogGql]) + items!: AuditLogGql[]; +} + +@Resolver() +export class UsersAdminResolver { + private usersBase = process.env.USERS_URL || 'http://127.0.0.1:4001'; + + @Query(() => ApiKeysListGql) + @Roles('owner', 'admin') + async apiKeys(@Context() ctx: AuthContext): Promise { + const auth = ctx?.req?.headers?.authorization as string | undefined; + const res = await axios.get(`${this.usersBase}/v1/api-keys`, { + headers: auth ? { Authorization: auth } : undefined, + validateStatus: () => true, + }); + if (res.status >= 400) throw new Error(res.data?.message || 'Failed to list keys'); + return res.data as ApiKeysListGql; + } + + @Mutation(() => ApiKeyGql) + @Roles('owner', 'admin') + async createApiKey(@Args('name') name: string, @Context() ctx: AuthContext): Promise { + const auth = ctx?.req?.headers?.authorization as string | undefined; + const res = await axios.post( + `${this.usersBase}/v1/api-keys`, + { name }, + { headers: auth ? { Authorization: auth } : undefined, validateStatus: () => true }, + ); + if (res.status >= 400) throw new Error(res.data?.message || 'Failed to create key'); + try { + await axios.post( + `${this.usersBase}/v1/audit`, + { action: 'api_key.create', resource: 'api_key', resourceId: (res.data as any).id }, + { headers: auth ? { Authorization: auth } : undefined }, + ); + } catch (err) { + // Audit logging is best-effort; ignore failures + } + return res.data as ApiKeyGql; + } + + @Mutation(() => Boolean) + @Roles('owner', 'admin') + async revokeApiKey(@Args('id') id: string, @Context() ctx: AuthContext): Promise { + const auth = ctx?.req?.headers?.authorization as string | undefined; + const res = await axios.delete(`${this.usersBase}/v1/api-keys/${id}`, { + headers: auth ? { Authorization: auth } : undefined, + validateStatus: () => true, + }); + if (res.status >= 400) throw new Error(res.data?.message || 'Failed to revoke key'); + try { + await axios.post( + `${this.usersBase}/v1/audit`, + { action: 'api_key.revoke', resource: 'api_key', resourceId: id }, + { headers: auth ? { Authorization: auth } : undefined }, + ); + } catch (err) { + // Audit logging is best-effort; ignore failures + } + return true; + } + + @Query(() => AuditListGql) + @Roles('owner', 'admin') + async auditLogs(@Args('tenantId') tenantId: string): Promise { + const res = await axios.get(`${this.usersBase}/v1/audit`, { + params: { tenantId }, + validateStatus: () => true, + }); + if (res.status >= 400) throw new Error(res.data?.message || 'Failed to list audit logs'); + return res.data as AuditListGql; + } +} diff --git a/apps/api-gateway/src/modules/users.resolver.ts b/apps/api-gateway/src/modules/users.resolver.ts new file mode 100644 index 0000000..ab60ef7 --- /dev/null +++ b/apps/api-gateway/src/modules/users.resolver.ts @@ -0,0 +1,65 @@ +import { Args, Field, ObjectType, Query, Resolver, Mutation, Context } from '@nestjs/graphql'; +import axios from 'axios'; +import type { AuthContext } from './security/jwt.context'; +import { Roles } from './security/roles.decorator'; + +@ObjectType() +class UserGql { + @Field() + id!: string; + @Field() + email!: string; + @Field() + role!: string; + @Field() + createdAt!: string; +} + +@ObjectType() +class UsersListGql { + @Field(() => [UserGql]) + items!: UserGql[]; +} + +@Resolver() +export class UsersResolver { + private usersBase = process.env.USERS_URL || 'http://127.0.0.1:4001'; + + @Query(() => UsersListGql) + @Roles('owner', 'admin') + async users(@Context() ctx: AuthContext): Promise { + const auth = ctx?.req?.headers?.authorization as string | undefined; + const res = await axios.get(`${this.usersBase}/v1/users`, { + headers: auth ? { Authorization: auth } : undefined, + validateStatus: () => true, + }); + if (res.status >= 400) throw new Error(res.data?.message || 'Failed to list users'); + return res.data as UsersListGql; + } + + @Mutation(() => Boolean) + @Roles('owner', 'admin') + async setUserRole( + @Args('id') id: string, + @Args('role') role: string, + @Context() ctx: AuthContext, + ): Promise { + const auth = ctx?.req?.headers?.authorization as string | undefined; + const res = await axios.put( + `${this.usersBase}/v1/users/${id}/role`, + { role }, + { headers: auth ? { Authorization: auth } : undefined, validateStatus: () => true }, + ); + if (res.status >= 400) throw new Error(res.data?.message || 'Failed to set role'); + try { + await axios.post( + `${this.usersBase}/v1/audit`, + { action: 'user.role_set', resource: 'user', resourceId: id, metadata: { role } }, + { headers: auth ? { Authorization: auth } : undefined }, + ); + } catch (err) { + // Audit logging is best-effort; ignore failures + } + return true; + } +} diff --git a/apps/api-gateway/src/types.d.ts b/apps/api-gateway/src/types.d.ts index c1ca452..4e10b2a 100644 --- a/apps/api-gateway/src/types.d.ts +++ b/apps/api-gateway/src/types.d.ts @@ -23,5 +23,3 @@ declare module '@dispatch/messaging' { } export function createRabbitMQ(url?: string): Messaging; } - - diff --git a/apps/api-gateway/test/orders.resolver.spec.ts b/apps/api-gateway/test/orders.resolver.spec.ts index cfb468b..f47e980 100644 --- a/apps/api-gateway/test/orders.resolver.spec.ts +++ b/apps/api-gateway/test/orders.resolver.spec.ts @@ -16,7 +16,14 @@ describe('OrdersResolver', () => { status: 200, data: { items: [ - { id: 'o1', channel: 'shopify', externalId: 'E1', status: 'created', createdAt: '2020-01-01', itemsCount: 2 }, + { + id: 'o1', + channel: 'shopify', + externalId: 'E1', + status: 'created', + createdAt: '2020-01-01', + itemsCount: 2, + }, ], page: 2, pageSize: 10, @@ -34,4 +41,3 @@ describe('OrdersResolver', () => { }); }); }); - diff --git a/apps/api-gateway/tsconfig.build.json b/apps/api-gateway/tsconfig.build.json index 25ce629..5153282 100644 --- a/apps/api-gateway/tsconfig.build.json +++ b/apps/api-gateway/tsconfig.build.json @@ -5,4 +5,3 @@ }, "include": ["src"] } - diff --git a/apps/api-gateway/tsconfig.json b/apps/api-gateway/tsconfig.json index 42e1079..2347d75 100644 --- a/apps/api-gateway/tsconfig.json +++ b/apps/api-gateway/tsconfig.json @@ -4,12 +4,7 @@ "outDir": "dist", "emitDecoratorMetadata": true, "experimentalDecorators": true, - "types": ["node", "jest"], - "paths": { - "@dispatch/config": ["packages/config/dist"], - "@dispatch/logger": ["packages/logger/dist"], - "@dispatch/telemetry": ["packages/telemetry/dist"] - } + "types": ["node"] }, - "include": ["src", "test", "test/**/*.ts"] + "include": ["src"] } diff --git a/apps/merchant-dashboard/Dockerfile b/apps/merchant-dashboard/Dockerfile new file mode 100644 index 0000000..8f930c4 --- /dev/null +++ b/apps/merchant-dashboard/Dockerfile @@ -0,0 +1,46 @@ +# syntax=docker/dockerfile:1.6 + +FROM node:20-alpine AS base +ENV PNPM_HOME=/pnpm +ENV PNPM_STORE_DIR=/pnpm/store +ENV PATH=$PNPM_HOME:$PATH +RUN corepack enable && corepack prepare pnpm@9.0.0 --activate +WORKDIR /app + +FROM base AS deps +COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ +COPY packages ./packages +COPY apps/merchant-dashboard/package.json apps/merchant-dashboard/ +RUN pnpm install --no-frozen-lockfile + +FROM base AS build +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /pnpm/store /pnpm/store +COPY --from=deps /app/pnpm-lock.yaml ./pnpm-lock.yaml +COPY --from=deps /app/package.json ./package.json +COPY --from=deps /app/pnpm-workspace.yaml ./pnpm-workspace.yaml +COPY tsconfig*.json ./ +COPY packages ./packages +COPY apps/merchant-dashboard ./apps/merchant-dashboard +# Build shared packages first, then the Next.js app +RUN pnpm -r --filter=./packages/** build \ + && pnpm -C apps/merchant-dashboard install --silent \ + && pnpm -C apps/merchant-dashboard build + +FROM node:20-alpine AS runner +ENV NODE_ENV=production +ENV PNPM_HOME=/pnpm +ENV PATH=$PNPM_HOME:$PATH +RUN corepack enable && corepack prepare pnpm@9.0.0 --activate +WORKDIR /app/apps/merchant-dashboard + +# Copy built app and dependencies +COPY --from=build /app/apps/merchant-dashboard ./ +# Also copy root node_modules to satisfy pnpm symlinks +COPY --from=build /app/node_modules /app/node_modules + +EXPOSE 3001 +CMD ["node", "node_modules/next/dist/bin/next", "start", "-p", "3001"] + + diff --git a/apps/merchant-dashboard/next.config.js b/apps/merchant-dashboard/next.config.js index 305bf10..bea4cf5 100644 --- a/apps/merchant-dashboard/next.config.js +++ b/apps/merchant-dashboard/next.config.js @@ -1,6 +1,57 @@ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, + async headers() { + const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/graphql'; + const rulesBase = process.env.NEXT_PUBLIC_RULES_BASE || 'http://localhost:14004'; + const shopifyUrl = process.env.NEXT_PUBLIC_SHOPIFY_URL || 'http://localhost:14005'; + const toOrigin = (url) => { + try { + const u = new URL(url.startsWith('http') ? url : `http://${url}`); + return `${u.protocol}//${u.host}`; + } catch { + return null; + } + }; + const origins = [ + toOrigin(apiUrl), + toOrigin(apiUrl.replace('http', 'ws')), + toOrigin(rulesBase), + toOrigin(shopifyUrl), + ].filter(Boolean); + const connectSrc = ["'self'", ...new Set(origins)].join(' '); + + const isDev = process.env.NODE_ENV !== 'production'; + const scriptSrc = ["'self'", isDev ? "'unsafe-eval'" : null].filter(Boolean).join(' '); + const fontSrc = ["'self'", 'data:', isDev ? 'https://r2cdn.perplexity.ai' : null] + .filter(Boolean) + .join(' '); + const csp = [ + "default-src 'self'", + "frame-ancestors 'none'", + "img-src 'self' data:", + "style-src 'self' 'unsafe-inline'", + `font-src ${fontSrc}`, + `script-src ${scriptSrc}`, + `connect-src ${connectSrc}`, + ].join('; '); + + return [ + { + source: '/(.*)', + headers: [ + { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'Referrer-Policy', value: 'no-referrer' }, + { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, + { + key: 'Strict-Transport-Security', + value: 'max-age=63072000; includeSubDomains; preload', + }, + { key: 'Content-Security-Policy', value: csp }, + ], + }, + ]; + }, }; module.exports = nextConfig; - diff --git a/apps/merchant-dashboard/package.json b/apps/merchant-dashboard/package.json index 37f5d53..7921a57 100644 --- a/apps/merchant-dashboard/package.json +++ b/apps/merchant-dashboard/package.json @@ -4,19 +4,26 @@ "private": true, "scripts": { "dev": "next dev -p 3001", + "dev:3101": "next dev -p 3101", "build": "next build", "start": "next start -p 3001", "lint": "eslint . --ext .ts,.tsx" }, "dependencies": { + "@dispatch/ui": "workspace:*", + "@tanstack/react-virtual": "^3.0.0", + "graphql-ws": "^5.15.0", + "recharts": "^2.12.7", "next": "^14.2.5", "react": "^18.3.1", - "react-dom": "^18.3.1", - "graphql-ws": "^5.15.0" + "react-dom": "^18.3.1" }, "devDependencies": { - "@types/react": "19.1.12", - "eslint": "^8.57.0", - "eslint-config-next": "^14.2.5" + "@types/react": "19.1.12", + "autoprefixer": "^10.4.21", + "eslint": "^8.57.0", + "eslint-config-next": "^14.2.5", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.13" } } diff --git a/apps/merchant-dashboard/postcss.config.js b/apps/merchant-dashboard/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/apps/merchant-dashboard/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/merchant-dashboard/src/components/Layout.tsx b/apps/merchant-dashboard/src/components/Layout.tsx new file mode 100644 index 0000000..ab38d73 --- /dev/null +++ b/apps/merchant-dashboard/src/components/Layout.tsx @@ -0,0 +1,145 @@ +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; + +type NavItem = { href: string; label: string; roles?: string[] }; + +const NAV: NavItem[] = [ + { href: '/', label: 'Dashboard' }, + { href: '/shipments', label: 'Shipments' }, + { href: '/orders', label: 'Orders' }, + { href: '/returns', label: 'Returns' }, + { href: '/analytics', label: 'Analytics' }, + { href: '/webhooks', label: 'Webhooks', roles: ['owner', 'admin'] }, + { href: '/users', label: 'Users', roles: ['owner', 'admin'] }, + { href: '/rules', label: 'Rules', roles: ['owner', 'admin'] }, + { href: '/notifications', label: 'Notifications', roles: ['owner', 'admin'] }, + { href: '/integrations', label: 'Integrations', roles: ['owner', 'admin'] }, + { href: '/settings', label: 'Settings', roles: ['owner', 'admin'] }, +]; + +function parseJwt(token?: string | null): { role?: string | null } { + if (!token) return {} as any; + try { + const [, payload] = token.split('.'); + const json = JSON.parse(Buffer.from(payload, 'base64').toString('utf-8')); + return { role: (json?.role as string) || null }; + } catch { + return {} as any; + } +} + +export default function Layout({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [role, setRole] = useState(null); + + useEffect(() => { + setSidebarOpen(false); + }, [router.pathname]); + + useEffect(() => { + const t = typeof window !== 'undefined' ? localStorage.getItem('token') : null; + const p = parseJwt(t); + setRole((p.role || '').toLowerCase()); + }, []); + + return ( +
+ + +
+
+ +
+ {role ? `Role: ${role}` : ''} +
+
+ + +
+
+
{children}
+
+
+ ); +} + +function ThemeToggle() { + const [mounted, setMounted] = useState(false); + const [isDark, setIsDark] = useState(false); + useEffect(() => { + setMounted(true); + if (typeof window !== 'undefined') { + const pref = localStorage.getItem('theme'); + const systemDark = + window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + const shouldDark = pref ? pref === 'dark' : systemDark; + if (shouldDark) document.documentElement.classList.add('dark'); + setIsDark(shouldDark); + } + }, []); + if (!mounted) return null; + return ( + + ); +} diff --git a/apps/merchant-dashboard/src/components/charts.tsx b/apps/merchant-dashboard/src/components/charts.tsx new file mode 100644 index 0000000..4cec97e --- /dev/null +++ b/apps/merchant-dashboard/src/components/charts.tsx @@ -0,0 +1,286 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + Area, + AreaChart, + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, + Pie, + PieChart, + Cell, +} from 'recharts'; + +export type TimeSeriesPoint = { t: string; v: number }; +export type BreakdownItem = { key: string; value: number }; + +function useMounted() { + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + return mounted; +} + +function formatDateLabel(d: string) { + // Expecting YYYY-MM-DD; fallback to raw + try { + const parts = d.split('-').map((x) => parseInt(x, 10)); + if (parts.length === 3) { + const date = new Date(parts[0], parts[1] - 1, parts[2]); + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } + } catch { + /* ignore parse errors */ + } + return d; +} + +export function compactNumber(n: number) { + try { + return new Intl.NumberFormat(undefined, { + notation: 'compact', + maximumFractionDigits: 1, + }).format(n); + } catch { + if (Math.abs(n) >= 1_000_000_000) return (n / 1_000_000_000).toFixed(1) + 'B'; + if (Math.abs(n) >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'; + if (Math.abs(n) >= 1_000) return (n / 1_000).toFixed(1) + 'K'; + return String(n); + } +} + +export function usdCents(n: number) { + try { + return new Intl.NumberFormat(undefined, { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 2, + }).format(n / 100); + } catch { + return `$${(n / 100).toFixed(2)}`; + } +} + +function getDomainAndScale(values: number[]): { + domain: [number, number]; + scale: 'linear' | 'log'; + plotValues?: number[]; + usedLog: boolean; +} { + const finite = values.filter((v) => Number.isFinite(v)); + if (!finite.length) return { domain: [0, 1], scale: 'linear', usedLog: false }; + const min = Math.min(...finite); + const max = Math.max(...finite); + if (min === max) { + if (min === 0) return { domain: [0, 1], scale: 'linear', usedLog: false }; + const pad = Math.abs(min) * 0.1; + return { domain: [min - pad, max + pad], scale: 'linear', usedLog: false }; + } + const minPositive = Math.min(...finite.filter((v) => v > 0)); + const ratio = minPositive > 0 ? max / minPositive : Infinity; + if (min >= 0 && ratio > 1000 && Number.isFinite(ratio) && minPositive !== Infinity) { + // Use log scale; map zeros to a small floor to avoid breaking + const floor = Math.max(minPositive * 0.5, 0.1); + const plotValues = values.map((v) => (v <= 0 ? floor : v)); + const plotFinite = plotValues.filter((v) => Number.isFinite(v)); + const pmin = Math.min(...plotFinite); + const pmax = Math.max(...plotFinite); + return { domain: [pmin, pmax], scale: 'log', plotValues, usedLog: true }; + } + // Linear with padding + const pad = (max - min) * 0.1; + return { domain: [min - pad, max + pad], scale: 'linear', usedLog: false }; +} + +type TimeSeriesChartProps = { + points: TimeSeriesPoint[]; + color?: string; // hex + area?: boolean; + valueFormatter?: (n: number) => string; + yDomain?: [number, number]; + percent?: boolean; + height?: number; + grid?: boolean; +}; + +export function TimeSeriesChart({ + points, + color = '#4f46e5', + area = true, + valueFormatter = compactNumber, + yDomain, + percent = false, + height = 240, + grid = true, +}: TimeSeriesChartProps) { + const mounted = useMounted(); + const data = useMemo(() => { + const vals = points?.map((p) => (Number.isFinite(p.v) ? p.v : 0)) || []; + const { domain, scale, plotValues, usedLog } = yDomain + ? { domain: yDomain, scale: 'linear' as const, plotValues: undefined, usedLog: false } + : getDomainAndScale(vals); + return { + usedLog, + scale, + domain, + rows: (points || []).map((p, i) => ({ + time: p.t, + value: vals[i] ?? 0, + plot: plotValues ? plotValues[i] : (vals[i] ?? 0), + })), + }; + }, [points, yDomain]); + + if (!mounted) return
Loading chart…
; + if (!points?.length) return
No data
; + + const stroke = color; + const fill = color.replace('#', '').length === 6 ? `${color}33` : color; // light alpha + + const chart = area ? ( + + {grid && } + + (percent ? `${v.toFixed(0)}%` : valueFormatter(v))} + domain={data.domain as any} + scale={data.scale} + /> + (percent ? `${Number(v).toFixed(1)}%` : valueFormatter(Number(v)))} + labelFormatter={(l: any) => formatDateLabel(String(l))} + /> + + + ) : ( + + {grid && } + + (percent ? `${v.toFixed(0)}%` : valueFormatter(v))} + domain={data.domain as any} + scale={data.scale} + /> + (percent ? `${Number(v).toFixed(1)}%` : valueFormatter(Number(v)))} + labelFormatter={(l: any) => formatDateLabel(String(l))} + /> + + + ); + + return ( +
+ {data.usedLog && ( +
+ Log Scale +
+ )} +
+ {chart} +
+
+ ); +} + +type DonutChartProps = { + items: BreakdownItem[]; + colors?: string[]; + height?: number; + valueFormatter?: (n: number) => string; + maxSlices?: number; // Aggregate rest as Other +}; + +const DEFAULT_COLORS = [ + '#4f46e5', + '#06b6d4', + '#22c55e', + '#f59e0b', + '#ef4444', + '#8b5cf6', + '#14b8a6', + '#e11d48', +]; + +export function DonutChart({ + items, + colors = DEFAULT_COLORS, + height = 240, + valueFormatter = compactNumber, + maxSlices = 6, +}: DonutChartProps) { + const mounted = useMounted(); + const data = useMemo(() => { + const list = (items || []).filter((x) => Number.isFinite(x.value)); + if (list.length > maxSlices) { + const sorted = [...list].sort((a, b) => b.value - a.value); + const head = sorted.slice(0, maxSlices - 1); + const tail = sorted.slice(maxSlices - 1); + const other = { key: 'Other', value: tail.reduce((s, x) => s + x.value, 0) }; + return [...head, other]; + } + return list; + }, [items, maxSlices]); + if (!mounted) return
Loading chart…
; + if (!data?.length) return
No data
; + const total = data.reduce((s, x) => s + (x.value || 0), 0) || 1; + return ( +
+ + + valueFormatter(Number(v))} /> + + {data.map((_, idx) => ( + + ))} + + + +
+
    + {data.map((it, idx) => { + const pct = ((it.value / total) * 100).toFixed(1); + return ( +
  • + + + {it.key || '(none)'} + + + {valueFormatter(it.value)} · {pct}% + +
  • + ); + })} +
+
+
+ ); +} diff --git a/apps/merchant-dashboard/src/components/ui.tsx b/apps/merchant-dashboard/src/components/ui.tsx new file mode 100644 index 0000000..59ff7b7 --- /dev/null +++ b/apps/merchant-dashboard/src/components/ui.tsx @@ -0,0 +1,81 @@ +import React from 'react'; + +export function KPICard({ title, value }: { title: string; value?: string | null }) { + return ( +
+
{title}
+
+
{value ?? '-'}
+
+
+ ); +} + +export type BarPoint = { t: string; v: number }; + +export function BarList({ + points, + colorClass = 'bg-gray-400', + format, +}: { + points: BarPoint[]; + colorClass?: string; + format?: (v: number) => string; +}) { + if (!points?.length) return
No data
; + const max = Math.max(1, ...points.map((p) => p.v)); + return ( +
+ {points.map((p) => ( +
+
{p.t}
+
+
{format ? format(p.v) : p.v}
+
+ ))} +
+ ); +} + +export function BreakdownList({ items }: { items: Array<{ key: string; value: number }> }) { + if (!items?.length) return
No data
; + return ( +
    + {items.map((it) => ( +
  • + {it.key || '(none)'} + {it.value} +
  • + ))} +
+ ); +} + +export function Section({ + title, + children, + className = '', +}: { + title: string; + children: React.ReactNode; + className?: string; +}) { + return ( +
+
{title}
+
{children}
+
+ ); +} + +export function fmtMs(ms?: number | null) { + if (!ms && ms !== 0) return '-'; + const s = Math.round(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rs = s % 60; + return `${m}m ${rs}s`; +} diff --git a/apps/merchant-dashboard/src/lib/graphql.ts b/apps/merchant-dashboard/src/lib/graphql.ts index ddca15a..ded229c 100644 --- a/apps/merchant-dashboard/src/lib/graphql.ts +++ b/apps/merchant-dashboard/src/lib/graphql.ts @@ -5,28 +5,51 @@ type GraphQLResponse = { errors?: Array<{ message?: string }>; }; +async function handleResponse(res: Response): Promise { + let text = ''; + try { + text = await res.text(); + } catch (e) { + /* ignore */ + } + let json: GraphQLResponse | null = null; + try { + json = text ? (JSON.parse(text) as GraphQLResponse) : null; + } catch (e) { + /* ignore */ + } + + if (!res.ok) { + const msg = json?.errors?.[0]?.message || text || `${res.status} ${res.statusText}`; + if (res.status === 401) throw new Error('Unauthorized: please log in again'); + if (res.status === 403) throw new Error('Forbidden: your token lacks access'); + throw new Error(msg || 'Request failed'); + } + + if (json?.errors && json.errors.length > 0) { + throw new Error(json.errors[0]?.message || 'GraphQL error'); + } + return (json?.data ?? ({} as T)) as T; +} + export async function gql(query: string, variables?: Record) { const res = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables }), }); - const json = (await res.json()) as unknown as GraphQLResponse; - if (json?.errors && json.errors.length > 0) { - throw new Error(json.errors[0]?.message || 'GraphQL error'); - } - return (json?.data ?? ({} as T)) as T; + return handleResponse(res); } -export async function gqlAuth(query: string, token: string, variables?: Record) { +export async function gqlAuth( + query: string, + token: string, + variables?: Record, +) { const res = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ query, variables }), }); - const json = (await res.json()) as unknown as GraphQLResponse; - if (json?.errors && json.errors.length > 0) { - throw new Error(json.errors[0]?.message || 'GraphQL error'); - } - return (json?.data ?? ({} as T)) as T; + return handleResponse(res); } diff --git a/apps/merchant-dashboard/src/lib/subscriptions.ts b/apps/merchant-dashboard/src/lib/subscriptions.ts index 441bd6c..79cd6e4 100644 --- a/apps/merchant-dashboard/src/lib/subscriptions.ts +++ b/apps/merchant-dashboard/src/lib/subscriptions.ts @@ -1,14 +1,23 @@ import { createClient } from 'graphql-ws'; export function makeWsClient(token?: string) { - const url = (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/graphql').replace('http', 'ws'); + const url = (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/graphql').replace( + 'http', + 'ws', + ); return createClient({ url, connectionParams: token ? { headers: { Authorization: `Bearer ${token}` } } : undefined, }); } -export function subscribe(client: ReturnType, query: string, variables: any, onData: (data: T) => void, onError?: (err: any) => void) { +export function subscribe( + client: ReturnType, + query: string, + variables: any, + onData: (data: T) => void, + onError?: (err: any) => void, +) { const dispose = client.subscribe<{ data?: T; errors?: any }>( { query, variables }, { @@ -21,4 +30,3 @@ export function subscribe(client: ReturnType, quer ); return dispose; } - diff --git a/apps/merchant-dashboard/src/pages/_app.tsx b/apps/merchant-dashboard/src/pages/_app.tsx new file mode 100644 index 0000000..8071414 --- /dev/null +++ b/apps/merchant-dashboard/src/pages/_app.tsx @@ -0,0 +1,43 @@ +import type { AppProps } from 'next/app'; +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import Layout from '../components/Layout'; +import '../styles/globals.css'; +import '@dispatch/ui/styles/tokens.css'; +import '@dispatch/ui/styles/components.css'; +import { ThemeProvider } from '@dispatch/ui'; + +export default function App({ Component, pageProps }: AppProps) { + const router = useRouter(); + const [token, setToken] = useState(null); + + const isPublic = + router.pathname === '/login' || + router.pathname === '/signup' || + router.pathname.startsWith('/api'); + + useEffect(() => { + try { + const t = typeof window !== 'undefined' ? localStorage.getItem('token') : null; + setToken(t); + } catch (e) { + console.error(e); + setToken(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [router.pathname]); + + useEffect(() => { + if (isPublic) return; + if (token === null) return; // not yet loaded + if (!token) { + const next = encodeURIComponent(router.asPath || '/'); + router.replace(`/login?next=${next}`); + } + }, [isPublic, token, router]); + + const content = ; + const wrapped = isPublic ? content : {content}; + const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/graphql'; + return {wrapped}; +} diff --git a/apps/merchant-dashboard/src/pages/_document.tsx b/apps/merchant-dashboard/src/pages/_document.tsx new file mode 100644 index 0000000..aa8b9d8 --- /dev/null +++ b/apps/merchant-dashboard/src/pages/_document.tsx @@ -0,0 +1,17 @@ +import { Html, Head, Main, NextScript } from 'next/document'; + +export default function Document() { + return ( + + + + + + + +
+ + + + ); +} diff --git a/apps/merchant-dashboard/src/pages/analytics.tsx b/apps/merchant-dashboard/src/pages/analytics.tsx new file mode 100644 index 0000000..34c6181 --- /dev/null +++ b/apps/merchant-dashboard/src/pages/analytics.tsx @@ -0,0 +1,119 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import { gqlAuth } from '../lib/graphql'; +import { KPICard, fmtMs } from '../components/ui'; +import { TimeSeriesChart, DonutChart } from '../components/charts'; + +type TimeSeriesPoint = { t: string; v: number }; +type BreakdownItem = { key: string; value: number }; +type KPISet = { + returnRatePct: number; + returnsCount: number; + refundsAmountCents: number; + avgApprovalMs?: number | null; + avgRefundMs?: number | null; +}; +type ReturnsOverview = { + kpis: KPISet; + returnsByDay: TimeSeriesPoint[]; + reasons: BreakdownItem[]; + channels: BreakdownItem[]; +}; +type OrdersOverview = { ordersByDay: TimeSeriesPoint[]; channels: BreakdownItem[] }; + +export default function AnalyticsOverview() { + const router = useRouter(); + const [token, setToken] = useState(null); + const [data, setData] = useState(null); + const [orders, setOrders] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const t = localStorage.getItem('token'); + if (!t) { + router.replace('/login'); + return; + } + setToken(t); + const to = new Date(); + const from = new Date(Date.now() - 30 * 24 * 3600 * 1000); + gqlAuth<{ analyticsReturns: ReturnsOverview }>( + `query Ret($range: AnalyticsRange!) { analyticsReturns(range: $range) { kpis { returnRatePct returnsCount refundsAmountCents avgApprovalMs avgRefundMs } returnsByDay { t v } reasons { key value } channels { key value } } }`, + t, + { range: { from: from.toISOString().slice(0, 10), to: to.toISOString().slice(0, 10) } }, + ) + .then((r) => setData(r.analyticsReturns)) + .catch((e) => setError(e?.message || 'Failed to load analytics')); + + gqlAuth<{ analyticsOrders: OrdersOverview }>( + `query Q($range: AnalyticsRange!) { analyticsOrders(range: $range) { ordersByDay { t v } channels { key value } } }`, + t, + { range: { from: from.toISOString().slice(0, 10), to: to.toISOString().slice(0, 10) } }, + ) + .then((r) => setOrders(r.analyticsOrders)) + .catch(() => {}); + }, [router]); + + if (!token) return null; + if (error) + return ( +
+

Analytics

+
+
{error}
+
+
+ ); + if (!data) + return ( +
+

Analytics

+
+
Loading…
+
+
+ ); + + return ( +
+
+

Analytics Overview

+
+
+ + + + + +
+
+
+
Returns (last 30d)
+
+ +
+
+
+
Orders (last 30d)
+
+ +
+
+
+
+
+
Top Reasons
+
+ +
+
+
+
Order Channels
+
+ +
+
+
+
+ ); +} diff --git a/apps/merchant-dashboard/src/pages/analytics/labels.tsx b/apps/merchant-dashboard/src/pages/analytics/labels.tsx new file mode 100644 index 0000000..c5809e2 --- /dev/null +++ b/apps/merchant-dashboard/src/pages/analytics/labels.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import { gqlAuth } from '../../lib/graphql'; +import { DonutChart, TimeSeriesChart, usdCents } from '../../components/charts'; + +type P = { t: string; v: number }; +type Item = { key: string; value: number }; +type LabelsOverview = { costByDay: P[]; carrierMix: Item[] }; + +export default function LabelsAnalytics() { + const router = useRouter(); + const [token, setToken] = useState(null); + const [data, setData] = useState(null); + useEffect(() => { + const t = localStorage.getItem('token'); + if (!t) { + router.replace('/login'); + return; + } + setToken(t); + const to = new Date(); + const from = new Date(Date.now() - 30 * 24 * 3600 * 1000); + gqlAuth<{ analyticsLabels: LabelsOverview }>( + `query Q($range: AnalyticsRange!) { analyticsLabels(range: $range) { costByDay { t v } carrierMix { key value } } }`, + t, + { range: { from: from.toISOString().slice(0, 10), to: to.toISOString().slice(0, 10) } }, + ).then((r) => setData(r.analyticsLabels)); + }, [router]); + if (!token || !data) return null; + return ( +
+
+

Shipping Labels Analytics

+
+
+
Cost by Day
+
+ +
+
+
+
Carrier/Service Mix
+
+ +
+
+
+ ); +} diff --git a/apps/merchant-dashboard/src/pages/analytics/orders.tsx b/apps/merchant-dashboard/src/pages/analytics/orders.tsx new file mode 100644 index 0000000..498df77 --- /dev/null +++ b/apps/merchant-dashboard/src/pages/analytics/orders.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import { gqlAuth } from '../../lib/graphql'; +import { DonutChart, TimeSeriesChart } from '../../components/charts'; + +type P = { t: string; v: number }; +type Item = { key: string; value: number }; +type OrdersOverview = { ordersByDay: P[]; channels: Item[] }; + +export default function OrdersAnalytics() { + const router = useRouter(); + const [token, setToken] = useState(null); + const [data, setData] = useState(null); + useEffect(() => { + const t = localStorage.getItem('token'); + if (!t) { + router.replace('/login'); + return; + } + setToken(t); + const to = new Date(); + const from = new Date(Date.now() - 30 * 24 * 3600 * 1000); + gqlAuth<{ analyticsOrders: OrdersOverview }>( + `query Q($range: AnalyticsRange!) { analyticsOrders(range: $range) { ordersByDay { t v } channels { key value } } }`, + t, + { range: { from: from.toISOString().slice(0, 10), to: to.toISOString().slice(0, 10) } }, + ).then((r) => setData(r.analyticsOrders)); + }, [router]); + if (!token || !data) return null; + return ( +
+
+

Orders Analytics

+
+
+
Orders by Day
+
+ +
+
+
+
Channel Mix
+
+ +
+
+
+ ); +} diff --git a/apps/merchant-dashboard/src/pages/analytics/refunds.tsx b/apps/merchant-dashboard/src/pages/analytics/refunds.tsx new file mode 100644 index 0000000..c0181b1 --- /dev/null +++ b/apps/merchant-dashboard/src/pages/analytics/refunds.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import { gqlAuth } from '../../lib/graphql'; +import { DonutChart, TimeSeriesChart, usdCents } from '../../components/charts'; + +type P = { t: string; v: number }; +type Item = { key: string; value: number }; +type RefundsOverview = { amountByDay: P[]; successRateByDay: P[]; providers: Item[] }; + +export default function RefundsAnalytics() { + const router = useRouter(); + const [token, setToken] = useState(null); + const [data, setData] = useState(null); + useEffect(() => { + const t = localStorage.getItem('token'); + if (!t) { + router.replace('/login'); + return; + } + setToken(t); + const to = new Date(); + const from = new Date(Date.now() - 30 * 24 * 3600 * 1000); + gqlAuth<{ analyticsRefunds: RefundsOverview }>( + `query Q($range: AnalyticsRange!) { analyticsRefunds(range: $range) { amountByDay { t v } successRateByDay { t v } providers { key value } } }`, + t, + { range: { from: from.toISOString().slice(0, 10), to: to.toISOString().slice(0, 10) } }, + ).then((r) => setData(r.analyticsRefunds)); + }, [router]); + if (!token || !data) return null; + return ( +
+
+

Refunds Analytics

+
+
+
Amount by Day
+
+ +
+
+
+
Success Rate
+
+ `${n.toFixed(0)}%`} + /> +
+
+
+
Providers
+
+ +
+
+
+ ); +} diff --git a/apps/merchant-dashboard/src/pages/analytics/returns.tsx b/apps/merchant-dashboard/src/pages/analytics/returns.tsx new file mode 100644 index 0000000..8ca9cb8 --- /dev/null +++ b/apps/merchant-dashboard/src/pages/analytics/returns.tsx @@ -0,0 +1,68 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import { gqlAuth } from '../../lib/graphql'; +import { DonutChart, TimeSeriesChart } from '../../components/charts'; + +type Point = { t: string; v: number }; +type Item = { key: string; value: number }; +type KPI = { + returnRatePct: number; + returnsCount: number; + refundsAmountCents: number; + avgApprovalMs?: number | null; + avgRefundMs?: number | null; +}; +type Ret = { kpis: KPI; returnsByDay: Point[]; reasons: Item[]; channels: Item[] }; + +export default function ReturnsAnalytics() { + const router = useRouter(); + const [token, setToken] = useState(null); + const [data, setData] = useState(null); + + useEffect(() => { + const t = localStorage.getItem('token'); + if (!t) { + router.replace('/login'); + return; + } + setToken(t); + const to = new Date(); + const from = new Date(Date.now() - 30 * 24 * 3600 * 1000); + gqlAuth<{ analyticsReturns: Ret }>( + `query Ret($range: AnalyticsRange!) { analyticsReturns(range: $range) { kpis { returnRatePct returnsCount refundsAmountCents avgApprovalMs avgRefundMs } returnsByDay { t v } reasons { key value } channels { key value } } }`, + t, + { range: { from: from.toISOString().slice(0, 10), to: to.toISOString().slice(0, 10) } }, + ).then((r) => setData(r.analyticsReturns)); + }, [router]); + + if (!token || !data) return null; + return ( +
+
+

Returns Analytics

+
+
+
Returns by Day
+
+ +
+
+
+
+
Top Reasons
+
+ +
+
+
+
Channels
+
+ +
+
+
+
+ ); +} + +// table replaced by DonutChart for better visualization diff --git a/apps/merchant-dashboard/src/pages/analytics/webhooks.tsx b/apps/merchant-dashboard/src/pages/analytics/webhooks.tsx new file mode 100644 index 0000000..9aaa301 --- /dev/null +++ b/apps/merchant-dashboard/src/pages/analytics/webhooks.tsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import { gqlAuth } from '../../lib/graphql'; +import { DonutChart, TimeSeriesChart } from '../../components/charts'; + +type P = { t: string; v: number }; +type Item = { key: string; value: number }; +type WebhooksOverview = { successRateByDay: P[]; deliveriesByStatus: Item[] }; + +export default function WebhooksAnalytics() { + const router = useRouter(); + const [token, setToken] = useState(null); + const [data, setData] = useState(null); + useEffect(() => { + const t = localStorage.getItem('token'); + if (!t) { + router.replace('/login'); + return; + } + setToken(t); + const to = new Date(); + const from = new Date(Date.now() - 30 * 24 * 3600 * 1000); + gqlAuth<{ analyticsWebhooks: WebhooksOverview }>( + `query Q($range: AnalyticsRange!) { analyticsWebhooks(range: $range) { successRateByDay { t v } deliveriesByStatus { key value } } }`, + t, + { range: { from: from.toISOString().slice(0, 10), to: to.toISOString().slice(0, 10) } }, + ).then((r) => setData(r.analyticsWebhooks)); + }, [router]); + if (!token || !data) return null; + return ( +
+
+

Webhooks Analytics

+
+
+
Success Rate by Day
+
+ `${n.toFixed(0)}%`} + /> +
+
+
+
Deliveries by Status
+
+ +
+
+
+ ); +} diff --git a/apps/merchant-dashboard/src/pages/api/health.ts b/apps/merchant-dashboard/src/pages/api/health.ts index 81e7232..4ce78fd 100644 --- a/apps/merchant-dashboard/src/pages/api/health.ts +++ b/apps/merchant-dashboard/src/pages/api/health.ts @@ -3,4 +3,3 @@ import type { NextApiRequest, NextApiResponse } from 'next'; export default function handler(_req: NextApiRequest, res: NextApiResponse) { res.status(200).json({ status: 'ok' }); } - diff --git a/apps/merchant-dashboard/src/pages/index.tsx b/apps/merchant-dashboard/src/pages/index.tsx index 2d296f7..287266a 100644 --- a/apps/merchant-dashboard/src/pages/index.tsx +++ b/apps/merchant-dashboard/src/pages/index.tsx @@ -1,53 +1,161 @@ import Link from 'next/link'; -import dynamic from 'next/dynamic'; +import { useEffect, useState } from 'react'; +import { gqlAuth } from '../lib/graphql'; +import { BreakdownList, KPICard, fmtMs } from '../components/ui'; +import { DonutChart, TimeSeriesChart } from '../components/charts'; + +type TimeSeriesPoint = { t: string; v: number }; +type BreakdownItem = { key: string; value: number }; +type KPISet = { + returnRatePct: number; + returnsCount: number; + refundsAmountCents: number; + avgApprovalMs?: number | null; + avgRefundMs?: number | null; +}; +type ReturnsOverview = { + kpis: KPISet; + returnsByDay: TimeSeriesPoint[]; + reasons: BreakdownItem[]; + channels: BreakdownItem[]; +}; +type OrdersOverview = { ordersByDay: TimeSeriesPoint[] }; export default function Home() { + const [data, setData] = useState(null); + const [orders, setOrders] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const t = typeof window !== 'undefined' ? localStorage.getItem('token') : null; + if (!t) return; + const to = new Date(); + const from = new Date(Date.now() - 14 * 24 * 3600 * 1000); + gqlAuth<{ analyticsReturns: ReturnsOverview }>( + `query Ret($range: AnalyticsRange!) { analyticsReturns(range: $range) { kpis { returnRatePct returnsCount refundsAmountCents avgApprovalMs avgRefundMs } returnsByDay { t v } reasons { key value } channels { key value } } }`, + t, + { range: { from: from.toISOString().slice(0, 10), to: to.toISOString().slice(0, 10) } }, + ) + .then((r) => setData(r.analyticsReturns)) + .catch((e) => setError(e?.message || 'Failed to load analytics')); + + gqlAuth<{ analyticsOrders: OrdersOverview }>( + `query Q($range: AnalyticsRange!) { analyticsOrders(range: $range) { ordersByDay { t v } } }`, + t, + { range: { from: from.toISOString().slice(0, 10), to: to.toISOString().slice(0, 10) } }, + ) + .then((r) => setOrders(r.analyticsOrders)) + .catch(() => {}); + }, []); + return ( -
-

Dispatch Merchant Dashboard

- -

Welcome. Health: query via api-gateway / GraphQL.

-
- - - - - - - - - - - - - - - - - - +
+
+

Dashboard

+
+ + View Orders + + + Initiate Return + +
-
- ); -} -function AuthBar() { - if (typeof window === 'undefined') return null as any; - const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null; - if (!token) return null as any; - return ( -
- + {error && ( +
+
+

{error}

+
+
+ )} + +
+ { + if (orders) { + const total = orders.ordersByDay.reduce( + (s, p) => s + (Number.isFinite(p.v) ? p.v : 0), + 0, + ); + return String(total); + } + // Fallback: estimate from returns + return rate when orders analytics is unavailable + if (data && Number.isFinite(data.kpis.returnRatePct) && data.kpis.returnRatePct > 0) { + const est = Math.round(data.kpis.returnsCount / (data.kpis.returnRatePct / 100)); + if (Number.isFinite(est)) return `~${est}`; + } + return undefined; + })()} + /> + + + + + +
+ +
+
+
Returns (last 14d)
+
+ +
+
+
+
Orders (last 14d)
+
+ +
+
+
+
Quick Links
+
+ + Analytics + + + Webhooks + + + Users + + + Rules + + + Integrations + + + Settings + +
+
+
+ +
+
+
Top Reasons
+
+ {data ? : } +
+
+
+
Channels
+
+ {data ? : } +
+
+
); } -// Avoid SSR rendering differences for AuthBar -const ClientAuthBar = dynamic(() => Promise.resolve(AuthBar as any), { ssr: false }); +// fmtMs imported from components/ui diff --git a/apps/merchant-dashboard/src/pages/integrations.tsx b/apps/merchant-dashboard/src/pages/integrations.tsx new file mode 100644 index 0000000..fa05510 --- /dev/null +++ b/apps/merchant-dashboard/src/pages/integrations.tsx @@ -0,0 +1,110 @@ +import { useEffect, useState } from 'react'; + +type Connection = { id: string; shop: string; status: string; lastWebhookAt?: string }; + +export default function IntegrationsPage() { + const shopifyUrl = process.env.NEXT_PUBLIC_SHOPIFY_URL || 'http://localhost:14005'; + const token = typeof window !== 'undefined' ? localStorage.getItem('token') : ''; + const [shop, setShop] = useState('test-shop.myshopify.com'); + const [connections, setConnections] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function fetchConnections() { + try { + const res = await fetch(`${shopifyUrl}/v1/shopify/connections`, { + headers: { Authorization: token ? `Bearer ${token}` : '' }, + }); + const json = await res.json(); + setConnections(json?.items || []); + } catch (e) { + console.error(e); + } + } + + useEffect(() => { + fetchConnections(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shopifyUrl, token]); + + async function startInstall() { + setLoading(true); + setError(null); + try { + const res = await fetch(`${shopifyUrl}/v1/shopify/install/start`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: token ? `Bearer ${token}` : '', + }, + body: JSON.stringify({ shop }), + }); + const json = await res.json(); + if (json?.redirectUrl) { + const full = `${shopifyUrl}${json.redirectUrl}`; + window.location.href = full; + } else if (json?.error) { + setError(json.error); + } + } catch (e: any) { + setError(e?.message || 'Failed to start install'); + } finally { + setLoading(false); + } + } + + return ( +
+

Integrations

+ {error && ( +
+
{error}
+
+ )} +
+
Connect Shopify
+
+

+ Enter your shop domain to connect via OAuth (dev stub). +

+
+ + +
+
+
+
+
Connected Stores
+
+ {connections.length === 0 ? ( +

No stores connected

+ ) : ( +
    + {connections.map((c) => ( +
  • + {c.shop} + + — {c.status}{' '} + {c.lastWebhookAt + ? `(last webhook: ${new Date(c.lastWebhookAt).toLocaleString()})` + : ''} + +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/apps/merchant-dashboard/src/pages/login.tsx b/apps/merchant-dashboard/src/pages/login.tsx index 23715fe..d334726 100644 --- a/apps/merchant-dashboard/src/pages/login.tsx +++ b/apps/merchant-dashboard/src/pages/login.tsx @@ -28,7 +28,8 @@ export default function Login() { input: { email, password }, }); localStorage.setItem('token', data.login.token); - router.push('/orders'); + const next = (router.query.next as string) || '/orders'; + router.push(next); } catch (err: any) { setError(err.message || 'Login failed'); } finally { @@ -37,26 +38,55 @@ export default function Login() { } return ( -
-

Log In

- {error &&

{error}

} -
-
- - setEmail(e.target.value)} type="email" required /> +
+
+
Welcome back
+
+ {error &&

{error}

} + +
+ + setEmail(e.target.value)} + type="email" + required + className="mt-1 w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2" + /> +
+
+ + setPassword(e.target.value)} + type="password" + required + className="mt-1 w-full rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2" + /> +
+ + +

+ New here?{' '} + + Create an account + +

+

+ + Back to Home + +

-
- - setPassword(e.target.value)} type="password" required /> -
- - -

- New here? Create an account -

-

- Back to Home -

-
+
+ ); } diff --git a/apps/merchant-dashboard/src/pages/notifications.tsx b/apps/merchant-dashboard/src/pages/notifications.tsx new file mode 100644 index 0000000..26a1435 --- /dev/null +++ b/apps/merchant-dashboard/src/pages/notifications.tsx @@ -0,0 +1,188 @@ +import { useEffect, useState } from 'react'; +import { gqlAuth } from '../lib/graphql'; + +type Template = { + id: string; + tenantId: string; + name: string; + event: string; + channel: string; + subject?: string; + body: string; +}; +// Preference type reserved for future usage + +export default function Notifications() { + const [templates, setTemplates] = useState([]); + const [event, setEvent] = useState('return.approved'); + const [channel, setChannel] = useState<'email' | 'sms'>('email'); + const [name, setName] = useState('Default'); + const [subject, setSubject] = useState('Your return was approved'); + const [body, setBody] = useState( + 'Hello {{customerName}}, your return {{returnId}} was approved.', + ); + const [prefEmail, setPrefEmail] = useState(true); + const [prefSms, setPrefSms] = useState(false); + const [tenantId, setTenantId] = useState(null); + const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null; + + useEffect(() => { + (async () => { + if (!token) return; + // In this MVP, tenantId is decoded from token if it uses standard claims + try { + const [, jwt] = (token || '').split(' '); + const raw = (token || '').startsWith('Bearer ') ? jwt : token; + const b64 = (raw || '').split('.')[1] || ''; + const json = + typeof window !== 'undefined' ? atob(b64) : Buffer.from(b64, 'base64').toString('utf8'); + const payload = JSON.parse(json || '{}'); + if (payload?.tenantId) setTenantId(payload.tenantId); + } catch (e) { + console.error(e); + } + const query = `query Tpls($event: String, $channel: String) { notificationTemplates(event: $event, channel: $channel) { items { id tenantId name event channel subject body } } }`; + const data = await gqlAuth<{ notificationTemplates: { items: Template[] } }>(query, token!, { + event, + channel, + }); + setTemplates(data.notificationTemplates.items); + })(); + }, [token, channel, event]); + + async function createTemplate(e: React.FormEvent) { + e.preventDefault(); + if (!token || !tenantId) return; + const mutation = `mutation CreateTpl($tenantId: String!, $name: String!, $event: String!, $channel: String!, $body: String!, $subject: String) { createNotificationTemplate(tenantId: $tenantId, name: $name, event: $event, channel: $channel, body: $body, subject: $subject) { id name event channel } }`; + await gqlAuth(mutation, token, { tenantId, name, event, channel, body, subject }); + const query = `query Tpls($event: String, $channel: String) { notificationTemplates(event: $event, channel: $channel) { items { id tenantId name event channel subject body } } }`; + const data = await gqlAuth<{ notificationTemplates: { items: Template[] } }>(query, token!, { + event, + channel, + }); + setTemplates(data.notificationTemplates.items); + } + + async function savePrefs() { + if (!token || !tenantId) return; + const mutation = `mutation UpsertPref($tenantId: String!, $event: String!, $emailEnabled: Boolean, $smsEnabled: Boolean) { upsertNotificationPreference(tenantId: $tenantId, event: $event, emailEnabled: $emailEnabled, smsEnabled: $smsEnabled) { id event emailEnabled smsEnabled } }`; + await gqlAuth(mutation, token, { + tenantId, + event, + emailEnabled: prefEmail, + smsEnabled: prefSms, + }); + alert('Preferences saved'); + } + + return ( +
+

Notifications

+
+
Templates
+
+
+ + + + {channel === 'email' && ( + + )} +