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