Skip to content
Closed
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
10 changes: 10 additions & 0 deletions meridian-api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,13 @@ POSTGRES_PASSWORD=password
POSTGRES_DB=meridian
POSTGRES_SYNC=true
POSTGRES_LOAD=true

# Cache Configuration
CACHE_TTL=300
CACHE_MAX_ITEMS=100

# Redis Configuration (optional, for production caching)
REDIS_URL=
REDIS_HOST=
REDIS_PORT=6379
REDIS_PASSWORD=
12 changes: 5 additions & 7 deletions meridian-api/nest-cli.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"assets" : [{
"include": "./mail/template",
"OutDir" : "dist/mail/template",
"watchAssets" : true
}]
"assets": [{
"include": "./mail/template",
"outDir": "dist/mail/template",
"watchAssets": true
}]
}

}
21,987 changes: 14,026 additions & 7,961 deletions meridian-api/package-lock.json

Large diffs are not rendered by default.

22 changes: 12 additions & 10 deletions meridian-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,22 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-s3": "3.787.0",
"@nestjs-modules/mailer": "^2.0.2",
"@aws-sdk/client-s3": "^3.1075.0",
"@nestjs-modules/mailer": "^2.0.1",
"@nestjs/cache-manager": "^3.1.3",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.0.0",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.27",
"@nestjs/jwt": "^10.2.0",
"@nestjs/mapped-types": "^2.0.6",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/swagger": "^8.1.0",
"@nestjs/platform-express": "^11.1.27",
"@nestjs/swagger": "^11.4.4",
"@nestjs/terminus": "^11.1.1",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^10.0.2",
"@nestjs/typeorm": "^11.0.2",
"@willsoto/nestjs-prometheus": "^6.1.0",
"bcrypt": "^5.1.1",
"cache-manager-redis": "^0.6.0",
"class-transformer": "^0.5.1",
"joi": "^17.13.3",
"class-validator": "^0.14.1",
Expand All @@ -47,8 +49,8 @@
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^11.1.27",
"@types/bcrypt": "^5.0.2",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.2",
Expand All @@ -60,7 +62,7 @@
"eslint": "^8.0.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"jest": "^25.0.0",
"prettier": "^3.0.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
Expand Down
2 changes: 2 additions & 0 deletions meridian-api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { TweetModule } from './tweets/tweet.module';
import { UploadModule } from './upload/upload.module';
import { HealthModule } from './health/health.module';
import { PrometheusModule } from '@willsoto/nestjs-prometheus';
import { CacheConfigModule } from './cache/cache.module';
import { AuditModule } from './audit/audit.module';

@Module({
Expand Down Expand Up @@ -93,6 +94,7 @@ import { AuditModule } from './audit/audit.module';
ConfigModule.forFeature(jwtConfig),
JwtModule.registerAsync(jwtConfig.asProvider()),

CacheConfigModule,
UsersModule,
PostModule,
TagModule,
Expand Down
4 changes: 1 addition & 3 deletions meridian-api/src/auth/providers/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ jest.mock('src/users/user.entity', () => ({ User: class User {} }), {
virtual: true,
});

import {
UnauthorizedException,
} from '@nestjs/common';
import { UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';

describe('AuthService - email verification (issue #435)', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
export const VERIFICATION_TTL_MS: number =
(() => {
const hours = Number(process.env.VERIFICATION_TOKEN_TTL_HOURS);
return Number.isFinite(hours) && hours > 0
? hours
: 24;
})() * 60 * 60 * 1000;
return Number.isFinite(hours) && hours > 0 ? hours : 24;
})() *
60 *
60 *
1000;
6 changes: 1 addition & 5 deletions meridian-api/src/auth/providers/verify-email.provider.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import {
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, MoreThan, Not, Repository } from 'typeorm';
import { User } from 'src/users/user.entity';
Expand Down
48 changes: 48 additions & 0 deletions meridian-api/src/cache/cache-invalidation.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common';
import { Inject } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';

@Injectable()
export class CacheInvalidationService {
private postCacheKeys: Set<string> = new Set();
private userCacheKeys: Set<string> = new Set();

constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}

trackPostCacheKey(key: string) {
this.postCacheKeys.add(key);
}

trackUserCacheKey(key: string) {
this.userCacheKeys.add(key);
}

async invalidatePostCache() {
const keys = Array.from(this.postCacheKeys);
await Promise.all(keys.map((key) => this.cacheManager.del(key)));
this.postCacheKeys.clear();
}

async invalidateUserCache() {
const keys = Array.from(this.userCacheKeys);
await Promise.all(keys.map((key) => this.cacheManager.del(key)));
this.userCacheKeys.clear();
}

async del(key: string) {
await this.cacheManager.del(key);
}

async get<T>(key: string): Promise<T | undefined> {
return this.cacheManager.get<T>(key);
}

async set(key: string, value: any, ttl?: number) {
if (ttl) {
await this.cacheManager.set(key, value, ttl);
} else {
await this.cacheManager.set(key, value);
}
}
}
38 changes: 38 additions & 0 deletions meridian-api/src/cache/cache.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Module, Global } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { CacheModule } from '@nestjs/cache-manager';
import { redisStore } from 'cache-manager-redis';
import { CacheInvalidationService } from './cache-invalidation.service';

@Global()
@Module({
imports: [
CacheModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
isGlobal: true,
useFactory: async (configService: ConfigService) => {
const redisUrl = configService.get<string>('REDIS_URL');
const redisHost = configService.get<string>('REDIS_HOST');

if (redisUrl || redisHost) {
return {
store: redisStore as any,
host: redisHost || undefined,
port: configService.get<number>('REDIS_PORT') || 6379,
password: configService.get<string>('REDIS_PASSWORD') || undefined,
ttl: configService.get<number>('CACHE_TTL') || 300,
};
}

return {
ttl: configService.get<number>('CACHE_TTL') || 300,
max: configService.get<number>('CACHE_MAX_ITEMS') || 100,
};
},
}),
],
providers: [CacheInvalidationService],
exports: [CacheModule, CacheInvalidationService],
})
export class CacheConfigModule {}
54 changes: 38 additions & 16 deletions meridian-api/src/common/exceptions/validation.exception.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,34 +63,42 @@ describe('flattenValidationErrors', () => {
expect(result).toEqual(
expect.arrayContaining([
{ field: 'password', message: 'weak', constraint: 'matches' },
{ field: 'email', message: 'email must be an email', constraint: 'isEmail' },
{
field: 'email',
message: 'email must be an email',
constraint: 'isEmail',
},
]),
);
});

it('walks nested children with dot-notation paths', () => {
const result = flattenValidationErrors([
vErr('user', {}, [
vErr('profile', {}, [
vErr('email', { isEmail: 'must be valid' }),
]),
vErr('profile', {}, [vErr('email', { isEmail: 'must be valid' })]),
]),
]);
expect(result).toEqual([
{ field: 'user.profile.email', message: 'must be valid', constraint: 'isEmail' },
{
field: 'user.profile.email',
message: 'must be valid',
constraint: 'isEmail',
},
]);
});

it('uses bracket notation for numeric indices (array items)', () => {
const result = flattenValidationErrors([
vErr('users', {}, [
vErr('0', {}, [
vErr('email', { isEmail: 'must be valid' }),
]),
vErr('0', {}, [vErr('email', { isEmail: 'must be valid' })]),
]),
]);
expect(result).toEqual([
{ field: 'users[0].email', message: 'must be valid', constraint: 'isEmail' },
{
field: 'users[0].email',
message: 'must be valid',
constraint: 'isEmail',
},
]);
});

Expand All @@ -99,7 +107,11 @@ describe('flattenValidationErrors', () => {
vErr('user', { isObject: 'user must be an object' }),
]);
expect(result).toEqual([
{ field: 'user', message: 'user must be an object', constraint: 'isObject' },
{
field: 'user',
message: 'user must be an object',
constraint: 'isObject',
},
]);
});

Expand Down Expand Up @@ -155,9 +167,7 @@ describe('validationExceptionFactory (ValidationPipe drop-in)', () => {
}),
vErr('email', { isEmail: 'email must be an email' }),
vErr('users', {}, [
vErr('0', {}, [
vErr('firstName', { minLength: 'must be longer' }),
]),
vErr('0', {}, [vErr('firstName', { minLength: 'must be longer' })]),
]),
]);
const body = ex.getResponse() as Record<string, unknown>;
Expand All @@ -168,9 +178,21 @@ describe('validationExceptionFactory (ValidationPipe drop-in)', () => {
expect(errors).toHaveLength(3);
expect(errors).toEqual(
expect.arrayContaining([
{ field: 'password', message: 'Password must be 8-16 …', constraint: 'matches' },
{ field: 'email', message: 'email must be an email', constraint: 'isEmail' },
{ field: 'users[0].firstName', message: 'must be longer', constraint: 'minLength' },
{
field: 'password',
message: 'Password must be 8-16 …',
constraint: 'matches',
},
{
field: 'email',
message: 'email must be an email',
constraint: 'isEmail',
},
{
field: 'users[0].firstName',
message: 'must be longer',
constraint: 'minLength',
},
]),
);
});
Expand Down
10 changes: 8 additions & 2 deletions meridian-api/src/health/health.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,19 @@ export class HealthController {

@Get()
@HealthCheck()
@ApiOperation({ summary: 'Check the health of the application and its dependencies' })
@ApiOperation({
summary: 'Check the health of the application and its dependencies',
})
check() {
return this.health.check([
() => this.db.pingCheck('database'),
() => this.memory.checkHeap('memory_heap', 150 * 1024 * 1024),
() => this.memory.checkRSS('memory_rss', 300 * 1024 * 1024),
() => this.disk.checkStorage('storage', { path: '/', thresholdPercent: 0.95 }),
() =>
this.disk.checkStorage('storage', {
path: '/',
thresholdPercent: 0.95,
}),
]);
}
}
18 changes: 14 additions & 4 deletions meridian-api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,26 @@ async function bootstrap() {
const nodeEnv = configService.get<string>('NODE_ENV') || 'development';
const allowedOriginsString = configService.get<string>('ALLOWED_ORIGINS');
const allowedOrigins = allowedOriginsString
? allowedOriginsString.split(',').map(origin => origin.trim())
? allowedOriginsString.split(',').map((origin) => origin.trim())
: [];

// In development, we can allow all origins if no ALLOWED_ORIGINS is set
// In production, we strictly enforce allowed origins
let corsOrigin: boolean | string | string[] | ((origin: string, callback: (err: Error | null, allow?: boolean) => void) => void);
let corsOrigin:
| boolean
| string
| string[]
| ((
origin: string,
callback: (err: Error | null, allow?: boolean) => void,
) => void);
if (nodeEnv === 'development' && allowedOrigins.length === 0) {
corsOrigin = true;
} else {
corsOrigin = (origin: string, callback: (err: Error | null, allow?: boolean) => void) => {
corsOrigin = (
origin: string,
callback: (err: Error | null, allow?: boolean) => void,
) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
Expand Down
Loading
Loading