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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,8 @@ temp/
*.sqlite3

# Prisma
prisma/migrations/
# Keep migrations in git so schema/index changes are deployable.
# (Avoid ignoring `prisma/migrations/`.)

# Docker
.dockerignore
Expand Down
56 changes: 8 additions & 48 deletions loadtests/propchain-loadtest.js
Original file line number Diff line number Diff line change
@@ -1,62 +1,22 @@
import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { check, sleep } from 'k6';

export const options = {
vus: Number(__ENV.LOAD_VUS || 40),
duration: __ENV.LOAD_DURATION || '3m',
vus: 5,
duration: '10s',
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<600'],
http_req_duration: ['p(95)<1000'],
},
};

const API_URL = __ENV.API_URL || 'http://localhost:3000';
const TEST_EMAIL = __ENV.TEST_USER_EMAIL || 'loadtest@propchain.local';
const TEST_PASSWORD = __ENV.TEST_USER_PASSWORD || 'Password123!';

function login() {
const payload = JSON.stringify({
email: TEST_EMAIL,
password: TEST_PASSWORD,
});

const headers = {
'Content-Type': 'application/json',
};

const res = http.post(`${API_URL}/auth/login`, payload, { headers });

export default function () {
const res = http.get(`${API_URL}/health`);
check(res, {
'login status is 200': (r) => r.status === 200,
'login returns access token': (r) => r.json('accessToken') !== undefined,
'health status is 200': r => r.status === 200,
});

return res.json('accessToken');
sleep(1);
}

export default () => {
group('Auth + Basic API traffic', () => {
const token = login();
const authHeaders = {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
};

const props = http.get(`${API_URL}/properties`, { headers: authHeaders });
check(props, {
'properties status is 200': (r) => r.status === 200,
});

const docs = http.get(`${API_URL}/documents`, { headers: authHeaders });
check(docs, {
'documents status is 200': (r) => r.status === 200,
});

const health = http.get(`${API_URL}/health`);
check(health, {
'health status is 200': (r) => r.status === 200,
});

sleep(1);
});
};
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,7 @@
"db:backup": "bash scripts/backup.sh",
"db:restore": "bash scripts/restore.sh",
"db:benchmark": "ts-node test/database/performance.benchmark.ts",
"loadtest": "npx k6 run loadtests/propchain-loadtest.js",
"loadtest:ci": "npx k6 run --out json=artifacts/k6-results.json loadtests/propchain-loadtest.js",
"loadtest:ci": "bash scripts/loadtest-ci.sh",
"docs:generate": "typedoc --skipErrorChecking"
},
"dependencies": {
Expand Down
51 changes: 51 additions & 0 deletions prisma/migrations/20260325120000_composite_indexes/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
-- Composite and supporting indexes for critical read paths.
-- Generated manually to match prisma/schema.prisma changes.

-- user_activities: common access patterns
CREATE INDEX "user_activities_user_id_action_created_at_idx"
ON "user_activities" ("user_id", "action", "created_at" DESC);

-- user_relationships: followers/following lists filtered by status and sorted by recency
CREATE INDEX "user_relationships_following_id_status_created_at_idx"
ON "user_relationships" ("following_id", "status", "created_at" DESC);

CREATE INDEX "user_relationships_follower_id_status_created_at_idx"
ON "user_relationships" ("follower_id", "status", "created_at" DESC);

-- properties: listing pages (status) ordered by recency; owner dashboards; status + price range filters
CREATE INDEX "properties_status_created_at_idx"
ON "properties" ("status", "created_at" DESC);

CREATE INDEX "properties_owner_id_created_at_idx"
ON "properties" ("owner_id", "created_at" DESC);

CREATE INDEX "properties_status_price_idx"
ON "properties" ("status", "price");

-- property_valuations: history pages (property) ordered by valuationDate desc
CREATE INDEX "property_valuations_property_id_valuation_date_idx"
ON "property_valuations" ("property_id", "valuation_date" DESC);

-- audit_logs: common filters + ordered by timestamp
CREATE INDEX "audit_logs_timestamp_idx"
ON "audit_logs" ("timestamp" DESC);

CREATE INDEX "audit_logs_user_id_timestamp_idx"
ON "audit_logs" ("user_id", "timestamp" DESC);

CREATE INDEX "audit_logs_table_name_timestamp_idx"
ON "audit_logs" ("table_name", "timestamp" DESC);

CREATE INDEX "audit_logs_operation_timestamp_idx"
ON "audit_logs" ("operation", "timestamp" DESC);

-- system_logs: operational queries (by level/context) ordered by timestamp
CREATE INDEX "system_logs_timestamp_idx"
ON "system_logs" ("timestamp" DESC);

CREATE INDEX "system_logs_log_level_timestamp_idx"
ON "system_logs" ("log_level", "timestamp" DESC);

CREATE INDEX "system_logs_context_timestamp_idx"
ON "system_logs" ("context", "timestamp" DESC);

14 changes: 14 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ model UserActivity {
@@index([userId])
@@index([action])
@@index([createdAt])
@@index([userId, action, createdAt(sort: Desc)])
@@map("user_activities")
}

Expand All @@ -91,6 +92,8 @@ model UserRelationship {
@@unique([followerId, followingId])
@@index([followerId])
@@index([followingId])
@@index([followingId, status, createdAt(sort: Desc)])
@@index([followerId, status, createdAt(sort: Desc)])
@@map("user_relationships")
}

Expand Down Expand Up @@ -136,6 +139,9 @@ model Property {
@@index([location]) // For searching/filtering by location
// Consider adding an index on price if you often filter/sort by price
@@index([price])
@@index([status, createdAt(sort: Desc)])
@@index([ownerId, createdAt(sort: Desc)])
@@index([status, price])
@@map("properties")
}

Expand All @@ -156,6 +162,7 @@ model PropertyValuation {
// Index for fast lookup by property and date
@@index([propertyId])
@@index([valuationDate])
@@index([propertyId, valuationDate(sort: Desc)])
@@map("property_valuations")
}

Expand Down Expand Up @@ -266,6 +273,10 @@ model AuditLog {
userId String? @map("user_id")
timestamp DateTime @default(now())

@@index([timestamp(sort: Desc)])
@@index([userId, timestamp(sort: Desc)])
@@index([tableName, timestamp(sort: Desc)])
@@index([operation, timestamp(sort: Desc)])
@@map("audit_logs")
}

Expand All @@ -277,6 +288,9 @@ model SystemLog {
context String?
timestamp DateTime @default(now())

@@index([timestamp(sort: Desc)])
@@index([logLevel, timestamp(sort: Desc)])
@@index([context, timestamp(sort: Desc)])
@@map("system_logs")
}

Expand Down
22 changes: 22 additions & 0 deletions scripts/loadtest-ci.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail

# k6 runs inside Docker; "localhost" / "127.0.0.1" in API_URL refer to the container, not the host.
# GitHub Actions often sets API_URL=http://localhost:3000 — rewrite so traffic reaches the app on the runner.
if [[ "${API_URL:-}" =~ ^https?://(localhost|127\.0\.0\.1)(:([0-9]+))?(/|$) ]]; then
PORT="${BASH_REMATCH[3]:-3000}"
export API_URL="http://host.docker.internal:${PORT}"
fi
export API_URL="${API_URL:-http://host.docker.internal:3000}"

mkdir -p artifacts

exec docker run --rm -i \
--user "$(id -u):$(id -g)" \
--add-host=host.docker.internal:host-gateway \
-e "API_URL=${API_URL}" \
-v "$PWD:/work" \
-w /work \
grafana/k6:latest run \
--out json=artifacts/k6-results.json \
loadtests/propchain-loadtest.js
6 changes: 4 additions & 2 deletions src/config/utils/cors-origin.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ export class CorsOriginValidator {

// Validate URL format (basic check)
if (!urlPattern.test(origin)) {
console.error(`[CorsOriginValidator] Invalid origin format: "${origin}". Expected valid URL (e.g., https://example.com)`);
console.error(
`[CorsOriginValidator] Invalid origin format: "${origin}". Expected valid URL (e.g., https://example.com)`,
);
return false;
}

Expand Down Expand Up @@ -126,4 +128,4 @@ export class CorsOriginValidator {

return false;
}
}
}
17 changes: 9 additions & 8 deletions src/config/validation/config.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ import * as Joi from 'joi';
*/
const corsOriginValidation = (value: string, helpers: Joi.CustomHelpers) => {
const nodeEnv = Joi.ref('$NODE_ENV');

// If no value provided, error
if (!value || value.trim() === '') {
return helpers.error('cors.origin.required');
}

// Allow '*' only in development and test
if (value === '*') {
const env = helpers.prefs?.context?.NODE_ENV || 'development';
Expand All @@ -21,16 +21,16 @@ const corsOriginValidation = (value: string, helpers: Joi.CustomHelpers) => {
}
return value;
}

// Validate individual origins (comma-separated)
const origins = value.split(',').map(o => o.trim());
const urlPattern = /^https?:\/\/[\w.-]+(:\d+)?(\/.*)?$/;

for (const origin of origins) {
if (origin === '*') {
return helpers.error('cors.origin.wildcard.notAllowed');
}

// Allow 'http://localhost' and 'http://localhost:*' variants
if (origin.startsWith('http://localhost') || origin.startsWith('http://127.0.0.1')) {
const env = helpers.prefs?.context?.NODE_ENV || 'development';
Expand All @@ -39,13 +39,13 @@ const corsOriginValidation = (value: string, helpers: Joi.CustomHelpers) => {
}
continue;
}

// Validate URL format
if (!urlPattern.test(origin)) {
return helpers.error('cors.origin.invalidFormat', { origin });
}
}

return value;
};

Expand All @@ -60,7 +60,8 @@ export const configValidationSchema = Joi.object({
API_PREFIX: Joi.string().default('api'),
CORS_ORIGIN: Joi.string().custom(corsOriginValidation).default('*').messages({
'cors.origin.required': 'CORS_ORIGIN is required',
'cors.origin.wildcard.notAllowed': 'Wildcard (*) origin is not allowed in production/staging. Specify explicit allowed origins.',
'cors.origin.wildcard.notAllowed':
'Wildcard (*) origin is not allowed in production/staging. Specify explicit allowed origins.',
'cors.origin.localhost.notAllowed': 'Localhost origins are not allowed in production/staging',
'cors.origin.invalidFormat': 'Invalid origin format: "{{origin}}". Must be a valid URL (e.g., https://example.com)',
}),
Expand Down
4 changes: 4 additions & 0 deletions src/database/database.module.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { Module, Global } from '@nestjs/common';
import { PrismaModule } from './prisma/prisma.module';
import { IndexMonitorService } from './optimization/index-monitor.service';
import { DatabaseOptimizationController } from './optimization/database-optimization.controller';

@Global()
@Module({
imports: [PrismaModule],
providers: [IndexMonitorService],
controllers: [DatabaseOptimizationController],
exports: [PrismaModule],
})
export class DatabaseModule {}
42 changes: 42 additions & 0 deletions src/database/optimization/database-optimization.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Controller, Get } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { PerformanceMonitorService } from './performance.monitor';
import { QueryOptimizerService } from './query.optimizer';

@ApiTags('database-optimization')
@Controller('database/optimization')
export class DatabaseOptimizationController {
constructor(
private readonly performanceMonitor: PerformanceMonitorService,
private readonly queryOptimizer: QueryOptimizerService,
) {}

@Get('slow-queries')
@ApiOperation({ summary: 'List slow queries observed by the app' })
getSlowQueries() {
return this.queryOptimizer.getSlowQueries();
}

@Get('top-queries')
@ApiOperation({ summary: 'List most frequent queries observed by the app' })
getTopQueries() {
return this.queryOptimizer.getMostFrequentQueries(25);
}

@Get('performance-report')
@ApiOperation({ summary: 'Get current database performance report' })
getPerformanceReport() {
const report = this.performanceMonitor.generatePerformanceReport();
const health = this.performanceMonitor.getHealthScore();
return { ...report, health };
}

@Get('index-usage')
@ApiOperation({ summary: 'Get current index usage snapshot (from pg_stat_user_indexes)' })
getIndexUsage() {
const metrics = this.performanceMonitor.getMetrics();
return Array.from(metrics.indexUsage.entries())
.map(([indexName, usageCount]) => ({ indexName, usageCount }))
.sort((a, b) => a.usageCount - b.usageCount);
}
}
Loading
Loading