@sundaysf/cli-v3 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +302 -0
- package/dist/cli.js +1327 -0
- package/package.json +55 -0
- package/templates/api/.claude/agents/knex-table-implementer.md +36 -0
- package/templates/api/.claude/agents/sundays-backend-builder.md +32 -0
- package/templates/api/.env.example +25 -0
- package/templates/api/.github/workflows/ci.yaml +45 -0
- package/templates/api/.github/workflows/deploy.yaml +72 -0
- package/templates/api/.prettierignore +5 -0
- package/templates/api/.prettierrc +9 -0
- package/templates/api/.sundaysrc +8 -0
- package/templates/api/CLAUDE.md +81 -0
- package/templates/api/Dockerfile +17 -0
- package/templates/api/README.md +164 -0
- package/templates/api/_dockerignore +8 -0
- package/templates/api/_gitignore +23 -0
- package/templates/api/_package.json +58 -0
- package/templates/api/docker-compose.yml +21 -0
- package/templates/api/eslint.config.js +27 -0
- package/templates/api/jest.config.js +33 -0
- package/templates/api/jest.setup.js +25 -0
- package/templates/api/knexfile.ts +5 -0
- package/templates/api/src/app.ts +48 -0
- package/templates/api/src/common/__tests__/common.test.ts +116 -0
- package/templates/api/src/common/config/env.ts +53 -0
- package/templates/api/src/common/errors/http.error.ts +30 -0
- package/templates/api/src/common/logger/index.ts +25 -0
- package/templates/api/src/common/utils/environment.resolver.ts +7 -0
- package/templates/api/src/common/utils/pagination.ts +25 -0
- package/templates/api/src/common/utils/version.resolver.ts +24 -0
- package/templates/api/src/common/validation/parse-dto.ts +20 -0
- package/templates/api/src/controllers/health/__tests__/health.controller.test.ts +52 -0
- package/templates/api/src/controllers/health/health.controller.ts +26 -0
- package/templates/api/src/db/BaseDAO.ts +92 -0
- package/templates/api/src/db/KnexConnection.ts +59 -0
- package/templates/api/src/db/__tests__/base-dao.test.ts +73 -0
- package/templates/api/src/db/__tests__/index.barrel.test.ts +10 -0
- package/templates/api/src/db/__tests__/knex-connection.test.ts +90 -0
- package/templates/api/src/db/d.types.ts +42 -0
- package/templates/api/src/db/dao/sundays-package-version/sundays-package-version.dao.ts +12 -0
- package/templates/api/src/db/index.ts +17 -0
- package/templates/api/src/db/interfaces/sundays-package-version/sundays-package-version.interfaces.ts +5 -0
- package/templates/api/src/db/knex.config.ts +46 -0
- package/templates/api/src/dto/input/.gitkeep +0 -0
- package/templates/api/src/jobs/.gitkeep +0 -0
- package/templates/api/src/middlewares/error/__tests__/error.middleware.test.ts +117 -0
- package/templates/api/src/middlewares/error/error.middleware.ts +70 -0
- package/templates/api/src/middlewares/not-found/__tests__/not-found.middleware.test.ts +54 -0
- package/templates/api/src/middlewares/not-found/not-found.middleware.ts +51 -0
- package/templates/api/src/middlewares/request-id/__tests__/request-id.middleware.test.ts +31 -0
- package/templates/api/src/middlewares/request-id/request-id.middleware.ts +20 -0
- package/templates/api/src/migrations/20240101000000_create_sundays_package_version.ts +15 -0
- package/templates/api/src/routes/__tests__/index-router.test.ts +61 -0
- package/templates/api/src/routes/health/__tests__/health.routes.test.ts +22 -0
- package/templates/api/src/routes/health/health.router.ts +18 -0
- package/templates/api/src/routes/index.ts +77 -0
- package/templates/api/src/seeds/001_sundays_package_version.ts +14 -0
- package/templates/api/src/server.ts +56 -0
- package/templates/api/src/services/.gitkeep +0 -0
- package/templates/api/tsconfig.json +20 -0
- package/templates/api/tsconfig.spec.json +10 -0
- package/templates/api-auth/overlay.json +95 -0
- package/templates/api-auth/src/controllers/auth/__tests__/auth.controller.test.ts +194 -0
- package/templates/api-auth/src/controllers/auth/auth.controller.ts +109 -0
- package/templates/api-auth/src/db/dao/auth/auth.dao.ts +21 -0
- package/templates/api-auth/src/db/dao/user/user.dao.ts +24 -0
- package/templates/api-auth/src/db/interfaces/auth/auth.interfaces.ts +8 -0
- package/templates/api-auth/src/db/interfaces/user/user.interfaces.ts +11 -0
- package/templates/api-auth/src/dto/input/auth/auth.login.dto.ts +14 -0
- package/templates/api-auth/src/dto/input/auth/auth.register.dto.ts +19 -0
- package/templates/api-auth/src/middlewares/auth/__tests__/auth.middleware.test.ts +52 -0
- package/templates/api-auth/src/middlewares/auth/auth.middleware.ts +56 -0
- package/templates/api-auth/src/migrations/20240101000001_create_user.ts +18 -0
- package/templates/api-auth/src/migrations/20240101000002_create_auth.ts +24 -0
- package/templates/api-auth/src/routes/auth/__tests__/auth.routes.test.ts +83 -0
- package/templates/api-auth/src/routes/auth/auth.router.ts +28 -0
- package/templates/api-auth/src/services/jwt/__tests__/jwt.service.test.ts +32 -0
- package/templates/api-auth/src/services/jwt/jwt.service.ts +36 -0
- package/templates/api-auth/src/services/password/__tests__/password.service.test.ts +13 -0
- package/templates/api-auth/src/services/password/password.service.ts +14 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
services:
|
|
2
|
+
postgres:
|
|
3
|
+
image: postgres:16-alpine
|
|
4
|
+
container_name: __SF_PROJECT_SLUG__-postgres
|
|
5
|
+
restart: unless-stopped
|
|
6
|
+
environment:
|
|
7
|
+
POSTGRES_USER: postgres
|
|
8
|
+
POSTGRES_PASSWORD: postgres
|
|
9
|
+
POSTGRES_DB: __SF_DB_NAME__
|
|
10
|
+
ports:
|
|
11
|
+
- '5432:5432'
|
|
12
|
+
volumes:
|
|
13
|
+
- postgres-data:/var/lib/postgresql/data
|
|
14
|
+
healthcheck:
|
|
15
|
+
test: ['CMD-SHELL', 'pg_isready -U postgres -d __SF_DB_NAME__']
|
|
16
|
+
interval: 5s
|
|
17
|
+
timeout: 3s
|
|
18
|
+
retries: 10
|
|
19
|
+
|
|
20
|
+
volumes:
|
|
21
|
+
postgres-data:
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const eslint = require('@eslint/js');
|
|
2
|
+
const tseslint = require('typescript-eslint');
|
|
3
|
+
const globals = require('globals');
|
|
4
|
+
|
|
5
|
+
module.exports = tseslint.config(
|
|
6
|
+
eslint.configs.recommended,
|
|
7
|
+
...tseslint.configs.recommended,
|
|
8
|
+
{
|
|
9
|
+
languageOptions: {
|
|
10
|
+
globals: { ...globals.node, ...globals.jest },
|
|
11
|
+
},
|
|
12
|
+
rules: {
|
|
13
|
+
'@typescript-eslint/no-unused-vars': [
|
|
14
|
+
'error',
|
|
15
|
+
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
// Plain CommonJS config files (this one, jest.*.js) legitimately use require().
|
|
21
|
+
files: ['**/*.js', '**/*.cjs'],
|
|
22
|
+
rules: { '@typescript-eslint/no-require-imports': 'off' },
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
ignores: ['dist/', 'node_modules/', 'coverage/'],
|
|
26
|
+
}
|
|
27
|
+
);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
|
2
|
+
module.exports = {
|
|
3
|
+
preset: 'ts-jest',
|
|
4
|
+
testEnvironment: 'node',
|
|
5
|
+
verbose: true,
|
|
6
|
+
// Tests live next to the code they cover, in __tests__/ folders.
|
|
7
|
+
roots: ['<rootDir>/src'],
|
|
8
|
+
testMatch: ['**/__tests__/**/*.test.ts'],
|
|
9
|
+
transform: {
|
|
10
|
+
'^.+\\.ts$': ['ts-jest', { tsconfig: 'tsconfig.spec.json' }],
|
|
11
|
+
},
|
|
12
|
+
// Loads .env, applies test defaults and refuses to run against a remote database.
|
|
13
|
+
setupFiles: ['<rootDir>/jest.setup.js'],
|
|
14
|
+
testTimeout: 30000,
|
|
15
|
+
// Route tests share one Postgres pool: run suites serially to keep them deterministic.
|
|
16
|
+
maxWorkers: 1,
|
|
17
|
+
collectCoverage: true,
|
|
18
|
+
collectCoverageFrom: ['src/**/*.ts'],
|
|
19
|
+
coveragePathIgnorePatterns: [
|
|
20
|
+
'/node_modules/',
|
|
21
|
+
'<rootDir>/src/server.ts',
|
|
22
|
+
'<rootDir>/src/migrations/',
|
|
23
|
+
'<rootDir>/src/seeds/',
|
|
24
|
+
'\\.d\\.ts$',
|
|
25
|
+
'\\.interfaces\\.ts$',
|
|
26
|
+
'/__tests__/',
|
|
27
|
+
],
|
|
28
|
+
coverageReporters: ['text-summary', 'text'],
|
|
29
|
+
// Raise these as the project matures (the reference project runs at 100%).
|
|
30
|
+
coverageThreshold: {
|
|
31
|
+
global: { statements: 80, branches: 80, functions: 80, lines: 80 },
|
|
32
|
+
},
|
|
33
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Runs before every test file (jest.config.js -> setupFiles).
|
|
2
|
+
require('dotenv').config({ quiet: true });
|
|
3
|
+
|
|
4
|
+
// Test-only defaults. Real values from .env win for the SQL_* group so route tests
|
|
5
|
+
// can hit the local Postgres started with `docker compose up -d`.
|
|
6
|
+
process.env.NODE_ENV = 'test';
|
|
7
|
+
process.env.LOG_LEVEL = 'silent';
|
|
8
|
+
process.env.RUN_MIGRATIONS = 'false';
|
|
9
|
+
process.env.SQL_HOST = process.env.SQL_HOST || 'localhost';
|
|
10
|
+
process.env.SQL_PORT = process.env.SQL_PORT || '5432';
|
|
11
|
+
process.env.SQL_USER = process.env.SQL_USER || 'postgres';
|
|
12
|
+
process.env.SQL_PASSWORD = process.env.SQL_PASSWORD || 'postgres';
|
|
13
|
+
process.env.SQL_DB_NAME = process.env.SQL_DB_NAME || '__SF_DB_NAME__';
|
|
14
|
+
|
|
15
|
+
// GUARD: route tests WRITE to the database. If .env points at a remote host (staging,
|
|
16
|
+
// production...), abort before touching anything. Opt in explicitly when intended:
|
|
17
|
+
// ALLOW_REMOTE_DB_TESTS=1 npm test
|
|
18
|
+
const sqlHost = process.env.SQL_HOST;
|
|
19
|
+
const isLocalDb = sqlHost === 'localhost' || sqlHost === '127.0.0.1';
|
|
20
|
+
if (!isLocalDb && process.env.ALLOW_REMOTE_DB_TESTS !== '1') {
|
|
21
|
+
throw new Error(
|
|
22
|
+
`SQL_HOST="${sqlHost}" is not local: tests write to the database. ` +
|
|
23
|
+
'Point SQL_* at localhost, or export ALLOW_REMOTE_DB_TESTS=1 if this is intentional.'
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Used only by the knex CLI (npm run db:*). The app itself builds the same config through
|
|
2
|
+
// src/db/knex.config.ts, so there is exactly one place where connection settings live.
|
|
3
|
+
import { buildKnexConfig } from './src/db/knex.config';
|
|
4
|
+
|
|
5
|
+
export default buildKnexConfig();
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import cors from 'cors';
|
|
2
|
+
import express, { type Express } from 'express';
|
|
3
|
+
import helmet from 'helmet';
|
|
4
|
+
import { pinoHttp } from 'pino-http';
|
|
5
|
+
import { env } from './common/config/env';
|
|
6
|
+
import { logger } from './common/logger';
|
|
7
|
+
import { errorMiddleware } from './middlewares/error/error.middleware';
|
|
8
|
+
import { notFoundMiddleware } from './middlewares/not-found/not-found.middleware';
|
|
9
|
+
import { requestIdMiddleware } from './middlewares/request-id/request-id.middleware';
|
|
10
|
+
import { IndexRouter } from './routes';
|
|
11
|
+
|
|
12
|
+
const app: Express = express();
|
|
13
|
+
|
|
14
|
+
app.disable('x-powered-by');
|
|
15
|
+
app.set('trust proxy', true);
|
|
16
|
+
|
|
17
|
+
app.use(helmet());
|
|
18
|
+
app.use(
|
|
19
|
+
cors({
|
|
20
|
+
origin:
|
|
21
|
+
env.CORS_ORIGINS === '*'
|
|
22
|
+
? true
|
|
23
|
+
: env.CORS_ORIGINS.split(',').map((o) => o.trim()),
|
|
24
|
+
})
|
|
25
|
+
);
|
|
26
|
+
app.use(requestIdMiddleware);
|
|
27
|
+
app.use(
|
|
28
|
+
pinoHttp({
|
|
29
|
+
logger,
|
|
30
|
+
genReqId: (req) => req.id,
|
|
31
|
+
autoLogging: { ignore: (req) => (req.url ?? '').startsWith('/api/health') },
|
|
32
|
+
})
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// Webhooks that need the RAW body for signature verification (Stripe, etc.) must be
|
|
36
|
+
// mounted here, BEFORE the JSON parser:
|
|
37
|
+
// app.post('/api/stripe/webhook', express.raw({ type: 'application/json' }), handler);
|
|
38
|
+
|
|
39
|
+
app.use(express.urlencoded({ extended: true }));
|
|
40
|
+
app.use(express.json());
|
|
41
|
+
|
|
42
|
+
app.use('/api', new IndexRouter().router);
|
|
43
|
+
|
|
44
|
+
// Order matters: 404 after every router, error handler last.
|
|
45
|
+
app.use(notFoundMiddleware);
|
|
46
|
+
app.use(errorMiddleware);
|
|
47
|
+
|
|
48
|
+
export default app;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { loadEnv } from '../config/env';
|
|
3
|
+
import {
|
|
4
|
+
HttpError,
|
|
5
|
+
badRequest,
|
|
6
|
+
conflict,
|
|
7
|
+
forbidden,
|
|
8
|
+
isHttpError,
|
|
9
|
+
notFound,
|
|
10
|
+
unauthorized,
|
|
11
|
+
} from '../errors/http.error';
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_LIMIT,
|
|
14
|
+
DEFAULT_PAGE,
|
|
15
|
+
MAX_LIMIT,
|
|
16
|
+
getPagination,
|
|
17
|
+
} from '../utils/pagination';
|
|
18
|
+
import { parseDto } from '../validation/parse-dto';
|
|
19
|
+
import { getServiceVersion } from '../utils/version.resolver';
|
|
20
|
+
import { getServiceEnvironment } from '../utils/environment.resolver';
|
|
21
|
+
|
|
22
|
+
/** The real env minus the keys under test, so extra required vars (e.g. JWT_SECRET) stay valid. */
|
|
23
|
+
const envWithout = (...keys: string[]): NodeJS.ProcessEnv => {
|
|
24
|
+
const e: NodeJS.ProcessEnv = { ...process.env };
|
|
25
|
+
for (const k of keys) delete e[k];
|
|
26
|
+
return e;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
describe('env', () => {
|
|
30
|
+
it('applies defaults and coerces types', () => {
|
|
31
|
+
const e = loadEnv({
|
|
32
|
+
...envWithout('NODE_ENV', 'SQL_PORT', 'SQL_REJECT_UNAUTHORIZED'),
|
|
33
|
+
PORT: '4000',
|
|
34
|
+
RUN_MIGRATIONS: 'true',
|
|
35
|
+
});
|
|
36
|
+
expect(e.PORT).toBe(4000);
|
|
37
|
+
expect(e.RUN_MIGRATIONS).toBe(true);
|
|
38
|
+
expect(e.SQL_REJECT_UNAUTHORIZED).toBe(true);
|
|
39
|
+
expect(e.SQL_PORT).toBe(5432);
|
|
40
|
+
expect(e.NODE_ENV).toBe('development');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('fails fast with a readable message', () => {
|
|
44
|
+
expect(() => loadEnv({ SQL_USER: 'u' })).toThrow(
|
|
45
|
+
/Invalid environment configuration/
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('HttpError helpers', () => {
|
|
51
|
+
it.each([
|
|
52
|
+
[badRequest(), 400],
|
|
53
|
+
[unauthorized(), 401],
|
|
54
|
+
[forbidden(), 403],
|
|
55
|
+
[notFound(), 404],
|
|
56
|
+
[conflict(), 409],
|
|
57
|
+
])('%s has status %i', (err, status) => {
|
|
58
|
+
expect(err).toBeInstanceOf(HttpError);
|
|
59
|
+
expect(err.statusCode).toBe(status);
|
|
60
|
+
expect(isHttpError(err)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('recognises non HttpErrors', () => {
|
|
64
|
+
expect(isHttpError(new Error('x'))).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('getPagination', () => {
|
|
69
|
+
it('falls back to defaults', () => {
|
|
70
|
+
expect(getPagination(undefined)).toEqual({
|
|
71
|
+
page: DEFAULT_PAGE,
|
|
72
|
+
limit: DEFAULT_LIMIT,
|
|
73
|
+
});
|
|
74
|
+
expect(getPagination({ page: 'x', limit: '-1' })).toEqual({
|
|
75
|
+
page: DEFAULT_PAGE,
|
|
76
|
+
limit: DEFAULT_LIMIT,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
it('parses and caps values', () => {
|
|
80
|
+
expect(getPagination({ page: '3', limit: '25' })).toEqual({
|
|
81
|
+
page: 3,
|
|
82
|
+
limit: 25,
|
|
83
|
+
});
|
|
84
|
+
expect(getPagination({ limit: '9999' }).limit).toBe(MAX_LIMIT);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('parseDto', () => {
|
|
89
|
+
const schema = z.object({ name: z.string().min(1) }).strict();
|
|
90
|
+
|
|
91
|
+
it('returns typed data', () => {
|
|
92
|
+
expect(parseDto(schema, { name: 'ok' })).toEqual({ name: 'ok' });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('throws HttpError(400) with field errors, also for undefined input', () => {
|
|
96
|
+
try {
|
|
97
|
+
parseDto(schema, undefined);
|
|
98
|
+
fail('should throw');
|
|
99
|
+
} catch (err) {
|
|
100
|
+
expect(isHttpError(err)).toBe(true);
|
|
101
|
+
const e = err as HttpError;
|
|
102
|
+
expect(e.statusCode).toBe(400);
|
|
103
|
+
expect(e.details).toHaveProperty('name');
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('resolvers', () => {
|
|
109
|
+
it('reads the package version', () => {
|
|
110
|
+
expect(getServiceVersion()).toMatch(/^\d+\.\d+\.\d+/);
|
|
111
|
+
expect(getServiceVersion()).toBe(getServiceVersion());
|
|
112
|
+
});
|
|
113
|
+
it('capitalizes the environment', () => {
|
|
114
|
+
expect(getServiceEnvironment()).toBe('Test');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import dotenv from 'dotenv';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
dotenv.config({ quiet: true });
|
|
5
|
+
|
|
6
|
+
const LOG_LEVELS = [
|
|
7
|
+
'fatal',
|
|
8
|
+
'error',
|
|
9
|
+
'warn',
|
|
10
|
+
'info',
|
|
11
|
+
'debug',
|
|
12
|
+
'trace',
|
|
13
|
+
'silent',
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Every environment variable the app reads, validated once at boot. Add new variables
|
|
18
|
+
* here (with a default when sensible) so a misconfigured deploy fails fast and loudly.
|
|
19
|
+
*/
|
|
20
|
+
export const EnvSchema = z.object({
|
|
21
|
+
NODE_ENV: z
|
|
22
|
+
.enum(['development', 'test', 'production'])
|
|
23
|
+
.default('development'),
|
|
24
|
+
PORT: z.coerce.number().int().positive().default(__SF_PORT__),
|
|
25
|
+
LOG_LEVEL: z.enum(LOG_LEVELS).default('info'),
|
|
26
|
+
CORS_ORIGINS: z.string().default('*'),
|
|
27
|
+
|
|
28
|
+
SQL_HOST: z.string().default('localhost'),
|
|
29
|
+
SQL_PORT: z.coerce.number().int().positive().default(5432),
|
|
30
|
+
SQL_USER: z.string().min(1),
|
|
31
|
+
SQL_PASSWORD: z.string(),
|
|
32
|
+
SQL_DB_NAME: z.string().min(1),
|
|
33
|
+
SQL_REJECT_UNAUTHORIZED: z.stringbool().default(true),
|
|
34
|
+
RUN_MIGRATIONS: z.stringbool().default(false),
|
|
35
|
+
// @sundays:env-schema
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export type Env = z.infer<typeof EnvSchema>;
|
|
39
|
+
|
|
40
|
+
export const loadEnv = (source: NodeJS.ProcessEnv = process.env): Env => {
|
|
41
|
+
const result = EnvSchema.safeParse(source);
|
|
42
|
+
if (!result.success) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Invalid environment configuration:\n${z.prettifyError(result.error)}`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return result.data;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const env: Env = loadEnv();
|
|
51
|
+
|
|
52
|
+
export const isProduction = (): boolean => env.NODE_ENV === 'production';
|
|
53
|
+
export const isTest = (): boolean => env.NODE_ENV === 'test';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error with an HTTP status. Throw it (or `next()` it) from anywhere in the request
|
|
3
|
+
* pipeline and the error middleware renders `{ success: false, message, errors? }`.
|
|
4
|
+
*/
|
|
5
|
+
export class HttpError extends Error {
|
|
6
|
+
constructor(
|
|
7
|
+
public readonly statusCode: number,
|
|
8
|
+
message: string,
|
|
9
|
+
public readonly details?: unknown
|
|
10
|
+
) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'HttpError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const badRequest = (
|
|
17
|
+
message = 'Bad request',
|
|
18
|
+
details?: unknown
|
|
19
|
+
): HttpError => new HttpError(400, message, details);
|
|
20
|
+
export const unauthorized = (message = 'Unauthorized'): HttpError =>
|
|
21
|
+
new HttpError(401, message);
|
|
22
|
+
export const forbidden = (message = 'Forbidden'): HttpError =>
|
|
23
|
+
new HttpError(403, message);
|
|
24
|
+
export const notFound = (message = 'Not found'): HttpError =>
|
|
25
|
+
new HttpError(404, message);
|
|
26
|
+
export const conflict = (message = 'Conflict'): HttpError =>
|
|
27
|
+
new HttpError(409, message);
|
|
28
|
+
|
|
29
|
+
export const isHttpError = (err: unknown): err is HttpError =>
|
|
30
|
+
err instanceof HttpError;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import pino, { type Logger } from 'pino';
|
|
2
|
+
import { env } from '../config/env';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Application logger. JSON in production (ship it to your log collector), pretty-printed
|
|
6
|
+
* elsewhere. Use `logger.child({ module: 'x' })` for per-module context, and `req.log`
|
|
7
|
+
* inside request handlers (pino-http attaches it with the request id).
|
|
8
|
+
*/
|
|
9
|
+
export const logger: Logger = pino({
|
|
10
|
+
level: env.LOG_LEVEL,
|
|
11
|
+
...(env.NODE_ENV === 'development'
|
|
12
|
+
? {
|
|
13
|
+
transport: {
|
|
14
|
+
target: 'pino-pretty',
|
|
15
|
+
options: {
|
|
16
|
+
colorize: true,
|
|
17
|
+
translateTime: 'HH:MM:ss',
|
|
18
|
+
ignore: 'pid,hostname',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
: {}),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export type { Logger };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface IPaginationParams {
|
|
2
|
+
page: number;
|
|
3
|
+
limit: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_PAGE = 1;
|
|
7
|
+
export const DEFAULT_LIMIT = 10;
|
|
8
|
+
export const MAX_LIMIT = 100;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Reads `?page=&limit=` from a query object. Missing, non numeric or < 1 values fall back
|
|
12
|
+
* to the defaults; `limit` is capped at MAX_LIMIT.
|
|
13
|
+
*/
|
|
14
|
+
export const getPagination = (
|
|
15
|
+
query: Record<string, unknown> | undefined
|
|
16
|
+
): IPaginationParams => {
|
|
17
|
+
const rawPage = parseInt(String(query?.page ?? ''), 10);
|
|
18
|
+
const rawLimit = parseInt(String(query?.limit ?? ''), 10);
|
|
19
|
+
const page = Number.isNaN(rawPage) || rawPage < 1 ? DEFAULT_PAGE : rawPage;
|
|
20
|
+
const limit =
|
|
21
|
+
Number.isNaN(rawLimit) || rawLimit < 1
|
|
22
|
+
? DEFAULT_LIMIT
|
|
23
|
+
: Math.min(rawLimit, MAX_LIMIT);
|
|
24
|
+
return { page, limit };
|
|
25
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
|
|
4
|
+
let cached: string | null = null;
|
|
5
|
+
|
|
6
|
+
/** Version from package.json (works from src/ under tsx and from dist/ once built). */
|
|
7
|
+
export const getServiceVersion = (): string => {
|
|
8
|
+
if (cached) return cached;
|
|
9
|
+
const candidates = [
|
|
10
|
+
path.resolve(__dirname, '../../../package.json'),
|
|
11
|
+
path.resolve(process.cwd(), 'package.json'),
|
|
12
|
+
];
|
|
13
|
+
for (const file of candidates) {
|
|
14
|
+
if (fs.existsSync(file)) {
|
|
15
|
+
const pkg = JSON.parse(fs.readFileSync(file, 'utf8')) as {
|
|
16
|
+
version?: string;
|
|
17
|
+
};
|
|
18
|
+
cached = pkg.version ?? '0.0.0';
|
|
19
|
+
return cached;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
cached = '0.0.0';
|
|
23
|
+
return cached;
|
|
24
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { HttpError } from '../errors/http.error';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validates `input` against a zod schema and returns the typed, sanitized value.
|
|
6
|
+
* On failure throws HttpError(400) whose `details` is `{ field: [messages] }`, rendered by
|
|
7
|
+
* the error middleware as `{ success: false, message: 'Validation failed', errors: {...} }`.
|
|
8
|
+
* Accepts `undefined` (Express 5 leaves req.body undefined when no parser matched).
|
|
9
|
+
*/
|
|
10
|
+
export const parseDto = <T>(schema: z.ZodType<T>, input: unknown): T => {
|
|
11
|
+
const result = schema.safeParse(input ?? {});
|
|
12
|
+
if (!result.success) {
|
|
13
|
+
throw new HttpError(
|
|
14
|
+
400,
|
|
15
|
+
'Validation failed',
|
|
16
|
+
z.flattenError(result.error).fieldErrors
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
return result.data;
|
|
20
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { NextFunction, Request, Response } from 'express';
|
|
2
|
+
import { HealthController } from '../health.controller';
|
|
3
|
+
|
|
4
|
+
jest.mock('../../../common/utils/version.resolver', () => ({
|
|
5
|
+
getServiceVersion: jest.fn(() => '9.9.9'),
|
|
6
|
+
}));
|
|
7
|
+
jest.mock('../../../common/utils/environment.resolver', () => ({
|
|
8
|
+
getServiceEnvironment: jest.fn(() => 'Test'),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
/** Minimal chainable Response double, reused across controller unit tests. */
|
|
12
|
+
export const mockRes = (): Response => {
|
|
13
|
+
const res: Partial<Response> = {};
|
|
14
|
+
res.status = jest.fn().mockReturnValue(res);
|
|
15
|
+
res.json = jest.fn().mockReturnValue(res);
|
|
16
|
+
return res as Response;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe('HealthController', () => {
|
|
20
|
+
it('responds with status, version and environment', async () => {
|
|
21
|
+
const controller = new HealthController();
|
|
22
|
+
const res = mockRes();
|
|
23
|
+
const next = jest.fn() as NextFunction;
|
|
24
|
+
|
|
25
|
+
await controller.getHealthStatus({} as Request, res, next);
|
|
26
|
+
|
|
27
|
+
expect(res.status).toHaveBeenCalledWith(200);
|
|
28
|
+
expect(res.json).toHaveBeenCalledWith(
|
|
29
|
+
expect.objectContaining({
|
|
30
|
+
success: true,
|
|
31
|
+
health: 'Up!',
|
|
32
|
+
version: '9.9.9',
|
|
33
|
+
environment: 'Test',
|
|
34
|
+
database: 'disconnected',
|
|
35
|
+
})
|
|
36
|
+
);
|
|
37
|
+
expect(next).not.toHaveBeenCalled();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('forwards unexpected errors to next()', async () => {
|
|
41
|
+
const controller = new HealthController();
|
|
42
|
+
const res = mockRes();
|
|
43
|
+
(res.status as jest.Mock).mockImplementation(() => {
|
|
44
|
+
throw new Error('boom');
|
|
45
|
+
});
|
|
46
|
+
const next = jest.fn() as NextFunction;
|
|
47
|
+
|
|
48
|
+
await controller.getHealthStatus({} as Request, res, next);
|
|
49
|
+
|
|
50
|
+
expect(next).toHaveBeenCalledWith(expect.any(Error));
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { NextFunction, Request, Response } from 'express';
|
|
2
|
+
import { getServiceEnvironment } from '../../common/utils/environment.resolver';
|
|
3
|
+
import { getServiceVersion } from '../../common/utils/version.resolver';
|
|
4
|
+
import KnexManager from '../../db/KnexConnection';
|
|
5
|
+
|
|
6
|
+
export class HealthController {
|
|
7
|
+
/** GET /api/health */
|
|
8
|
+
public async getHealthStatus(
|
|
9
|
+
_req: Request,
|
|
10
|
+
res: Response,
|
|
11
|
+
next: NextFunction
|
|
12
|
+
): Promise<void> {
|
|
13
|
+
try {
|
|
14
|
+
res.status(200).json({
|
|
15
|
+
success: true,
|
|
16
|
+
health: 'Up!',
|
|
17
|
+
version: getServiceVersion(),
|
|
18
|
+
environment: getServiceEnvironment(),
|
|
19
|
+
database: KnexManager.isConnected() ? 'connected' : 'disconnected',
|
|
20
|
+
uptime: Math.round(process.uptime()),
|
|
21
|
+
});
|
|
22
|
+
} catch (err) {
|
|
23
|
+
next(err);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import type { Knex } from 'knex';
|
|
3
|
+
import KnexManager from './KnexConnection';
|
|
4
|
+
import type { CreateInput, IBaseDAO, IDataPaginator, IEntity } from './d.types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Generic CRUD over one table. Subclasses only declare the table name and add their own
|
|
8
|
+
* finders:
|
|
9
|
+
*
|
|
10
|
+
* export class ProductDAO extends BaseDAO<IProduct> {
|
|
11
|
+
* protected readonly table = 'product';
|
|
12
|
+
* getBySlug(slug: string) { return this.q().where({ slug }).first(); }
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* Every method accepts an optional transaction so multi-table writes can share one:
|
|
16
|
+
* await knex.transaction(async (trx) => { await dao.create(x, trx); ... });
|
|
17
|
+
*/
|
|
18
|
+
export abstract class BaseDAO<T extends IEntity> implements IBaseDAO<T> {
|
|
19
|
+
protected abstract readonly table: string;
|
|
20
|
+
|
|
21
|
+
/** Lazy: the DAO can be instantiated before KnexManager.connect() runs. */
|
|
22
|
+
protected get _knex(): Knex {
|
|
23
|
+
return KnexManager.getConnection();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Query builder on this table, bound to `trx` when given. */
|
|
27
|
+
protected q(trx?: Knex.Transaction): Knex.QueryBuilder {
|
|
28
|
+
return (trx ?? this._knex)(this.table);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async create(item: CreateInput<T>, trx?: Knex.Transaction): Promise<T> {
|
|
32
|
+
const [row] = await this.q(trx)
|
|
33
|
+
.insert({ ...item, uuid: randomUUID() })
|
|
34
|
+
.returning('*');
|
|
35
|
+
return row as T;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async getById(id: number, trx?: Knex.Transaction): Promise<T | null> {
|
|
39
|
+
const row = await this.q(trx).where({ id }).first();
|
|
40
|
+
return (row as T | undefined) ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async getByUuid(uuid: string, trx?: Knex.Transaction): Promise<T | null> {
|
|
44
|
+
const row = await this.q(trx).where({ uuid }).first();
|
|
45
|
+
return (row as T | undefined) ?? null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async update(
|
|
49
|
+
id: number,
|
|
50
|
+
item: Partial<T>,
|
|
51
|
+
trx?: Knex.Transaction
|
|
52
|
+
): Promise<T | null> {
|
|
53
|
+
const [row] = await this.q(trx)
|
|
54
|
+
.where({ id })
|
|
55
|
+
.update({ ...item, updatedAt: this._knex.fn.now() })
|
|
56
|
+
.returning('*');
|
|
57
|
+
return (row as T | undefined) ?? null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async delete(id: number, trx?: Knex.Transaction): Promise<boolean> {
|
|
61
|
+
const deleted = await this.q(trx).where({ id }).delete();
|
|
62
|
+
return deleted > 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async getAll(
|
|
66
|
+
page: number,
|
|
67
|
+
limit: number,
|
|
68
|
+
trx?: Knex.Transaction
|
|
69
|
+
): Promise<IDataPaginator<T>> {
|
|
70
|
+
const safePage = Math.max(page, 1);
|
|
71
|
+
const safeLimit = Math.max(limit, 1);
|
|
72
|
+
const offset = (safePage - 1) * safeLimit;
|
|
73
|
+
|
|
74
|
+
const [countRow] = await this.q(trx).count('* as count');
|
|
75
|
+
const totalCount = Number((countRow as { count: string | number }).count);
|
|
76
|
+
const data = (await this.q(trx)
|
|
77
|
+
.select('*')
|
|
78
|
+
.orderBy('id', 'desc')
|
|
79
|
+
.limit(safeLimit)
|
|
80
|
+
.offset(offset)) as T[];
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
success: true,
|
|
84
|
+
data,
|
|
85
|
+
page: safePage,
|
|
86
|
+
limit: safeLimit,
|
|
87
|
+
count: data.length,
|
|
88
|
+
totalCount,
|
|
89
|
+
totalPages: Math.ceil(totalCount / safeLimit),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|