Skip to content
Open
Original file line number Diff line number Diff line change
@@ -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");
Original file line number Diff line number Diff line change
@@ -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
22 changes: 19 additions & 3 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions backend/src/modules/doctors/controllers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
29 changes: 29 additions & 0 deletions backend/src/modules/doctors/repositories/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
},
});
}
1 change: 1 addition & 0 deletions backend/src/modules/doctors/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
11 changes: 11 additions & 0 deletions backend/src/modules/rate-limit/index.ts
Original file line number Diff line number Diff line change
@@ -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';
81 changes: 81 additions & 0 deletions backend/src/modules/rate-limit/repositories/index.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
const result = await prisma.$executeRaw`
DELETE FROM "RateLimitRecord"
WHERE "consumedAt" < ${before};
`;
return Number(result);
}
129 changes: 129 additions & 0 deletions backend/src/modules/rate-limit/services/index.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
return repo.pruneOldRateLimitRecords(before);
}

/** Measure the overhead of a rate-limit check in milliseconds. */
export async function measureOverhead(
identifier: string,
config: RateLimitConfig,
): Promise<number> {
const start = process.hrtime.bigint();
const result = await checkRateLimit(identifier, config);
const end = process.hrtime.bigint();
return Number(end - start) / 1_000_000;
}
Loading
Loading