diff --git a/prisma/db.ts b/prisma/db.ts index a0d1c5b4b..e7959f27e 100644 --- a/prisma/db.ts +++ b/prisma/db.ts @@ -1,5 +1,7 @@ import { PrismaClient } from "@prisma/client"; +import { softDeleteMiddleware } from "./middlewares/softDeleteMiddleware"; + declare global { // allow global `var` declarations // eslint-disable-next-line no-var @@ -8,4 +10,6 @@ declare global { export const prisma = global.prisma || new PrismaClient(); +softDeleteMiddleware(prisma, "Poll"); + if (process.env.NODE_ENV !== "production") global.prisma = prisma; diff --git a/prisma/middlewares/softDeleteMiddleware.ts b/prisma/middlewares/softDeleteMiddleware.ts new file mode 100644 index 000000000..85f53f952 --- /dev/null +++ b/prisma/middlewares/softDeleteMiddleware.ts @@ -0,0 +1,48 @@ +import { Prisma, PrismaClient } from "@prisma/client"; + +export const softDeleteMiddleware = ( + prisma: PrismaClient, + model: Prisma.ModelName, +) => { + prisma.$use(async (params, next) => { + // We use middleware to handle soft deletes + // See: https://www.prisma.io/docs/concepts/components/prisma-client/middleware/soft-delete-middleware + if (params.model === model) { + if (params.action === "delete") { + // Delete queries + // Change action to an update + params.action = "update"; + params.args["data"] = { deleted: true }; + } + if (params.action == "deleteMany") { + // Delete many queries + params.action = "updateMany"; + if (params.args.data != undefined) { + params.args.data["deleted"] = true; + } else { + params.args["data"] = { deleted: true }; + } + } + if (params.action === "findUnique" || params.action === "findFirst") { + // Change to findFirst - you cannot filter + // by anything except ID / unique with findUnique + params.action = "findFirst"; + // Add 'deleted' filter + // ID filter maintained + params.args.where["deleted"] = false; + } + if (params.action === "findMany") { + // Find many queries + if (params.args.where) { + if (params.args.where.deleted == undefined) { + // Exclude deleted records if they have not been explicitly requested + params.args.where["deleted"] = false; + } + } else { + params.args["where"] = { deleted: false }; + } + } + } + return next(params); + }); +}; diff --git a/prisma/migrations/20220519075453_add_delete_column/migration.sql b/prisma/migrations/20220519075453_add_delete_column/migration.sql new file mode 100644 index 000000000..a62246a56 --- /dev/null +++ b/prisma/migrations/20220519075453_add_delete_column/migration.sql @@ -0,0 +1,44 @@ +-- DropForeignKey +ALTER TABLE "comments" DROP CONSTRAINT "comments_poll_id_fkey"; + +-- DropForeignKey +ALTER TABLE "links" DROP CONSTRAINT "links_poll_id_fkey"; + +-- DropForeignKey +ALTER TABLE "options" DROP CONSTRAINT "options_poll_id_fkey"; + +-- DropForeignKey +ALTER TABLE "participants" DROP CONSTRAINT "participants_poll_id_fkey"; + +-- DropForeignKey +ALTER TABLE "votes" DROP CONSTRAINT "votes_option_id_fkey"; + +-- DropForeignKey +ALTER TABLE "votes" DROP CONSTRAINT "votes_participant_id_fkey"; + +-- DropForeignKey +ALTER TABLE "votes" DROP CONSTRAINT "votes_poll_id_fkey"; + +-- AlterTable +ALTER TABLE "polls" ADD COLUMN "deleted" BOOLEAN NOT NULL DEFAULT false; + +-- AddForeignKey +ALTER TABLE "links" ADD CONSTRAINT "links_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "polls"("url_id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "participants" ADD CONSTRAINT "participants_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "polls"("url_id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "options" ADD CONSTRAINT "options_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "polls"("url_id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "votes" ADD CONSTRAINT "votes_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "polls"("url_id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "votes" ADD CONSTRAINT "votes_participant_id_fkey" FOREIGN KEY ("participant_id") REFERENCES "participants"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "votes" ADD CONSTRAINT "votes_option_id_fkey" FOREIGN KEY ("option_id") REFERENCES "options"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "comments" ADD CONSTRAINT "comments_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "polls"("url_id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20220520115326_add_touch_column/migration.sql b/prisma/migrations/20220520115326_add_touch_column/migration.sql new file mode 100644 index 000000000..6373917a4 --- /dev/null +++ b/prisma/migrations/20220520115326_add_touch_column/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "polls" ADD COLUMN "touched_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 495c9ce2f..6186a7663 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -49,6 +49,8 @@ model Poll { legacy Boolean @default(false) closed Boolean @default(false) notifications Boolean @default(false) + deleted Boolean @default(false) + touchedAt DateTime @default(now()) @map("touched_at") @@map("polls") } @@ -64,7 +66,7 @@ model Link { urlId String @id @unique @map("url_id") role Role pollId String @map("poll_id") - poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade) + poll Poll @relation(fields: [pollId], references: [urlId]) createdAt DateTime @default(now()) @map("created_at") @@unique([pollId, role]) @@ -77,7 +79,7 @@ model Participant { user User? @relation(fields: [userId], references: [id]) userId String? @map("user_id") guestId String? @map("guest_id") - poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade) + poll Poll @relation(fields: [pollId], references: [urlId]) pollId String @map("poll_id") votes Vote[] createdAt DateTime @default(now()) @map("created_at") @@ -91,7 +93,7 @@ model Option { id String @id @default(cuid()) value String pollId String @map("poll_id") - poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade) + poll Poll @relation(fields: [pollId], references: [urlId]) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime? @updatedAt @map("updated_at") votes Vote[] @@ -109,11 +111,11 @@ enum VoteType { model Vote { id String @id @default(cuid()) - participant Participant @relation(fields: [participantId], references: [id], onDelete: Cascade) + participant Participant @relation(fields: [participantId], references: [id]) participantId String @map("participant_id") - option Option @relation(fields: [optionId], references: [id], onDelete: Cascade) + option Option @relation(fields: [optionId], references: [id]) optionId String @map("option_id") - poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade) + poll Poll @relation(fields: [pollId], references: [urlId]) pollId String @map("poll_id") type VoteType @default(yes) createdAt DateTime @default(now()) @map("created_at") @@ -125,7 +127,7 @@ model Vote { model Comment { id String @id @default(cuid()) content String - poll Poll @relation(fields: [pollId], references: [urlId], onDelete: Cascade) + poll Poll @relation(fields: [pollId], references: [urlId]) pollId String @map("poll_id") authorName String @map("author_name") user User? @relation(fields: [userId], references: [id]) diff --git a/public/locales/en/app.json b/public/locales/en/app.json index 09db6d730..272fe0805 100644 --- a/public/locales/en/app.json +++ b/public/locales/en/app.json @@ -56,5 +56,6 @@ "ifNeedBe": "If need be", "areYouSure": "Are you sure?", "deletePollDescription": "All data related to this poll will be deleted. This action cannot be undone. To confirm, please type “{{confirmText}}” in to the input below:", - "deletePoll": "Delete poll" + "deletePoll": "Delete poll", + "demoPollNotice": "Demo polls are automatically deleted after 1 day" } diff --git a/src/components/badge.tsx b/src/components/badge.tsx index 5c114b219..4b452e260 100644 --- a/src/components/badge.tsx +++ b/src/components/badge.tsx @@ -3,17 +3,19 @@ import React from "react"; const Badge: React.VoidFunctionComponent<{ children?: React.ReactNode; - color?: "gray" | "amber" | "green"; + color?: "gray" | "amber" | "green" | "red" | "blue"; className?: string; }> = ({ children, color = "gray", className }) => { return (
{ const { participants } = useParticipants(); const router = useRouter(); + useTouchBeacon(poll.pollId); + useMount(() => { const path = poll.role === "admin" ? "admin" : "p"; diff --git a/src/components/poll/poll-subheader.tsx b/src/components/poll/poll-subheader.tsx index bc3455c6e..e2ce14505 100644 --- a/src/components/poll/poll-subheader.tsx +++ b/src/components/poll/poll-subheader.tsx @@ -33,7 +33,7 @@ const PollSubheader: React.VoidFunctionComponent = () => { />   - {poll.role === "admin" ? ( + {poll.role === "admin" && !poll.demo ? ( poll.verified ? ( Verified ) : ( @@ -86,6 +86,11 @@ const PollSubheader: React.VoidFunctionComponent = () => { Legacy ) : null} + {poll.demo ? ( + }> + Demo + + ) : null}
 •  diff --git a/src/components/poll/use-touch-beacon.ts b/src/components/poll/use-touch-beacon.ts new file mode 100644 index 000000000..98718ae87 --- /dev/null +++ b/src/components/poll/use-touch-beacon.ts @@ -0,0 +1,15 @@ +import { useMount } from "react-use"; + +import { trpc } from "../../utils/trpc"; + +/** + * Touching a poll updates a column with the current date. This information is used to + * find polls that haven't been accessed for some time so that they can be deleted by house keeping. + */ +export const useTouchBeacon = (pollId: string) => { + const touchMutation = trpc.useMutation(["polls.touch"]); + + useMount(() => { + touchMutation.mutate({ pollId }); + }); +}; diff --git a/src/pages/api/house-keeping.ts b/src/pages/api/house-keeping.ts new file mode 100644 index 000000000..ed69ab7f4 --- /dev/null +++ b/src/pages/api/house-keeping.ts @@ -0,0 +1,153 @@ +import { Prisma } from "@prisma/client"; +import { addDays } from "date-fns"; +import { NextApiRequest, NextApiResponse } from "next"; + +import { prisma } from "~/prisma/db"; + +/** + * This endpoint will permanently delete polls that: + * * have been soft deleted OR + * * are demo polls that are older than 1 day + * All dependant records are also deleted. + */ +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + if (req.method !== "POST") { + res.setHeader("Allow", "POST"); + res.status(405).end("Method not allowed"); + return; + } + + // Batch size is the max number of polls we will attempt to delete + // We can adjust this value through the batchSize query param + const parsedBatchSizeQueryParam = + typeof req.query["batchSize"] === "string" + ? parseInt(req.query["batchSize"]) + : NaN; + + // If not specified we default to a max of 500 polls + const batchSize = !isNaN(parsedBatchSizeQueryParam) + ? parsedBatchSizeQueryParam + : 500; + + const { authorization } = req.headers; + + if (authorization !== `Bearer ${process.env.API_SECRET}`) { + res.status(401).json({ success: false }); + return; + } + + const pollIds = ( + await prisma.poll.findMany({ + where: { + OR: [ + { + deleted: true, + }, + // demo polls that are 1 day old + { + demo: true, + createdAt: { + lte: addDays(new Date(), -1), + }, + }, + // polls that have not been accessed for over 30 days + { + touchedAt: { + lte: addDays(new Date(), -30), + }, + }, + ], + }, + select: { + urlId: true, + }, + orderBy: { + createdAt: "asc", // oldest first + }, + take: batchSize, + }) + ).map(({ urlId }) => urlId); + + if (pollIds.length !== 0) { + // Delete links + await prisma.$transaction([ + prisma.$executeRawUnsafe("ALTER TABLE links DISABLE TRIGGER ALL"), + prisma.link.deleteMany({ + where: { + pollId: { + in: pollIds, + }, + }, + }), + prisma.$executeRawUnsafe("ALTER TABLE links ENABLE TRIGGER ALL"), + ]); + + // Delete comments + await prisma.$transaction([ + prisma.$executeRawUnsafe("ALTER TABLE comments DISABLE TRIGGER ALL"), + prisma.comment.deleteMany({ + where: { + pollId: { + in: pollIds, + }, + }, + }), + prisma.$executeRawUnsafe("ALTER TABLE comments ENABLE TRIGGER ALL"), + ]); + + await prisma.$transaction([ + prisma.$executeRawUnsafe("ALTER TABLE votes DISABLE TRIGGER ALL"), + prisma.vote.deleteMany({ + where: { + pollId: { + in: pollIds, + }, + }, + }), + prisma.$executeRawUnsafe("ALTER TABLE votes ENABLE TRIGGER ALL"), + ]); + + // Delete participants + await prisma.$transaction([ + prisma.$executeRawUnsafe("ALTER TABLE participants DISABLE TRIGGER ALL"), + prisma.participant.deleteMany({ + where: { + pollId: { + in: pollIds, + }, + }, + }), + prisma.$executeRawUnsafe("ALTER TABLE participants ENABLE TRIGGER ALL"), + ]); + + // Delete options + await prisma.$transaction([ + prisma.$executeRawUnsafe("ALTER TABLE options DISABLE TRIGGER ALL"), + prisma.option.deleteMany({ + where: { + pollId: { + in: pollIds, + }, + }, + }), + prisma.$executeRawUnsafe("ALTER TABLE options ENABLE TRIGGER ALL"), + ]); + + // Delete polls + await prisma.$transaction([ + prisma.$executeRawUnsafe("ALTER TABLE polls DISABLE TRIGGER ALL"), + // Using raw query to bypass soft delete middleware + prisma.$executeRaw`DELETE FROM polls WHERE url_id IN (${Prisma.join( + pollIds, + )})`, + prisma.$executeRawUnsafe("ALTER TABLE polls ENABLE TRIGGER ALL"), + ]); + } + + res.status(200).json({ + deleted: pollIds, + }); +} diff --git a/src/server/routers/polls.ts b/src/server/routers/polls.ts index 1f1fe102f..fdae32478 100644 --- a/src/server/routers/polls.ts +++ b/src/server/routers/polls.ts @@ -217,4 +217,19 @@ export const polls = createRouter() await prisma.poll.delete({ where: { urlId: link.pollId } }); }, + }) + .mutation("touch", { + input: z.object({ + pollId: z.string(), + }), + resolve: async ({ input: { pollId } }) => { + await prisma.poll.update({ + where: { + urlId: pollId, + }, + data: { + touchedAt: new Date(), + }, + }); + }, }); diff --git a/src/server/routers/session.ts b/src/server/routers/session.ts index 4d0dd0363..d6210e59f 100644 --- a/src/server/routers/session.ts +++ b/src/server/routers/session.ts @@ -10,10 +10,19 @@ export const session = createRouter() where: { id: ctx.session.user.id }, }); - return user - ? { id: user.id, name: user.name, email: user.email, isGuest: false } - : null; + if (!user) { + ctx.session.destroy(); + return null; + } + + return { + id: user.id, + name: user.name, + email: user.email, + isGuest: false, + }; } + return ctx.session.user; }, }) diff --git a/tests/vote-and-comment.spec.ts b/tests/vote-and-comment.spec.ts index cb2f25299..a922141ac 100644 --- a/tests/vote-and-comment.spec.ts +++ b/tests/vote-and-comment.spec.ts @@ -1,6 +1,12 @@ import { expect, test } from "@playwright/test"; test("should be able to vote and comment on a poll", async ({ page }) => { + const touchRequest = page.waitForRequest( + (request) => + request.method() === "POST" && + request.url().includes("/api/trpc/polls.touch"), + ); + await page.goto("/demo"); await expect(page.locator('text="Lunch Meeting Demo"')).toBeVisible(); @@ -26,4 +32,7 @@ test("should be able to vote and comment on a poll", async ({ page }) => { const comment = page.locator("data-testid=comment"); await expect(comment.locator("text='This is a comment!'")).toBeVisible(); await expect(comment.locator("text=You")).toBeVisible(); + + // make sure call to touch RPC is made + await touchRequest; });