♻️ Refactor poll view tracking (#1644)

This commit is contained in:
Luke Vella 2025-03-28 10:10:46 +00:00 committed by GitHub
parent 6b914610d9
commit f05f437b56
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 288 additions and 41 deletions

View file

@ -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" };
}
}

View file

@ -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 (
<div className="mx-auto max-w-4xl space-y-3 p-3 lg:space-y-4 lg:px-4 lg:py-8">
{/* Track poll views */}
<PollViewTracker pollId={poll.id} />
<PollHeader />
<GoToApp />
<EventCard />

View file

@ -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 (
<div className="space-y-3 lg:space-y-4">
{/* Track poll views */}
<PollViewTracker pollId={poll.id} />
<UnsubscribeAlert />
<PollHeader />
<GuestPollAlert />

View file

@ -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,

View file

@ -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<string>();
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;
}

View file

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

View file

@ -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 };

View file

@ -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 ({

View file

@ -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<Request>;
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 () => {

View file

@ -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;

View file

@ -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"

View file

@ -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

View file

@ -0,0 +1,43 @@
// Memory fallback when sessionStorage isn't available
const memoryStorage = new Map<string, string>();
/**
* 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);
},
};