feat(platform): add validated configuration and Prisma database foundation
This commit is contained in:
parent
cce4ccc794
commit
39290c4944
|
|
@ -0,0 +1,4 @@
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=3000
|
||||||
|
DATABASE_URL=postgresql://mani:mani_local@localhost:5432/mani_candles
|
||||||
|
CORS_ORIGINS=http://localhost:3001
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
pnpm-lock.yaml
|
||||||
|
src/generated
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
node_modules
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{ "singleQuote": true, "trailingComma": "all" }
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1' },
|
||||||
|
testEnvironment: 'node',
|
||||||
|
testMatch: ['**/*.spec.ts'],
|
||||||
|
collectCoverageFrom: [
|
||||||
|
'src/**/*.ts',
|
||||||
|
'!src/generated/**',
|
||||||
|
'!src/main.ts',
|
||||||
|
'!src/**/*.module.ts',
|
||||||
|
],
|
||||||
|
coverageThreshold: {
|
||||||
|
global: { branches: 80, functions: 80, lines: 80, statements: 80 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
{
|
||||||
|
"name": "@mani-candles/backend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "pnpm@11.19.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24.15 <25"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"dev": "tsc -p tsconfig.build.json --watch",
|
||||||
|
"test": "jest --runInBand",
|
||||||
|
"test:coverage": "jest --runInBand --coverage",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:validate": "prisma validate",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:deploy": "prisma migrate deploy",
|
||||||
|
"check": "pnpm format:check && pnpm db:validate && pnpm typecheck && pnpm test:coverage && pnpm build",
|
||||||
|
"start:watch": "node --watch dist/main.js",
|
||||||
|
"db:status": "prisma migrate status"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^11.1.0",
|
||||||
|
"@nestjs/core": "^11.1.0",
|
||||||
|
"@nestjs/platform-express": "^11.1.0",
|
||||||
|
"@prisma/adapter-pg": "^7.0.0",
|
||||||
|
"@prisma/client": "^7.0.0",
|
||||||
|
"dotenv": "^17.0.0",
|
||||||
|
"helmet": "^8.0.0",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.2",
|
||||||
|
"zod": "^4.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/testing": "^11.1.0",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/supertest": "^6.0.0",
|
||||||
|
"jest": "^30.0.0",
|
||||||
|
"prettier": "^3.0.0",
|
||||||
|
"prisma": "^7.0.0",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"ts-jest": "^29.4.0",
|
||||||
|
"typescript": "~5.9.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,6 @@
|
||||||
|
allowBuilds:
|
||||||
|
'@parcel/watcher': true
|
||||||
|
'@prisma/engines': true
|
||||||
|
esbuild: true
|
||||||
|
prisma: true
|
||||||
|
unrs-resolver: true
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { defineConfig } from 'prisma/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: 'prisma/schema.prisma',
|
||||||
|
migrations: { path: 'prisma/migrations' },
|
||||||
|
datasource: { url: process.env.DATABASE_URL },
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
CREATE TABLE "organizations" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"name" VARCHAR(160) NOT NULL,
|
||||||
|
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
CONSTRAINT "organizations_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
provider = "postgresql"
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../src/generated/prisma"
|
||||||
|
moduleFormat = "cjs"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
// The owning business. Supplier/customer organizations belong to later modules.
|
||||||
|
model Organization {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
name String @db.VarChar(160)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
|
||||||
|
@@map("organizations")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
|
||||||
|
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is required');
|
||||||
|
const commands = [
|
||||||
|
['prisma', 'migrate', 'deploy'],
|
||||||
|
['prisma', 'migrate', 'status'],
|
||||||
|
[
|
||||||
|
'prisma',
|
||||||
|
'migrate',
|
||||||
|
'diff',
|
||||||
|
'--from-config-datasource',
|
||||||
|
'--to-schema',
|
||||||
|
'prisma/schema.prisma',
|
||||||
|
'--exit-code',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
for (const args of commands) {
|
||||||
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
['node_modules/prisma/build/index.js', ...args.slice(1)],
|
||||||
|
{
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: process.env,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { parseEnvironment } from './environment';
|
||||||
|
|
||||||
|
export const ENVIRONMENT = Symbol('ENVIRONMENT');
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
{ provide: ENVIRONMENT, useFactory: () => parseEnvironment(process.env) },
|
||||||
|
],
|
||||||
|
exports: [ENVIRONMENT],
|
||||||
|
})
|
||||||
|
export class EnvironmentModule {}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
NODE_ENV: z
|
||||||
|
.enum(['development', 'test', 'production'])
|
||||||
|
.default('development'),
|
||||||
|
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
||||||
|
DATABASE_URL: z.url().refine((value) => /^postgres(ql)?:/.test(value)),
|
||||||
|
CORS_ORIGINS: z
|
||||||
|
.string()
|
||||||
|
.default('')
|
||||||
|
.transform((value) =>
|
||||||
|
value
|
||||||
|
.split(',')
|
||||||
|
.map((origin) => origin.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
z.array(
|
||||||
|
z.url().refine((value) => {
|
||||||
|
if (!URL.canParse(value)) return false;
|
||||||
|
const url = new URL(value);
|
||||||
|
return (
|
||||||
|
['http:', 'https:'].includes(url.protocol) && url.origin === value
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Environment = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
export function parseEnvironment(input: NodeJS.ProcessEnv): Environment {
|
||||||
|
const result = schema.safeParse(input);
|
||||||
|
if (!result.success) {
|
||||||
|
const fields = [
|
||||||
|
...new Set(result.error.issues.map((issue) => issue.path.join('.'))),
|
||||||
|
];
|
||||||
|
throw new Error(`Invalid environment configuration: ${fields.join(', ')}`);
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DatabaseService } from './database.service';
|
||||||
|
|
||||||
|
@Module({ providers: [DatabaseService], exports: [DatabaseService] })
|
||||||
|
export class DatabaseModule {}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import {
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
OnModuleDestroy,
|
||||||
|
OnModuleInit,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
import { PrismaClient } from '../generated/prisma/client';
|
||||||
|
import { ENVIRONMENT } from '../config/environment.module';
|
||||||
|
import type { Environment } from '../config/environment';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DatabaseService
|
||||||
|
extends PrismaClient
|
||||||
|
implements OnModuleInit, OnModuleDestroy
|
||||||
|
{
|
||||||
|
constructor(@Inject(ENVIRONMENT) environment: Environment) {
|
||||||
|
super({
|
||||||
|
adapter: new PrismaPg({
|
||||||
|
connectionString: environment.DATABASE_URL,
|
||||||
|
connectionTimeoutMillis: 3000,
|
||||||
|
query_timeout: 3000,
|
||||||
|
max: 10,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
await this.$connect();
|
||||||
|
}
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
await this.$disconnect();
|
||||||
|
}
|
||||||
|
async ping(): Promise<void> {
|
||||||
|
await this.$queryRaw`SELECT 1`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import 'reflect-metadata';
|
||||||
|
import { DatabaseService } from '../src/database/database.service';
|
||||||
|
import { parseEnvironment } from '../src/config/environment';
|
||||||
|
|
||||||
|
describe('database lifecycle', () => {
|
||||||
|
let database: DatabaseService;
|
||||||
|
beforeEach(() => {
|
||||||
|
database = new DatabaseService(
|
||||||
|
parseEnvironment({ DATABASE_URL: 'postgresql://localhost/mani' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('connects during initialization', async () => {
|
||||||
|
const connect = jest.spyOn(database, '$connect').mockResolvedValue();
|
||||||
|
await database.onModuleInit();
|
||||||
|
expect(connect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
it('disconnects during shutdown', async () => {
|
||||||
|
const disconnect = jest.spyOn(database, '$disconnect').mockResolvedValue();
|
||||||
|
await database.onModuleDestroy();
|
||||||
|
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
it('propagates connection failures', async () => {
|
||||||
|
jest
|
||||||
|
.spyOn(database, '$connect')
|
||||||
|
.mockRejectedValue(new Error('unavailable'));
|
||||||
|
await expect(database.onModuleInit()).rejects.toThrow('unavailable');
|
||||||
|
});
|
||||||
|
it('executes a database probe and propagates query failure', async () => {
|
||||||
|
const query = jest
|
||||||
|
.spyOn(database, '$queryRaw')
|
||||||
|
.mockResolvedValue([{ value: 1 }]);
|
||||||
|
await database.ping();
|
||||||
|
expect(query).toHaveBeenCalledTimes(1);
|
||||||
|
query.mockRejectedValue(new Error('timeout'));
|
||||||
|
await expect(database.ping()).rejects.toThrow('timeout');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { parseEnvironment } from '../src/config/environment';
|
||||||
|
|
||||||
|
const valid = { DATABASE_URL: 'postgresql://user:secret@localhost:5432/mani' };
|
||||||
|
|
||||||
|
describe('environment configuration', () => {
|
||||||
|
it('applies defaults and parses explicit configuration', () => {
|
||||||
|
expect(parseEnvironment(valid)).toMatchObject({
|
||||||
|
PORT: 3000,
|
||||||
|
CORS_ORIGINS: [],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
parseEnvironment({
|
||||||
|
...valid,
|
||||||
|
PORT: '4000',
|
||||||
|
CORS_ORIGINS: ' https://shop.example.com ',
|
||||||
|
}),
|
||||||
|
).toMatchObject({ PORT: 4000, CORS_ORIGINS: ['https://shop.example.com'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ DATABASE_URL: undefined },
|
||||||
|
{ DATABASE_URL: 'https://example.com' },
|
||||||
|
{ PORT: '0' },
|
||||||
|
{ PORT: '65536' },
|
||||||
|
{ PORT: '1.5' },
|
||||||
|
{ PORT: 'invalid' },
|
||||||
|
{ NODE_ENV: 'staging' },
|
||||||
|
{ CORS_ORIGINS: '*' },
|
||||||
|
{ CORS_ORIGINS: 'https://shop.example.com/path' },
|
||||||
|
])('rejects invalid configuration %j', (override) => {
|
||||||
|
expect(() => parseEnvironment({ ...valid, ...override })).toThrow(
|
||||||
|
'Invalid environment',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not leak credentials in validation errors', () => {
|
||||||
|
expect(() => parseEnvironment({ DATABASE_URL: 'secret-password' })).toThrow(
|
||||||
|
'DATABASE_URL',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
parseEnvironment({ DATABASE_URL: 'secret-password' });
|
||||||
|
} catch (error) {
|
||||||
|
expect(String(error)).not.toContain('secret-password');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": { "rootDir": "src" },
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["**/*.spec.ts"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"module": "Node16",
|
||||||
|
"moduleResolution": "Node16",
|
||||||
|
"strict": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"sourceMap": true,
|
||||||
|
"isolatedModules": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "test/**/*.ts", "prisma.config.ts"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue