diff --git a/apps/web/src/actions/track-poll-view.ts b/apps/web/src/actions/track-poll-view.ts new file mode 100644 index 000000000..7161b9934 --- /dev/null +++ b/apps/web/src/actions/track-poll-view.ts @@ -0,0 +1,33 @@ +"use server"; + +import { prisma } from "@rallly/database"; +import { headers } from "next/headers"; + +import { getUserId } from "@/next-auth"; + +/** + * Server action to track a poll view + * Records information about the view in the database + */ +export async function trackPollView(pollId: string) { + try { + const headersList = headers(); + const userAgent = headersList.get("user-agent"); + const ip = headersList.get("x-forwarded-for") || "unknown"; + + const userId = await getUserId(); + await prisma.pollView.create({ + data: { + pollId, + userId, + ipAddress: ip, + userAgent, + }, + }); + + return { success: true }; + } catch (error) { + console.error("Error recording poll view:", error); + return { success: false, error: "Failed to record view" }; + } +} diff --git a/apps/web/src/app/[locale]/invite/[urlId]/invite-page.tsx b/apps/web/src/app/[locale]/invite/[urlId]/invite-page.tsx index f8be656a7..4b4f305fe 100644 --- a/apps/web/src/app/[locale]/invite/[urlId]/invite-page.tsx +++ b/apps/web/src/app/[locale]/invite/[urlId]/invite-page.tsx @@ -6,9 +6,9 @@ import Discussion from "@/components/discussion"; import { EventCard } from "@/components/event-card"; import { PollFooter } from "@/components/poll/poll-footer"; import { PollHeader } from "@/components/poll/poll-header"; +import { PollViewTracker } from "@/components/poll/poll-view-tracker"; import { ResponsiveResults } from "@/components/poll/responsive-results"; import { ScheduledEvent } from "@/components/poll/scheduled-event"; -import { useTouchBeacon } from "@/components/poll/use-touch-beacon"; import { VotingForm } from "@/components/poll/voting-form"; import { Trans } from "@/components/trans"; import { useUser } from "@/components/user-provider"; @@ -51,9 +51,12 @@ const GoToApp = () => { }; export function InvitePage() { - useTouchBeacon(); + const poll = usePoll(); + return (
+ {/* Track poll views */} + diff --git a/apps/web/src/app/[locale]/poll/[urlId]/admin-page.tsx b/apps/web/src/app/[locale]/poll/[urlId]/admin-page.tsx index 84f341609..5395d6f2a 100644 --- a/apps/web/src/app/[locale]/poll/[urlId]/admin-page.tsx +++ b/apps/web/src/app/[locale]/poll/[urlId]/admin-page.tsx @@ -3,18 +3,23 @@ import Discussion from "@/components/discussion"; import { EventCard } from "@/components/event-card"; import { PollFooter } from "@/components/poll/poll-footer"; import { PollHeader } from "@/components/poll/poll-header"; +import { PollViewTracker } from "@/components/poll/poll-view-tracker"; import { ResponsiveResults } from "@/components/poll/responsive-results"; import { ScheduledEvent } from "@/components/poll/scheduled-event"; -import { useTouchBeacon } from "@/components/poll/use-touch-beacon"; import { VotingForm } from "@/components/poll/voting-form"; +import { usePoll } from "@/contexts/poll"; import { GuestPollAlert } from "./guest-poll-alert"; import { UnsubscribeAlert } from "./unsubscribe-alert"; export function AdminPage() { - useTouchBeacon(); + // Get the poll ID from the context + const poll = usePoll(); + return (
+ {/* Track poll views */} + 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 index 6fbdfe640..a6b23c859 100644 --- 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 @@ -13,21 +13,20 @@ export async function POST() { const unauthorized = checkApiAuthorization(); if (unauthorized) return unauthorized; + // Define the 30-day threshold once + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + // 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, + // All poll dates are in the past options: { none: { - startTime: { - gt: new Date(), - }, + startTime: { gt: new Date() }, }, }, - // Include polls without a user or with users that don't have an active subscription + // User is either null or doesn't have an active subscription OR: [ { userId: null }, { @@ -36,6 +35,13 @@ export async function POST() { }, }, ], + // Poll is inactive (not touched AND not viewed in the last 30 days) + touchedAt: { lt: thirtyDaysAgo }, + views: { + none: { + viewedAt: { gte: thirtyDaysAgo }, + }, + }, }, data: { deleted: true, diff --git a/apps/web/src/components/poll/poll-view-tracker.tsx b/apps/web/src/components/poll/poll-view-tracker.tsx new file mode 100644 index 000000000..1c3482ab4 --- /dev/null +++ b/apps/web/src/components/poll/poll-view-tracker.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { safeSessionStorage } from "@rallly/utils/safe-session-storage"; +import { useDebounce } from "react-use"; + +import { trackPollView } from "@/actions/track-poll-view"; + +// Global tracking state to prevent duplicate tracking across re-renders and strict mode +const trackedPolls = new Set(); + +const VIEW_DELAY = 5000; + +/** + * Component that tracks poll views + * This should be included in poll pages to automatically record views + */ +export function PollViewTracker({ pollId }: { pollId: string }) { + // Use debounce to handle the view tracking + // This will only execute after VIEW_DELAY milliseconds + // and will automatically handle cleanup if the component unmounts + useDebounce( + () => { + // Only track views in the browser + if (typeof window === "undefined") return; + + // Check if this poll has already been tracked in this session + if (trackedPolls.has(pollId)) { + return; + } + + // Mark as tracked immediately to prevent duplicate tracking + trackedPolls.add(pollId); + + // Avoid duplicate view tracking within the same browser session + const viewKey = `poll-view-${pollId}`; + const lastView = safeSessionStorage.get(viewKey); + const now = Date.now(); + + // Only track a view if it's been more than 30 minutes since the last view + // or if this is the first view in this session + if (!lastView || now - parseInt(lastView) > 30 * 60 * 1000) { + // Record the view using server action + trackPollView(pollId) + .then(() => { + // Update the last view timestamp + safeSessionStorage.set(viewKey, now.toString()); + }) + .catch(() => { + trackedPolls.delete(pollId); + }); + } + }, + VIEW_DELAY, + [pollId], + ); + + // This component doesn't render anything + return null; +} diff --git a/apps/web/src/components/poll/use-touch-beacon.ts b/apps/web/src/components/poll/use-touch-beacon.ts deleted file mode 100644 index 0085e183e..000000000 --- a/apps/web/src/components/poll/use-touch-beacon.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useMount } from "react-use"; - -import { usePoll } from "@/contexts/poll"; -import { trpc } from "@/trpc/client"; - -/** - * 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 = () => { - const poll = usePoll(); - const touchMutation = trpc.polls.touch.useMutation({ - meta: { doNotInvalidate: true }, - }); - - useMount(() => { - touchMutation.mutate({ pollId: poll.id }); - }); -}; diff --git a/apps/web/src/next-auth.ts b/apps/web/src/next-auth.ts index f280af4c5..e268ea75b 100644 --- a/apps/web/src/next-auth.ts +++ b/apps/web/src/next-auth.ts @@ -219,4 +219,13 @@ const requireUser = async () => { return session?.user; }; +/** + * If email is not set it means the user is a guest + * @returns + */ +export const getUserId = async () => { + const session = await auth(); + return session?.user?.email ? session.user.id : null; +}; + export { auth, handlers, requireUser, signIn, signOut }; diff --git a/apps/web/tests/house-keeping.spec.ts b/apps/web/tests/house-keeping.spec.ts index f0ed7422f..3583599a6 100644 --- a/apps/web/tests/house-keeping.spec.ts +++ b/apps/web/tests/house-keeping.spec.ts @@ -158,6 +158,24 @@ test.describe("House-keeping API", () => { }); createdPollIds.push(oldPollNoUser.id); + // 6. Old poll with recent views from regular user (should NOT be marked as deleted) + const oldPollWithRecentViews = await prisma.poll.create({ + data: { + id: "old-poll-with-recent-views", + title: "Old Poll With Recent Views", + participantUrlId: "old-poll-with-recent-views-participant", + adminUrlId: "old-poll-with-recent-views-admin", + userId: regularUser.id, + touchedAt: dayjs().subtract(35, "day").toDate(), // 35 days old + views: { + create: { + viewedAt: dayjs().subtract(15, "day").toDate(), // Viewed 15 days ago + }, + }, + }, + }); + createdPollIds.push(oldPollWithRecentViews.id); + // Call the delete-inactive-polls endpoint const response = await request.post( `${baseURL}/api/house-keeping/delete-inactive-polls`, @@ -207,6 +225,12 @@ test.describe("House-keeping API", () => { }); expect(updatedOldPollNoUser?.deleted).toBe(true); expect(updatedOldPollNoUser?.deletedAt).not.toBeNull(); + + const updatedOldPollWithRecentViews = await prisma.poll.findUnique({ + where: { id: oldPollWithRecentViews.id }, + }); + expect(updatedOldPollWithRecentViews?.deleted).toBe(false); + expect(updatedOldPollWithRecentViews?.deletedAt).toBeNull(); }); test("should permanently remove polls that have been marked as deleted for more than 7 days", async ({ diff --git a/apps/web/tests/vote-and-comment.spec.ts b/apps/web/tests/vote-and-comment.spec.ts index ba4dc08be..3ab9a4860 100644 --- a/apps/web/tests/vote-and-comment.spec.ts +++ b/apps/web/tests/vote-and-comment.spec.ts @@ -1,5 +1,6 @@ -import type { Page, Request } from "@playwright/test"; +import type { Page } from "@playwright/test"; import { expect, test } from "@playwright/test"; +import { prisma } from "@rallly/database"; import { load } from "cheerio"; import type { PollPage } from "tests/poll-page"; @@ -9,26 +10,64 @@ import { NewPollPage } from "./new-poll-page"; test.describe(() => { let page: Page; let pollPage: PollPage; - let touchRequest: Promise; let editSubmissionUrl: string; + let pollId: string; test.beforeAll(async ({ browser }) => { page = await browser.newPage(); - touchRequest = page.waitForRequest( - (request) => - request.method() === "POST" && - request.url().includes("/api/trpc/polls.touch"), - ); + await page.clock.install(); + const newPollPage = new NewPollPage(page); await newPollPage.goto(); pollPage = await newPollPage.createPollAndCloseDialog({ name: "Monthly Meetup", }); + + // Extract the poll ID from the URL + const url = page.url(); + const match = url.match(/\/poll\/([a-zA-Z0-9]+)/); + pollId = match ? match[1] : ""; + expect(pollId).not.toBe(""); }); - test("should call touch endpoint", async () => { - // make sure call to touch RPC is made - expect(await touchRequest).not.toBeNull(); + test("should record poll view", async () => { + // Fast forward time to trigger view tracking + await page.clock.fastForward(5000); + + let pollViews: Array<{ + id: string; + pollId: string; + ipAddress: string | null; + userId: string | null; + userAgent: string | null; + viewedAt: Date; + }> = []; + + // Retry until we find poll views or timeout + await expect(async () => { + // Query the database for poll views + pollViews = await prisma.pollView.findMany({ + where: { + pollId, + }, + orderBy: { + viewedAt: "desc", + }, + }); + + // This will throw if the condition is not met, causing a retry + expect(pollViews.length).toBeGreaterThan(0); + }).toPass({ + timeout: 5000, // 5 second timeout + intervals: [100, 200, 500, 1000], // Retry intervals in milliseconds + }); + + // Now that we know we have at least one poll view, verify its properties + const latestView = pollViews[0]; + expect(latestView.pollId).toBe(pollId); + expect(latestView.ipAddress).toBeDefined(); + expect(latestView.userAgent).toBeDefined(); + expect(latestView.viewedAt).toBeDefined(); }); test("should be able to comment", async () => { diff --git a/packages/database/prisma/migrations/20250327193922_track_poll_views/migration.sql b/packages/database/prisma/migrations/20250327193922_track_poll_views/migration.sql new file mode 100644 index 000000000..3caefdd27 --- /dev/null +++ b/packages/database/prisma/migrations/20250327193922_track_poll_views/migration.sql @@ -0,0 +1,26 @@ +-- CreateTable +CREATE TABLE "poll_views" ( + "id" TEXT NOT NULL, + "poll_id" TEXT NOT NULL, + "ip_address" TEXT, + "user_id" TEXT, + "user_agent" TEXT, + "viewed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "poll_views_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "poll_views_poll_id_idx" ON "poll_views" USING HASH ("poll_id"); + +-- CreateIndex +CREATE INDEX "poll_views_user_id_idx" ON "poll_views" USING HASH ("user_id"); + +-- CreateIndex +CREATE INDEX "poll_views_viewed_at_idx" ON "poll_views"("viewed_at"); + +-- AddForeignKey +ALTER TABLE "poll_views" ADD CONSTRAINT "poll_views_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "polls"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "poll_views" ADD CONSTRAINT "poll_views_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/database/prisma/migrations/migration_lock.toml b/packages/database/prisma/migrations/migration_lock.toml index fbffa92c2..648c57fd5 100644 --- a/packages/database/prisma/migrations/migration_lock.toml +++ b/packages/database/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually -# It should be added in your version-control system (i.e. Git) +# It should be added in your version-control system (e.g., Git) provider = "postgresql" \ No newline at end of file diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 0748a764d..042a5a141 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -62,6 +62,7 @@ model User { participants Participant[] paymentMethods PaymentMethod[] subscription Subscription? @relation("UserToSubscription") + pollViews PollView[] @@map("users") } @@ -167,6 +168,7 @@ model Poll { watchers Watcher[] comments Comment[] votes Vote[] + views PollView[] @@index([guestId]) @@map("polls") @@ -284,6 +286,23 @@ model Comment { @@map("comments") } +model PollView { + id String @id @default(cuid()) + pollId String @map("poll_id") + ipAddress String? @map("ip_address") + userId String? @map("user_id") + userAgent String? @map("user_agent") + viewedAt DateTime @default(now()) @map("viewed_at") + + poll Poll @relation(fields: [pollId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + + @@index([pollId], type: Hash) + @@index([userId], type: Hash) + @@index([viewedAt]) + @@map("poll_views") +} + model VerificationToken { identifier String @db.Citext token String @unique diff --git a/packages/utils/src/safe-session-storage.ts b/packages/utils/src/safe-session-storage.ts new file mode 100644 index 000000000..53a89f386 --- /dev/null +++ b/packages/utils/src/safe-session-storage.ts @@ -0,0 +1,43 @@ +// Memory fallback when sessionStorage isn't available +const memoryStorage = new Map(); + +/** + * Safe wrapper for sessionStorage with memory fallback + * Handles browser environments, private browsing modes, and SSR + */ +export const safeSessionStorage = { + get(key: string): string | null { + try { + return typeof window !== "undefined" + ? window.sessionStorage.getItem(key) + : memoryStorage.get(key) || null; + } catch (error) { + console.warn("Error accessing sessionStorage:", error); + return memoryStorage.get(key) || null; + } + }, + + set(key: string, value: string): void { + try { + if (typeof window !== "undefined") { + window.sessionStorage.setItem(key, value); + return; + } + } catch (error) { + console.warn("Error setting sessionStorage:", error); + } + memoryStorage.set(key, value); + }, + + remove(key: string): void { + try { + if (typeof window !== "undefined") { + window.sessionStorage.removeItem(key); + return; + } + } catch (error) { + console.warn("Error deleting sessionStorage:", error); + } + memoryStorage.delete(key); + }, +};