diff --git a/backend/prisma/migrations/20261016110000_add_specialty_verification/migration.sql b/backend/prisma/migrations/20261016110000_add_specialty_verification/migration.sql new file mode 100644 index 0000000..4db9e7b --- /dev/null +++ b/backend/prisma/migrations/20261016110000_add_specialty_verification/migration.sql @@ -0,0 +1,11 @@ +-- Add per-specialty verification fields to DoctorSpecialty +ALTER TABLE "DoctorSpecialty" ADD COLUMN "verificationStatus" VARCHAR(191); +ALTER TABLE "DoctorSpecialty" ADD COLUMN "reviewer" VARCHAR(191); +ALTER TABLE "DoctorSpecialty" ADD COLUMN "reviewDate" TIMESTAMP(3); +ALTER TABLE "DoctorSpecialty" ADD COLUMN "rejectionReason" VARCHAR(191); + +-- Backfill existing rows: doctors who have doctorProfile assignments get 'pending' status +UPDATE "DoctorSpecialty" SET "verificationStatus" = 'pending' WHERE "verificationStatus" IS NULL; + +-- Add index for querying verified doctors by specialty +CREATE INDEX "DoctorSpecialty_verificationStatus_idx" ON "DoctorSpecialty"("verificationStatus"); \ No newline at end of file diff --git a/backend/prisma/migrations/20261016120000_add_rate_limit_store/migration.sql b/backend/prisma/migrations/20261016120000_add_rate_limit_store/migration.sql new file mode 100644 index 0000000..4e2a57f --- /dev/null +++ b/backend/prisma/migrations/20261016120000_add_rate_limit_store/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "RateLimitRecord" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "scope" TEXT NOT NULL, // 'user' | 'ip' + "cost" INTEGER NOT NULL DEFAULT 1, + "consumedAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "RateLimitRecord_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "RateLimitRecord_key_scope_idx" ON "RateLimitRecord"("key", "scope"); + +-- CreateIndex +CREATE INDEX "RateLimitRecord_consumedAt_idx" ON "RateLimitRecord"("consumedAt"); + +-- Backfill: existing LoginAttempt rows are already tracked; no-op for new repo \ No newline at end of file diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b52f32d..e7018ac 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -402,9 +402,13 @@ model Specialty { } model DoctorSpecialty { - id String @id @default(uuid()) - doctorProfileId String - specialtyId String + id String @id @default(uuid()) + doctorProfileId String + specialtyId String + verificationStatus VerificationStatus? + reviewer String? + reviewDate DateTime? + rejectionReason String? doctorProfile DoctorProfile @relation(fields: [doctorProfileId], references: [id], onDelete: Cascade) specialty Specialty @relation(fields: [specialtyId], references: [id], onDelete: Cascade) @@ -769,6 +773,18 @@ model FulfillmentRecord { // into PrescriptionFulfillment / LabOrderFulfillment before implementation. } +model RateLimitRecord { + id String @id @default(uuid()) + key String + scope String // 'user' | 'ip' + cost Int @default(1) + consumedAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([key, scope, consumedAt]) +} + model Notification { id String @id @default(uuid()) type String diff --git a/backend/src/modules/doctors/controllers/index.ts b/backend/src/modules/doctors/controllers/index.ts index 1ac8db9..a201d3d 100644 --- a/backend/src/modules/doctors/controllers/index.ts +++ b/backend/src/modules/doctors/controllers/index.ts @@ -32,3 +32,16 @@ export async function setVerification(req: Request, res: Response) { const profile = await doctorsService.setVerificationStatus(auditActor(req), doctorProfileId, status); res.status(200).json(profile); } + +export async function setDoctorSpecialtyVerification(req: Request, res: Response) { + const { status, reviewer } = setVerificationStatusSchema.parse(req.body); + const doctorProfileId = getParam(req, 'doctorProfileId'); + const specialtyId = getParam(req, 'specialtyId'); + const profile = await doctorsService.setDoctorSpecialtyVerification( + doctorProfileId, + specialtyId, + status, + reviewer, + ); + res.status(200).json(profile); +} diff --git a/backend/src/modules/doctors/repositories/index.ts b/backend/src/modules/doctors/repositories/index.ts index e9649ad..4006136 100644 --- a/backend/src/modules/doctors/repositories/index.ts +++ b/backend/src/modules/doctors/repositories/index.ts @@ -167,3 +167,32 @@ export function findDoctorSpecialties(doctorProfileId: string) { include: { specialty: true }, }); } + +export function findDoctorSpecialtyByDoctorAndSpecialty(doctorProfileId: string, specialtyId: string) { + return prisma.doctorSpecialty.findUnique({ + where: { doctorProfileId_specialtyId: { doctorProfileId, specialtyId } }, + }); +} + +export function findDoctorSpecialtiesByDoctor(doctorProfileId: string) { + return prisma.doctorSpecialty.findMany({ where: { doctorProfileId } }); +} + +export function updateDoctorSpecialtyVerification( + doctorProfileId: string, + specialtyId: string, + status: VerificationStatus, + reviewer: string, + rejectionReason?: string, +) { + const now = new Date(); + return prisma.doctorSpecialty.update({ + where: { doctorProfileId_specialtyId: { doctorProfileId, specialtyId } }, + data: { + verificationStatus: status, + reviewer, + reviewDate: now, + ...(rejectionReason !== undefined && { rejectionReason }), + }, + }); +} diff --git a/backend/src/modules/doctors/validation.ts b/backend/src/modules/doctors/validation.ts index 5cd4241..341bce1 100644 --- a/backend/src/modules/doctors/validation.ts +++ b/backend/src/modules/doctors/validation.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; export const setVerificationStatusSchema = z.object({ status: z.enum(['pending', 'verified', 'rejected']), + reviewer: z.string(), }); export const setMySpecialtiesSchema = z.object({ diff --git a/backend/src/modules/rate-limit/index.ts b/backend/src/modules/rate-limit/index.ts new file mode 100644 index 0000000..8b6b908 --- /dev/null +++ b/backend/src/modules/rate-limit/index.ts @@ -0,0 +1,11 @@ +export { MemoryRateLimitStore } from './stores/memory'; +export { prismaRateLimitStore } from './types'; +export type { RateLimitStore, RateLimitRecord } from './types'; +export { + rateLimitMiddleware, + checkRateLimit, + getRateLimitState, + pruneOldRateLimitRecords, + measureOverhead, + createRateLimitConfig, +} from './services'; \ No newline at end of file diff --git a/backend/src/modules/rate-limit/repositories/index.ts b/backend/src/modules/rate-limit/repositories/index.ts new file mode 100644 index 0000000..5163504 --- /dev/null +++ b/backend/src/modules/rate-limit/repositories/index.ts @@ -0,0 +1,81 @@ +import { prisma } from '../../../db/prisma-client'; +import type { RateLimitStore } from '../../rate-limit/types'; + +export interface RateLimitRecord { + id: string; + key: string; + scope: 'user' | 'ip'; + cost: number; + consumedAt: Date; + createdAt: Date; + updatedAt: Date; +} + +/** Record a rate limit consumption and return the updated bucket state. */ +export async function recordRateLimitConsumption( + key: string, + scope: 'user' | 'ip', + cost = 1, +): Promise<{ remaining: number; resetAt: Date }> { + const now = new Date(); + const windowSeconds = 60; // 1-minute window for rate limiting + + // Use a conditional upsert to atomically increment the counter + const record = await prisma.$executeRaw` + INSERT INTO "RateLimitRecord" ("key", "scope", "cost", "consumedAt", "createdAt", "updatedAt") + VALUES (${key}, ${scope}, ${cost}, ${now}, NOW(), NOW()) + ON CONFLICT ("key", "scope") DO UPDATE + SET "consumedAt" = NOW(), + "updatedAt" = NOW(), + "cost" = "RateLimitRecord"."cost" + ${cost} + RETURNING "cost"; + `; + + // Count total consumption in the current window + const totalConsumed = await prisma.$countRateLimitRecord({ + where: { + key, + scope, + consumedAt: { + gte: new Date(Date.now() - windowSeconds * 1000), + }, + }, + }); + + const remaining = Math.max(0, 100 - totalConsumed); // 100 requests per window + const resetAt = new Date(Date.now() + (60 - windowSeconds) * 1000); + + return { remaining, resetAt }; +} + +/** Get rate limit state for a key. */ +export async function getRateLimitState( + key: string, + scope: 'user' | 'ip', +): Promise<{ remaining: number; resetAt: Date }> { + const windowSeconds = 60; + + const totalConsumed = await prisma.$countRateLimitRecord({ + where: { + key, + scope, + consumedAt: { + gte: new Date(Date.now() - windowSeconds * 1000), + }, + }, + }); + + const remaining = Math.max(0, 100 - totalConsumed); + const resetAt = new Date(Date.now() + (60 - windowSeconds) * 1000); + + return { remaining, resetAt }; +} + +/** Prune old rate limit records to prevent unbounded growth. */ +export async function pruneOldRateLimitRecords(before: Date): Promise { + const result = await prisma.$executeRaw` + DELETE FROM "RateLimitRecord" + WHERE "consumedAt" < ${before}; + `; + return Number(result); +} \ No newline at end of file diff --git a/backend/src/modules/rate-limit/services/index.ts b/backend/src/modules/rate-limit/services/index.ts new file mode 100644 index 0000000..5fabfaf --- /dev/null +++ b/backend/src/modules/rate-limit/services/index.ts @@ -0,0 +1,129 @@ +import { config } from '../../../config'; +import { HttpError } from '../../../http-error'; +import * as repo from '../repositories'; +import type { RateLimitRecord } from '../types'; + +export interface RateLimitConfig { + windowSeconds: number; + maxRequests: number; + identifier: 'user' | 'ip'; +} + +/** Create a rate limit config per route */ +export function createRateLimitConfig(identifier: 'user' | 'ip', customWindow?: number, customMax?: number): RateLimitConfig { + return { + windowSeconds: customWindow || config.rateLimit.windowSeconds, + maxRequests: customMax || config.rateLimit.maxRequests, + identifier, + }; +} + +/** Check if a request is rate-limited and return headers if so. */ +export async function checkRateLimit( + identifier: string, + config: RateLimitConfig, +): Promise<{ allowed: boolean; remaining: number; resetAt: Date; retryAfter?: number }> { + const now = new Date(); + const windowStart = new Date(now.getTime() - config.windowSeconds * 1000); + + // Count consumption in the current window + const totalConsumed = await repo.countRateLimitRecords({ + key: identifier, + scope: config.identifier, + since: windowStart, + }); + + const remaining = Math.max(0, config.maxRequests - totalConsumed); + const resetAt = new Date(now.getTime() + (config.windowSeconds - (now.getTime() - windowStart.getTime()))); + + const allowed = totalConsumed < config.maxRequests; + + if (!allowed) { + const retryAfter = Math.ceil((config.windowSeconds - (now.getTime() - windowStart.getTime())) / 1000); + return { allowed: false, remaining: 0, resetAt, retryAfter }; + } + + // Record this consumption + await repo.recordRateLimitConsumption({ + key: identifier, + scope: config.identifier, + cost: 1, + consumedAt: now, + }); + + return { allowed: true, remaining: remaining - 1, resetAt }; +} + +/** Per-route rate limiting middleware for Express 5 */ +export function rateLimitMiddleware(config: RateLimitConfig) { + return async (req: import('express').Request, res: import('express').Response, next: import('express').NextFunction) => { + // Exempt /health endpoint + if (req.path === '/health') { + return next(); + } + + // Resolve the identifier + let identifier: string; + + if (config.identifier === 'user') { + // Use authenticated user ID if available, otherwise fall back to IP + if (req.user && (req.user as any).id) { + identifier = (req.user as any).id; + } else { + identifier = req.ip || 'anonymous'; + } + } else { + // IP-based identifier + // Handle Render proxy: trust first proxy hop + const forwardedFor = req.headers['x-forwarded-for'] as string | undefined; + const realIp = forwardedFor + ? (forwardedFor.split(',')[0].trim()) + : (req.ip || 'unknown'); + identifier = realIp; + } + + const result = await checkRateLimit(identifier, config); + + if (!result.allowed) { + const retryAfter = result.retryAfter ?? Math.ceil((config.windowSeconds - (Date.now() - Date.now())) / 1000); + res.setHeader('RateLimit-Limit', config.maxRequests.toString()); + res.setHeader('RateLimit-Remaining', '0'); + res.setHeader('RateLimit-Reset', result.resetAt.toISOString()); + res.setHeader('Retry-After', String(retryAfter)); + throw new HttpError(429, 'Too many requests. Try again later.', { + 'Retry-After': String(retryAfter), + }); + } + + // Set rate limit headers for allowed requests + res.setHeader('RateLimit-Limit', config.maxRequests.toString()); + res.setHeader('RateLimit-Remaining', result.remaining.toString()); + res.setHeader('RateLimit-Reset', result.resetAt.toISOString()); + + next(); + }; +} + +/** Aggregate rate-limit state across multiple buckets (for internal use). */ +export async function getRateLimitState( + identifier: string, + config: RateLimitConfig, +): Promise<{ remaining: number; resetAt: Date }> { + return repo.getRateLimitState(identifier, config.identifier); +} + +/** Cleanup old rate-limit records to prevent unbounded growth. */ +export async function pruneOldRateLimitRecords(before: Date): Promise { + return repo.pruneOldRateLimitRecords(before); +} + +/** Measure the overhead of a rate-limit check in milliseconds. */ +export async function measureOverhead( + identifier: string, + config: RateLimitConfig, +): Promise { + const start = process.hrtime.bigint(); + const result = await checkRateLimit(identifier, config); + const end = process.hrtime.bigint(); + return Number(end - start) / 1_000_000; +} diff --git a/backend/src/modules/rate-limit/stores/memory.ts b/backend/src/modules/rate-limit/stores/memory.ts new file mode 100644 index 0000000..b612108 --- /dev/null +++ b/backend/src/modules/rate-limit/stores/memory.ts @@ -0,0 +1,79 @@ +import type { RateLimitStore, RateLimitRecord as RateLimitRecordType } from '../types'; + +/** In-memory rate limit store for tests. */ +export class MemoryRateLimitStore implements RateLimitStore { + private records = new Map(); + + private makeRecord(data: { + id: string; + key: string; + scope: 'user' | 'ip'; + cost: number; + consumedAt: Date; + }): RateLimitRecordType { + return { + id: data.id, + key: data.key, + scope: data.scope, + cost: data.cost, + consumedAt: data.consumedAt, + createdAt: new Date(), + updatedAt: new Date(), + }; + } + + async recordConsumption(data: { + key: string; + scope: 'user' | 'ip'; + cost: number; + consumedAt: Date; + }): Promise { + const { key, scope, cost, consumedAt } = data; + const mapKey = `${key}:${scope}`; + + if (!this.records.has(mapKey)) { + this.records.set(mapKey, []); + } + + const records = this.records.get(mapKey)!; + records.push(this.makeRecord({ id: crypto.randomUUID(), key, scope, cost, consumedAt })); + } + + async countRecords(params: { + key: string; + scope: 'user' | 'ip'; + since: Date; + }): Promise { + const { key, scope, since } = params; + const mapKey = `${key}:${scope}`; + const records = this.records.get(mapKey) || []; + return records.filter((r) => r.consumedAt >= since).length; + } + + async getState(params: { + key: string; + scope: 'user' | 'ip'; + }): Promise<{ remaining: number; resetAt: Date }> { + const { key, scope } = params; + // Default: 100 requests per 60-second window + const windowMs = 60 * 1000; + const windowStart = new Date(Date.now() - windowMs); + + const mapKey = `${key}:${scope}`; + const records = this.records.get(mapKey) || []; + const totalConsumed = records.filter((r) => r.consumedAt >= windowStart).length; + const remaining = Math.max(0, 100 - totalConsumed); + const resetAt = new Date(Date.now() + (windowMs - (Date.now() - windowStart.getTime()))); + + return { remaining, resetAt }; + } + + async prune(oldBefore: Date): Promise { + let count = 0; + for (const [mapKey, records] of this.records.entries()) { + this.records.set(mapKey, records.filter((r) => r.consumedAt >= oldBefore)); + count += records.length - this.records.get(mapKey)!.length; + } + return count; + } +} \ No newline at end of file diff --git a/backend/src/modules/rate-limit/types.ts b/backend/src/modules/rate-limit/types.ts new file mode 100644 index 0000000..64bfb67 --- /dev/null +++ b/backend/src/modules/rate-limit/types.ts @@ -0,0 +1,73 @@ +import type { Prisma } from '@prisma/client'; + +/** Store interface for rate-limit state. */ +export interface RateLimitStore { + recordConsumption(data: { + key: string; + scope: 'user' | 'ip'; + cost: number; + consumedAt: Date; + }): Promise; + countRecords(params: { + key: string; + scope: 'user' | 'ip'; + since: Date; + }): Promise; + getState(params: { + key: string; + scope: 'user' | 'ip'; + }): Promise<{ remaining: number; resetAt: Date }>; + prune(oldBefore: Date): Promise; +} + +/** Prisma-based implementation for production. */ +export async function prismaRateLimitStore( + prisma: Prisma.Client, +): Promise { + return { + recordConsumption: async (data) => { + // Production uses raw SQL via prisma.$executeRaw for UPSERT + await prisma.$executeRaw` + INSERT INTO "RateLimitRecord" ("key", "scope", "cost", "consumedAt", "createdAt", "updatedAt") + VALUES (${data.key}, ${data.scope}, ${data.cost}, ${data.consumedAt}, NOW(), NOW()) + ON CONFLICT ("key", "scope") DO UPDATE + SET "consumedAt" = NOW(), + "updatedAt" = NOW(), + "cost" = "RateLimitRecord"."cost" + ${data.cost} + `; + }, + countRecords: async (params) => { + return prisma.$countRateLimitRecord({ + where: { + key: params.key, + scope: params.scope, + consumedAt: { + gte: params.since, + }, + }, + }); + }, + getState: async (params) => { + return prisma.$countRateLimitRecord({ + where: { + key: params.key, + scope: params.scope, + consumedAt: { + gte: new Date(Date.now() - 60 * 1000), + }, + }, + }).then((count) => { + const remaining = Math.max(0, 100 - count); + const resetAt = new Date(Date.now() + (60 - 60) * 1000); + return { remaining, resetAt }; + }); + }, + prune: async (oldBefore) => { + const result = await prisma.$executeRaw` + DELETE FROM "RateLimitRecord" + WHERE "consumedAt" < ${oldBefore}; + `; + return Number(result); + }, + }; +} \ No newline at end of file diff --git a/backend/tests/unit/rate-limit.test.ts b/backend/tests/unit/rate-limit.test.ts new file mode 100644 index 0000000..ee2d65e --- /dev/null +++ b/backend/tests/unit/rate-limit.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { MemoryRateLimitStore } from '../../src/modules/rate-limit/stores/memory'; +import { checkRateLimit, measureOverhead } from '../../src/modules/rate-limit/services'; + +describe('Rate Limiter', () => { + let store: MemoryRateLimitStore; + + beforeEach(() => { + store = new MemoryRateLimitStore(); + }); + + describe('checkRateLimit', () => { + it('should allow requests within the limit', async () => { + const result = await checkRateLimit('test-user-1', { + windowSeconds: 60, + maxRequests: 5, + identifier: 'user', + }); + expect(result.allowed).toBe(true); + expect(result.remaining).toBeGreaterThanOrEqual(0); + }); + + it('should block requests beyond the limit', async () => { + const max = 5; + for (let i = 0; i < max; i++) { + await checkRateLimit(`user-${i}`, { + windowSeconds: 60, + maxRequests: max, + identifier: 'user', + }); + } + + const result = await checkRateLimit('user-over', { + windowSeconds: 60, + maxRequests: max, + identifier: 'user', + }); + expect(result.allowed).toBe(false); + expect(result.remaining).toBe(0); + expect(result.retryAfter).toBeGreaterThan(0); + }); + + it('should reset after window expires', async () => { + const max = 3; + for (let i = 0; i < max; i++) { + await checkRateLimit('test-user', { + windowSeconds: 60, + maxRequests: max, + identifier: 'user', + }); + } + + // Wait for window to expire (simulate time passing) + await new Promise((resolve) => setTimeout(resolve, 61000)); + + const result = await checkRateLimit('test-user', { + windowSeconds: 60, + maxRequests: max, + identifier: 'user', + }); + expect(result.allowed).toBe(true); + }); + }); + + describe('measureOverhead', () => { + it('should return a reasonable overhead time', async () => { + const overhead = await measureOverhead('test-user', { + windowSeconds: 60, + maxRequests: 100, + identifier: 'user', + }); + // Should be fast - under 1 second for in-memory operations + expect(overhead).toBeLessThan(1000); + }); + }); +}); \ No newline at end of file