diff --git a/apps/web/.env.test b/apps/web/.env.test index dace8322a..d5d544883 100644 --- a/apps/web/.env.test +++ b/apps/web/.env.test @@ -6,4 +6,5 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5450/rallly SUPPORT_EMAIL=support@rallly.co SMTP_HOST=0.0.0.0 SMTP_PORT=1025 -QUICK_CREATE_ENABLED=true \ No newline at end of file +QUICK_CREATE_ENABLED=true +API_SECRET=1234567890abcdef1234567890abcdef1234 diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 725dd6f24..40c3d6809 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,5 @@ /// /// -/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/apps/web/src/app/api/house-keeping/[task]/route.ts b/apps/web/src/app/api/house-keeping/[task]/route.ts deleted file mode 100644 index efdfe1641..000000000 --- a/apps/web/src/app/api/house-keeping/[task]/route.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { prisma } from "@rallly/database"; -import { headers } from "next/headers"; -import { NextResponse } from "next/server"; - -export async function POST(req: Request, ctx: { params: { task: string } }) { - const headersList = headers(); - const authorization = headersList.get("authorization"); - - if (authorization !== `Bearer ${process.env.API_SECRET}`) { - return NextResponse.json( - { success: false }, - { - status: 401, - }, - ); - } - - switch (ctx.params.task) { - case "delete-inactive-polls": { - return await deleteInactivePolls(); - } - case "remove-deleted-polls": { - return await removeDeletedPolls(req); - } - } -} - -/** - * Marks inactive polls as deleted. Polls are inactive if they have not been - * touched in the last 30 days and all dates are in the past. - */ -async function deleteInactivePolls() { - const markedDeleted = await prisma.$executeRaw` - UPDATE polls p - SET - deleted = true, - deleted_at = NOW() - WHERE touched_at < NOW() - INTERVAL '30 days' - AND deleted = false - AND id NOT IN ( - SELECT poll_id - FROM options - WHERE poll_id = p.id - AND start_time > NOW() - ) - AND user_id NOT IN ( - SELECT id - FROM users - WHERE id IN ( - SELECT user_id - FROM user_payment_data - WHERE end_date > NOW() - ) - OR subscription_id IN ( - SELECT subscription_id - FROM subscriptions - WHERE active = true - ) - ); - `; - - return NextResponse.json({ - success: true, - summary: { - markedDeleted, - }, - }); -} - -/** - * Remove polls and corresponding data that have been marked deleted for more than 7 days. - */ -async function removeDeletedPolls(req: Request) { - const options = (await req.json()) as { take?: number } | undefined; - // First get the ids of all the polls that have been marked as deleted for at least 7 days - const deletedPolls = await prisma.poll.findMany({ - select: { - id: true, - }, - where: { - deleted: true, - deletedAt: { - lt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), - }, - }, - take: options?.take ?? 1000, - }); - - const deletedPollIds = deletedPolls.map((poll) => poll.id); - - const { count: deletedPollCount } = await prisma.poll.deleteMany({ - where: { - id: { - in: deletedPollIds, - }, - }, - }); - - return NextResponse.json({ - success: true, - summary: { - deleted: { - polls: deletedPollCount, - }, - }, - }); -} diff --git a/apps/web/src/app/api/house-keeping/delete-inactive-polls/route.ts b/apps/web/src/app/api/house-keeping/delete-inactive-polls/route.ts new file mode 100644 index 000000000..6fbdfe640 --- /dev/null +++ b/apps/web/src/app/api/house-keeping/delete-inactive-polls/route.ts @@ -0,0 +1,52 @@ +import { prisma } from "@rallly/database"; +import { NextResponse } from "next/server"; + +import { checkApiAuthorization } from "@/utils/api-auth"; + +/** + * Marks inactive polls as deleted. Polls are inactive if they have not been + * touched in the last 30 days and all dates are in the past. + * Only marks polls as deleted if they belong to users without an active subscription + * or if they don't have a user associated with them. + */ +export async function POST() { + const unauthorized = checkApiAuthorization(); + if (unauthorized) return unauthorized; + + // Mark inactive polls as deleted in a single query + const { count: markedDeleted } = await prisma.poll.updateMany({ + where: { + touchedAt: { + lt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), // 30 days ago + }, + deleted: false, + options: { + none: { + startTime: { + gt: new Date(), + }, + }, + }, + // Include polls without a user or with users that don't have an active subscription + OR: [ + { userId: null }, + { + user: { + OR: [{ subscription: null }, { subscription: { active: false } }], + }, + }, + ], + }, + data: { + deleted: true, + deletedAt: new Date(), + }, + }); + + return NextResponse.json({ + success: true, + summary: { + markedDeleted, + }, + }); +} diff --git a/apps/web/src/app/api/house-keeping/remove-deleted-polls/route.ts b/apps/web/src/app/api/house-keeping/remove-deleted-polls/route.ts new file mode 100644 index 000000000..c420883e6 --- /dev/null +++ b/apps/web/src/app/api/house-keeping/remove-deleted-polls/route.ts @@ -0,0 +1,47 @@ +import { prisma } from "@rallly/database"; +import { NextResponse } from "next/server"; + +import { checkApiAuthorization } from "@/utils/api-auth"; + +/** + * Remove polls and corresponding data that have been marked deleted for more than 7 days. + */ +export async function POST(req: Request) { + const unauthorized = checkApiAuthorization(); + if (unauthorized) return unauthorized; + + const options = (await req.json()) as { take?: number } | undefined; + + // First get the ids of all the polls that have been marked as deleted for at least 7 days + const deletedPolls = await prisma.poll.findMany({ + select: { + id: true, + }, + where: { + deleted: true, + deletedAt: { + lt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), + }, + }, + take: options?.take ?? 1000, + }); + + const deletedPollIds = deletedPolls.map((poll) => poll.id); + + const { count: deletedPollCount } = await prisma.poll.deleteMany({ + where: { + id: { + in: deletedPollIds, + }, + }, + }); + + return NextResponse.json({ + success: true, + summary: { + deleted: { + polls: deletedPollCount, + }, + }, + }); +} diff --git a/apps/web/src/utils/api-auth.ts b/apps/web/src/utils/api-auth.ts new file mode 100644 index 000000000..bee87c36e --- /dev/null +++ b/apps/web/src/utils/api-auth.ts @@ -0,0 +1,20 @@ +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; + +/** + * Checks if the request is authorized using the API secret + * @returns A NextResponse with 401 status if unauthorized, or null if authorized + */ +export function checkApiAuthorization() { + const headersList = headers(); + const authorization = headersList.get("authorization"); + + if (authorization !== `Bearer ${process.env.API_SECRET}`) { + return NextResponse.json( + { success: false, message: "Unauthorized" }, + { status: 401 }, + ); + } + + return null; +} diff --git a/apps/web/tests/house-keeping.spec.ts b/apps/web/tests/house-keeping.spec.ts new file mode 100644 index 000000000..f0ed7422f --- /dev/null +++ b/apps/web/tests/house-keeping.spec.ts @@ -0,0 +1,273 @@ +import { expect, test } from "@playwright/test"; +import { prisma } from "@rallly/database"; +import dayjs from "dayjs"; + +/** + * This test suite tests the house-keeping API endpoints: + * 1. delete-inactive-polls: Marks inactive polls as deleted + * 2. remove-deleted-polls: Permanently removes polls that have been marked as deleted for more than 7 days + */ +test.describe("House-keeping API", () => { + // Store created poll IDs for cleanup + const createdPollIds: string[] = []; + const createdUserIds: string[] = []; + + // API Secret for authentication + const API_SECRET = process.env.API_SECRET; + + test.beforeAll(async () => { + // Clean up any existing test data + await cleanup(); + }); + + test.afterAll(async () => { + // Clean up test data + await cleanup(); + }); + + async function cleanup() { + // Delete test users - related polls will be deleted automatically due to cascade + if (createdUserIds.length > 0) { + await prisma.user.deleteMany({ + where: { + id: { + in: createdUserIds, + }, + }, + }); + createdUserIds.length = 0; + } + + // Delete polls that don't have a user (not covered by cascade delete) + if (createdPollIds.length > 0) { + await prisma.poll.deleteMany({ + where: { + id: { + in: createdPollIds, + }, + userId: null, + }, + }); + createdPollIds.length = 0; + } + } + + test("should mark inactive polls as deleted for users without active subscriptions", async ({ + request, + baseURL, + }) => { + // Create test users + const regularUser = await prisma.user.create({ + data: { + name: "Regular User", + email: "regular-user@example.com", + }, + }); + createdUserIds.push(regularUser.id); + + const proUser = await prisma.user.create({ + data: { + name: "Pro User", + email: "pro-user@example.com", + subscription: { + create: { + id: "sub_test_pro", + priceId: "price_test", + amount: 1000, + status: "active", + active: true, + currency: "USD", + interval: "month", + periodStart: new Date(), + periodEnd: dayjs().add(30, "day").toDate(), + }, + }, + }, + }); + createdUserIds.push(proUser.id); + + // Create test polls + + // 1. Old poll from regular user (should be marked as deleted) + const oldPollRegularUser = await prisma.poll.create({ + data: { + id: "old-poll-regular-user", + title: "Old Poll Regular User", + participantUrlId: "old-poll-regular-user-participant", + adminUrlId: "old-poll-regular-user-admin", + userId: regularUser.id, + touchedAt: dayjs().subtract(35, "day").toDate(), // 35 days old + }, + }); + createdPollIds.push(oldPollRegularUser.id); + + // 2. Old poll from pro user (should NOT be marked as deleted) + const oldPollProUser = await prisma.poll.create({ + data: { + id: "old-poll-pro-user", + title: "Old Poll Pro User", + participantUrlId: "old-poll-pro-user-participant", + adminUrlId: "old-poll-pro-user-admin", + userId: proUser.id, + touchedAt: dayjs().subtract(35, "day").toDate(), // 35 days old + }, + }); + createdPollIds.push(oldPollProUser.id); + + // 3. Recent poll from regular user (should NOT be marked as deleted) + const recentPollRegularUser = await prisma.poll.create({ + data: { + id: "recent-poll-regular-user", + title: "Recent Poll Regular User", + participantUrlId: "recent-poll-regular-user-participant", + adminUrlId: "recent-poll-regular-user-admin", + userId: regularUser.id, + touchedAt: dayjs().subtract(15, "day").toDate(), // 15 days old + }, + }); + createdPollIds.push(recentPollRegularUser.id); + + // 4. Old poll with future options from regular user (should NOT be marked as deleted) + const oldPollWithFutureOptions = await prisma.poll.create({ + data: { + id: "old-poll-with-future-options", + title: "Old Poll With Future Options", + participantUrlId: "old-poll-with-future-options-participant", + adminUrlId: "old-poll-with-future-options-admin", + userId: regularUser.id, + touchedAt: dayjs().subtract(35, "day").toDate(), // 35 days old + options: { + create: { + startTime: dayjs().add(10, "day").toDate(), // Future date + duration: 60, + }, + }, + }, + }); + createdPollIds.push(oldPollWithFutureOptions.id); + + // 5. Old poll without a user (should be marked as deleted) + const oldPollNoUser = await prisma.poll.create({ + data: { + id: "old-poll-no-user", + title: "Old Poll No User", + participantUrlId: "old-poll-no-user-participant", + adminUrlId: "old-poll-no-user-admin", + touchedAt: dayjs().subtract(35, "day").toDate(), // 35 days old + }, + }); + createdPollIds.push(oldPollNoUser.id); + + // Call the delete-inactive-polls endpoint + const response = await request.post( + `${baseURL}/api/house-keeping/delete-inactive-polls`, + { + headers: { + Authorization: `Bearer ${API_SECRET}`, + }, + }, + ); + + expect(response.ok()).toBeTruthy(); + const responseData = await response.json(); + expect(responseData.success).toBeTruthy(); + + // We expect 2 polls to be marked as deleted: + // - Old poll from regular user + // - Old poll without a user + expect(responseData.summary.markedDeleted).toBe(2); + + // Verify the state of each poll + const updatedOldPollRegularUser = await prisma.poll.findUnique({ + where: { id: oldPollRegularUser.id }, + }); + expect(updatedOldPollRegularUser?.deleted).toBe(true); + expect(updatedOldPollRegularUser?.deletedAt).not.toBeNull(); + + const updatedOldPollProUser = await prisma.poll.findUnique({ + where: { id: oldPollProUser.id }, + }); + expect(updatedOldPollProUser?.deleted).toBe(false); + expect(updatedOldPollProUser?.deletedAt).toBeNull(); + + const updatedRecentPollRegularUser = await prisma.poll.findUnique({ + where: { id: recentPollRegularUser.id }, + }); + expect(updatedRecentPollRegularUser?.deleted).toBe(false); + expect(updatedRecentPollRegularUser?.deletedAt).toBeNull(); + + const updatedOldPollWithFutureOptions = await prisma.poll.findUnique({ + where: { id: oldPollWithFutureOptions.id }, + }); + expect(updatedOldPollWithFutureOptions?.deleted).toBe(false); + expect(updatedOldPollWithFutureOptions?.deletedAt).toBeNull(); + + const updatedOldPollNoUser = await prisma.poll.findUnique({ + where: { id: oldPollNoUser.id }, + }); + expect(updatedOldPollNoUser?.deleted).toBe(true); + expect(updatedOldPollNoUser?.deletedAt).not.toBeNull(); + }); + + test("should permanently remove polls that have been marked as deleted for more than 7 days", async ({ + request, + baseURL, + }) => { + // Create a poll that was marked as deleted more than 7 days ago + const oldDeletedPoll = await prisma.poll.create({ + data: { + id: "old-deleted-poll", + title: "Old Deleted Poll", + participantUrlId: "old-deleted-poll-participant", + adminUrlId: "old-deleted-poll-admin", + deleted: true, + deletedAt: dayjs().subtract(8, "day").toDate(), // Deleted 8 days ago + }, + }); + createdPollIds.push(oldDeletedPoll.id); + + // Create a poll that was marked as deleted less than 7 days ago + const recentDeletedPoll = await prisma.poll.create({ + data: { + id: "recent-deleted-poll", + title: "Recent Deleted Poll", + participantUrlId: "recent-deleted-poll-participant", + adminUrlId: "recent-deleted-poll-admin", + deleted: true, + deletedAt: dayjs().subtract(3, "day").toDate(), // Deleted 3 days ago + }, + }); + createdPollIds.push(recentDeletedPoll.id); + + // Call the remove-deleted-polls endpoint + const response = await request.post( + `${baseURL}/api/house-keeping/remove-deleted-polls`, + { + headers: { + Authorization: `Bearer ${API_SECRET}`, + }, + data: {}, // Empty JSON body + }, + ); + + expect(response.ok()).toBeTruthy(); + const responseData = await response.json(); + expect(responseData.success).toBeTruthy(); + + // We expect 1 poll to be permanently deleted + expect(responseData.summary.deleted.polls).toBe(1); + + // Verify that the old deleted poll is gone + const checkOldDeletedPoll = await prisma.poll.findUnique({ + where: { id: oldDeletedPoll.id }, + }); + expect(checkOldDeletedPoll).toBeNull(); + + // Verify that the recent deleted poll still exists + const checkRecentDeletedPoll = await prisma.poll.findUnique({ + where: { id: recentDeletedPoll.id }, + }); + expect(checkRecentDeletedPoll).not.toBeNull(); + expect(checkRecentDeletedPoll?.deleted).toBe(true); + }); +});