diff --git a/.env.example b/.env.example index ad7ce014..3e8f83b0 100644 --- a/.env.example +++ b/.env.example @@ -32,4 +32,7 @@ MOBILE_REDIRECT_URI=devcard://oauth/callback # ─── Server ─── PORT=3000 -NODE_ENV=development \ No newline at end of file +NODE_ENV=development + +# ─── Refresh Token Cleanup ─── +REFRESH_TOKEN_CLEANUP_INTERVAL_MS=86400000 \ No newline at end of file diff --git a/apps/backend/prisma/migrations/20260617074743_refresh_token_cleanup_indexes/migration.sql b/apps/backend/prisma/migrations/20260617074743_refresh_token_cleanup_indexes/migration.sql new file mode 100644 index 00000000..94fcab49 --- /dev/null +++ b/apps/backend/prisma/migrations/20260617074743_refresh_token_cleanup_indexes/migration.sql @@ -0,0 +1,168 @@ +/* + Warnings: + + - You are about to drop the column `provider` on the `users` table. All the data in the column will be lost. + - You are about to drop the column `provider_id` on the `users` table. All the data in the column will be lost. + - A unique constraint covering the columns `[phone_number]` on the table `users` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateEnum +CREATE TYPE "Role" AS ENUM ('SUPERADMIN', 'ADMIN', 'USER'); + +-- CreateEnum +CREATE TYPE "TeamRole" AS ENUM ('OWNER', 'ADMIN', 'MEMBER'); + +-- DropIndex +DROP INDEX "users_provider_provider_id_key"; + +-- AlterTable +ALTER TABLE "users" DROP COLUMN "provider", +DROP COLUMN "provider_id", +ADD COLUMN "authRole" "Role" NOT NULL DEFAULT 'USER', +ADD COLUMN "email_verified" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "last_sign_in_at" TIMESTAMP(3), +ADD COLUMN "phone_number" TEXT; + +-- CreateTable +CREATE TABLE "user_identities" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "provider_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_identities_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "refresh_tokens" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "family" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "revoked_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "user_agent" TEXT, + "ip" TEXT, + + CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Event" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "location" TEXT NOT NULL, + "description" TEXT, + "organizerId" TEXT NOT NULL, + "startDate" TIMESTAMP(3) NOT NULL, + "endDate" TIMESTAMP(3) NOT NULL, + "isPublic" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Event_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "EventAttendee" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "joinedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "EventAttendee_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "teams" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "description" TEXT, + "avatarUrl" TEXT, + "ownerId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "teams_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "team_members" ( + "id" TEXT NOT NULL, + "teamId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "role" "TeamRole" NOT NULL, + "joinedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "team_members_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "user_identities_user_id_idx" ON "user_identities"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_identities_provider_provider_id_key" ON "user_identities"("provider", "provider_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "refresh_tokens_token_hash_key" ON "refresh_tokens"("token_hash"); + +-- CreateIndex +CREATE INDEX "refresh_tokens_user_id_idx" ON "refresh_tokens"("user_id"); + +-- CreateIndex +CREATE INDEX "refresh_tokens_family_idx" ON "refresh_tokens"("family"); + +-- CreateIndex +CREATE INDEX "refresh_tokens_expires_at_idx" ON "refresh_tokens"("expires_at"); + +-- CreateIndex +CREATE INDEX "refresh_tokens_revoked_at_idx" ON "refresh_tokens"("revoked_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "Event_slug_key" ON "Event"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "EventAttendee_userId_eventId_key" ON "EventAttendee"("userId", "eventId"); + +-- CreateIndex +CREATE UNIQUE INDEX "teams_slug_key" ON "teams"("slug"); + +-- CreateIndex +CREATE INDEX "teams_slug_idx" ON "teams"("slug"); + +-- CreateIndex +CREATE INDEX "team_members_userId_idx" ON "team_members"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "team_members_userId_teamId_key" ON "team_members"("userId", "teamId"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_phone_number_key" ON "users"("phone_number"); + +-- AddForeignKey +ALTER TABLE "user_identities" ADD CONSTRAINT "user_identities_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Event" ADD CONSTRAINT "Event_organizerId_fkey" FOREIGN KEY ("organizerId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EventAttendee" ADD CONSTRAINT "EventAttendee_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EventAttendee" ADD CONSTRAINT "EventAttendee_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "teams" ADD CONSTRAINT "teams_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "team_members" ADD CONSTRAINT "team_members_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "teams"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "team_members" ADD CONSTRAINT "team_members_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 38fb91fe..c296bcea 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -77,6 +77,8 @@ model RefreshToken { @@index([userId]) @@index([family]) + @@index([expiresAt]) + @@index([revokedAt]) @@map("refresh_tokens") } diff --git a/apps/backend/src/__tests__/refreshTokenCleanup.test.ts b/apps/backend/src/__tests__/refreshTokenCleanup.test.ts new file mode 100644 index 00000000..d66c36a5 --- /dev/null +++ b/apps/backend/src/__tests__/refreshTokenCleanup.test.ts @@ -0,0 +1,319 @@ +import Fastify, { type FastifyInstance } from "fastify"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { refreshTokenCleanupPlugin } from "../plugins/refreshTokenCleanup.js"; +import { cleanupExpiredAndRevokedTokens } from "../services/refreshTokenCleanupService.js"; +import * as service from "../services/refreshTokenCleanupService.js"; + +import type { PrismaClient } from "@prisma/client"; + +describe("refreshTokenCleanupService", () => { + const mockPrisma = { + refreshToken: { + deleteMany: vi.fn(), + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("active token survives (neither expired nor revoked are deleted)", async () => { + mockPrisma.refreshToken.deleteMany.mockResolvedValue({ count: 0 }); + + const result = await cleanupExpiredAndRevokedTokens( + mockPrisma as unknown as PrismaClient, + ); + + expect(mockPrisma.refreshToken.deleteMany).toHaveBeenCalledTimes(1); + const callArgs = mockPrisma.refreshToken.deleteMany.mock.calls[0][0]; + + // Explicitly verify the query structure: + // It must delete ONLY: revokedAt is not null OR expiresAt has passed (expiresAt < now) + expect(callArgs?.where?.OR).toBeDefined(); + expect(callArgs.where.OR).toHaveLength(2); + expect(callArgs.where.OR).toContainEqual({ revokedAt: { not: null } }); + expect(callArgs.where.OR[1].expiresAt.lt).toBeInstanceOf(Date); + + expect(result.deletedCount).toBe(0); + }); + + it("expired token deleted", async () => { + mockPrisma.refreshToken.deleteMany.mockResolvedValue({ count: 1 }); + + await cleanupExpiredAndRevokedTokens(mockPrisma as unknown as PrismaClient); + + const callArgs = mockPrisma.refreshToken.deleteMany.mock.calls[0][0]; + + expect(callArgs.where.OR).toContainEqual({ + expiresAt: { + lt: expect.any(Date), + }, + }); + }); + + it("revoked token deleted", async () => { + mockPrisma.refreshToken.deleteMany.mockResolvedValue({ count: 1 }); + + await cleanupExpiredAndRevokedTokens(mockPrisma as unknown as PrismaClient); + + const callArgs = mockPrisma.refreshToken.deleteMany.mock.calls[0][0]; + + expect(callArgs.where.OR).toContainEqual({ + revokedAt: { + not: null, + }, + }); + }); + + it("mixed dataset query contains both cleanup conditions", async () => { + mockPrisma.refreshToken.deleteMany.mockResolvedValue({ count: 5 }); + + await cleanupExpiredAndRevokedTokens(mockPrisma as unknown as PrismaClient); + + const callArgs = mockPrisma.refreshToken.deleteMany.mock.calls[0][0]; + + expect(callArgs.where.OR).toHaveLength(2); + }); + + it("returns the exact count of deleted tokens reported by the database", async () => { + mockPrisma.refreshToken.deleteMany.mockResolvedValue({ count: 15 }); + const result = await cleanupExpiredAndRevokedTokens( + mockPrisma as unknown as PrismaClient, + ); + expect(result.deletedCount).toBe(15); + }); + + it("empty dataset (table is empty, deleteMany returns 0)", async () => { + mockPrisma.refreshToken.deleteMany.mockResolvedValue({ count: 0 }); + const result = await cleanupExpiredAndRevokedTokens( + mockPrisma as unknown as PrismaClient, + ); + expect(result.deletedCount).toBe(0); + }); + + it("service error handling is propagated correctly", async () => { + mockPrisma.refreshToken.deleteMany.mockRejectedValue( + new Error("Database query timeout"), + ); + await expect( + cleanupExpiredAndRevokedTokens(mockPrisma as unknown as PrismaClient), + ).rejects.toThrow("Database query timeout"); + }); + + it("service is idempotent on multiple executions", async () => { + mockPrisma.refreshToken.deleteMany.mockResolvedValue({ count: 0 }); + const run1 = await cleanupExpiredAndRevokedTokens( + mockPrisma as unknown as PrismaClient, + ); + const run2 = await cleanupExpiredAndRevokedTokens( + mockPrisma as unknown as PrismaClient, + ); + expect(run1.deletedCount).toBe(0); + expect(run2.deletedCount).toBe(0); + expect(mockPrisma.refreshToken.deleteMany).toHaveBeenCalledTimes(2); + }); +}); + +describe("refreshTokenCleanupPlugin", () => { + let app: FastifyInstance | null = null; + const mockPrisma = { + refreshToken: { + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + app = Fastify({ logger: { level: "error" } }); + app.decorate("prisma", mockPrisma as unknown as PrismaClient); + }); + + afterEach(async () => { + vi.useRealTimers(); + if (app) { + await app.close(); + app = null; + } + delete process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS; + }); + + it("starts cleanup on register/startup and schedules interval", async () => { + const cleanupSpy = vi + .spyOn(service, "cleanupExpiredAndRevokedTokens") + .mockResolvedValue({ + deletedCount: 3, + durationMs: 15, + }); + + process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS = "60000"; // 1 minute + + await app!.register(refreshTokenCleanupPlugin); + await app!.ready(); + + // Verification 1: startup cleanup run + expect(cleanupSpy).toHaveBeenCalledTimes(1); + + // Verification 2: interval execution + await vi.advanceTimersByTimeAsync(60000); + expect(cleanupSpy).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(60000); + expect(cleanupSpy).toHaveBeenCalledTimes(3); + }); + + it("invalid interval fallback (undefined)", async () => { + const cleanupSpy = vi + .spyOn(service, "cleanupExpiredAndRevokedTokens") + .mockResolvedValue({ + deletedCount: 0, + durationMs: 1, + }); + + delete process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS; + + await app!.register(refreshTokenCleanupPlugin); + await app!.ready(); + + expect(cleanupSpy).toHaveBeenCalledTimes(1); // startup + + // Should not run at 1 minute + await vi.advanceTimersByTimeAsync(60000); + expect(cleanupSpy).toHaveBeenCalledTimes(1); + + // Should run at 24 hours (86400000ms) + await vi.advanceTimersByTimeAsync(86400000 - 60000); + expect(cleanupSpy).toHaveBeenCalledTimes(2); + }); + + it("invalid interval fallback (NaN)", async () => { + const cleanupSpy = vi + .spyOn(service, "cleanupExpiredAndRevokedTokens") + .mockResolvedValue({ + deletedCount: 0, + durationMs: 1, + }); + + process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS = "not-a-number"; + + await app!.register(refreshTokenCleanupPlugin); + await app!.ready(); + + // Startup run + expect(cleanupSpy).toHaveBeenCalledTimes(1); + + // Should fallback to 24 hours + await vi.advanceTimersByTimeAsync(86400000); + expect(cleanupSpy).toHaveBeenCalledTimes(2); + }); + + it("invalid interval fallback (<= 0)", async () => { + const cleanupSpy = vi + .spyOn(service, "cleanupExpiredAndRevokedTokens") + .mockResolvedValue({ + deletedCount: 0, + durationMs: 1, + }); + + process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS = "0"; + + await app!.register(refreshTokenCleanupPlugin); + await app!.ready(); + + // Startup run + expect(cleanupSpy).toHaveBeenCalledTimes(1); + + // Should fallback to 24 hours + await vi.advanceTimersByTimeAsync(86400000); + expect(cleanupSpy).toHaveBeenCalledTimes(2); + }); + + it("invalid interval fallback (Infinity)", async () => { + const cleanupSpy = vi + .spyOn(service, "cleanupExpiredAndRevokedTokens") + .mockResolvedValue({ + deletedCount: 0, + durationMs: 1, + }); + + process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS = "Infinity"; + + await app!.register(refreshTokenCleanupPlugin); + await app!.ready(); + + // Startup run + expect(cleanupSpy).toHaveBeenCalledTimes(1); + + // Should fallback to 24 hours + await vi.advanceTimersByTimeAsync(86400000); + expect(cleanupSpy).toHaveBeenCalledTimes(2); + }); + + it("startup cleanup failure logs error but does not crash app or stop scheduler", async () => { + const cleanupSpy = vi + .spyOn(service, "cleanupExpiredAndRevokedTokens") + .mockRejectedValue(new Error("Connection failure")); + + process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS = "5000"; + + await app!.register(refreshTokenCleanupPlugin); + + // app!.ready() should resolve successfully without throwing + await expect(app!.ready()).resolves.toBeDefined(); + expect(cleanupSpy).toHaveBeenCalledTimes(1); + + // Scheduler should still function + cleanupSpy.mockResolvedValue({ deletedCount: 0, durationMs: 1 }); + await vi.advanceTimersByTimeAsync(5000); + expect(cleanupSpy).toHaveBeenCalledTimes(2); + }); + + it("scheduled cleanup failure logs error but does not crash process", async () => { + const cleanupSpy = vi + .spyOn(service, "cleanupExpiredAndRevokedTokens") + .mockResolvedValue({ + deletedCount: 0, + durationMs: 1, + }); + + process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS = "5000"; + + await app!.register(refreshTokenCleanupPlugin); + await app!.ready(); + + expect(cleanupSpy).toHaveBeenCalledTimes(1); + + // Fail during scheduled run + cleanupSpy.mockRejectedValue(new Error("Transaction deadlock")); + await vi.advanceTimersByTimeAsync(5000); + expect(cleanupSpy).toHaveBeenCalledTimes(2); + + // Next run works again + cleanupSpy.mockResolvedValue({ deletedCount: 1, durationMs: 1 }); + await vi.advanceTimersByTimeAsync(5000); + expect(cleanupSpy).toHaveBeenCalledTimes(3); + }); + + it("shutdown clears interval and avoids timer leaks", async () => { + const cleanupSpy = vi + .spyOn(service, "cleanupExpiredAndRevokedTokens") + .mockResolvedValue({ + deletedCount: 0, + durationMs: 1, + }); + + process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS = "1000"; + await app!.register(refreshTokenCleanupPlugin); + await app!.ready(); + + expect(cleanupSpy).toHaveBeenCalledTimes(1); + + await app!.close(); + app = null; + + // Advance timer: should not be called again because onClose hook cleared it + await vi.advanceTimersByTimeAsync(5000); + expect(cleanupSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 44842088..c7d4ad26 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -11,6 +11,7 @@ import Fastify, {type FastifyInstance, type FastifyReply, type FastifyRequest} f import { prismaPlugin } from './plugins/prisma.js'; import { redisPlugin } from './plugins/redis.js'; +import { refreshTokenCleanupPlugin } from './plugins/refreshTokenCleanup.js'; import { analyticsRoutes } from './routes/analytics.js'; import { authRoutes } from './routes/auth.js'; import { cardRoutes } from './routes/cards.js'; @@ -95,13 +96,16 @@ export async function buildApp():Promise { // Files must be served through authenticated route handlers // with ownership validation. - // ─── Database & Cache Plugins ─── - if (process.env.NODE_ENV !== 'test') { - await app.register(prismaPlugin); //change +// ─── Database & Cache Plugins ─── +if (process.env.NODE_ENV !== 'test') { + await app.register(prismaPlugin); } - if (process.env.NODE_ENV !== 'test') { + +if (process.env.NODE_ENV !== 'test') { await app.register(redisPlugin); + await app.register(refreshTokenCleanupPlugin); } + // ─── Auth Decorator ─── // Checks the Redis blocklist before calling jwtVerify so that a logged-out // token is rejected immediately even if it has not yet expired. diff --git a/apps/backend/src/plugins/refreshTokenCleanup.ts b/apps/backend/src/plugins/refreshTokenCleanup.ts new file mode 100644 index 00000000..a198c7de --- /dev/null +++ b/apps/backend/src/plugins/refreshTokenCleanup.ts @@ -0,0 +1,57 @@ +import fp from 'fastify-plugin'; + +import { cleanupExpiredAndRevokedTokens } from '../services/refreshTokenCleanupService.js'; + +import type { FastifyInstance } from 'fastify'; + +export const refreshTokenCleanupPlugin = fp(async (app: FastifyInstance) => { + // Read environment variable for interval configuration + const intervalEnv = process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS; + const defaultInterval = 86_400_000; // 24 hours in milliseconds + let intervalMs = defaultInterval; + + if (intervalEnv !== undefined) { + const parsed = Number(intervalEnv); + + if (Number.isFinite(parsed) && parsed > 0) { + intervalMs = parsed; + } else { + app.log.warn( + `Invalid REFRESH_TOKEN_CLEANUP_INTERVAL_MS value: "${intervalEnv}". Falling back to default: ${defaultInterval}ms` + ); + } + } + + // Execution function with try/catch and logging + const runCleanup = async (): Promise => { + app.log.info('Starting automated refresh token cleanup...'); + try { + const result = await cleanupExpiredAndRevokedTokens(app.prisma); + app.log.info( + { + deletedCount: result.deletedCount, + durationMs: result.durationMs, + }, + 'Refresh token cleanup completed' + ); + } catch (error) { + app.log.error({ err: error }, 'Automated refresh token cleanup failed'); + } + }; + + // 1. Startup cleanup attempt + void runCleanup(); + + // 2. Scheduled cleanup interval setup + app.log.info(`Scheduling automated refresh token cleanup every ${intervalMs}ms`); + const intervalId = setInterval(() => { + // Run cleanup asynchronously + void runCleanup(); + }, intervalMs); + + // 3. Graceful shutdown to avoid timer leaks + app.addHook('onClose', async () => { + clearInterval(intervalId); + app.log.info('Automated refresh token cleanup scheduler stopped'); + }); +}); diff --git a/apps/backend/src/services/refreshTokenCleanupService.ts b/apps/backend/src/services/refreshTokenCleanupService.ts new file mode 100644 index 00000000..e01130c2 --- /dev/null +++ b/apps/backend/src/services/refreshTokenCleanupService.ts @@ -0,0 +1,35 @@ +import type { PrismaClient } from '@prisma/client'; + +export interface CleanupResult { + deletedCount: number; + durationMs: number; +} + +/** + * Clean up expired and revoked refresh tokens from the database. + * Deletes where revokedAt is not null OR expiresAt has passed. + * Active tokens (revokedAt is null AND expiresAt is in the future) are not deleted. + */ +export async function cleanupExpiredAndRevokedTokens( + prisma: PrismaClient +): Promise { + const startTime = Date.now(); + const now = new Date(); + + // Perform deleteMany directly to avoid pre-fetching rows + const result = await prisma.refreshToken.deleteMany({ + where: { + OR: [ + { revokedAt: { not: null } }, + { expiresAt: { lt: now } }, + ], + }, + }); + + const durationMs = Date.now() - startTime; + + return { + deletedCount: result.count, + durationMs, + }; +}