mirror of
https://github.com/lukevella/rallly.git
synced 2025-04-29 18:26:34 +02:00
♻️ Update house keeping (#1591)
This commit is contained in:
parent
b173cc78a0
commit
c25ee09855
7 changed files with 394 additions and 109 deletions
|
@ -6,4 +6,5 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5450/rallly
|
||||||
SUPPORT_EMAIL=support@rallly.co
|
SUPPORT_EMAIL=support@rallly.co
|
||||||
SMTP_HOST=0.0.0.0
|
SMTP_HOST=0.0.0.0
|
||||||
SMTP_PORT=1025
|
SMTP_PORT=1025
|
||||||
QUICK_CREATE_ENABLED=true
|
QUICK_CREATE_ENABLED=true
|
||||||
|
API_SECRET=1234567890abcdef1234567890abcdef1234
|
||||||
|
|
1
apps/web/next-env.d.ts
vendored
1
apps/web/next-env.d.ts
vendored
|
@ -1,6 +1,5 @@
|
||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
/// <reference types="next/navigation-types/compat/navigation" />
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||||
|
|
|
@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
20
apps/web/src/utils/api-auth.ts
Normal file
20
apps/web/src/utils/api-auth.ts
Normal file
|
@ -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;
|
||||||
|
}
|
273
apps/web/tests/house-keeping.spec.ts
Normal file
273
apps/web/tests/house-keeping.spec.ts
Normal file
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Add table
Reference in a new issue