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);
+ });
+});