Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
};

4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: ci

on:
push:
branches: [ main ]
branches: [main]
pull_request:
branches: [ main ]
branches: [main]

jobs:
build:
Expand Down
7 changes: 3 additions & 4 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: codeql

on:
push:
branches: [ main ]
branches: [main]
pull_request:
branches: [ main ]
branches: [main]
schedule:
- cron: '0 8 * * 1'

Expand All @@ -19,7 +19,7 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
language: ['javascript']
steps:
- name: Checkout repository
uses: actions/checkout@v4
Expand All @@ -33,4 +33,3 @@ jobs:
uses: github/codeql-action/analyze@v3
with:
category: '/language:${{matrix.language}}'

2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ coverage
services/*/src/generated/
/.env
**/.env
**/.env.local
**/.env.*.local
*.log
.DS_Store
.idea
Expand Down
1 change: 0 additions & 1 deletion .prettierrc
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,3 @@
"printWidth": 100,
"semi": true
}

1 change: 0 additions & 1 deletion .vscode/extensions.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,3 @@
"GraphQL.vscode-graphql"
]
}

1 change: 0 additions & 1 deletion apps/api-gateway/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,3 @@ const config: Config = {
};

export default config;

6 changes: 5 additions & 1 deletion apps/api-gateway/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
50 changes: 50 additions & 0 deletions apps/api-gateway/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 <prefix.secret> 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}`);
}
Expand Down
103 changes: 103 additions & 0 deletions apps/api-gateway/src/modules/analytics.controller.ts
Original file line number Diff line number Diff line change
@@ -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());
}
}
Loading
Loading