mirror of
https://github.com/lukevella/rallly.git
synced 2025-06-01 10:11:50 +02:00
♻️ Refactor poll view tracking (#1644)
This commit is contained in:
parent
6b914610d9
commit
f05f437b56
13 changed files with 288 additions and 41 deletions
33
apps/web/src/actions/track-poll-view.ts
Normal file
33
apps/web/src/actions/track-poll-view.ts
Normal 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" };
|
||||
}
|
||||
}
|
|
@ -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 />
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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,
|
||||
|
|
59
apps/web/src/components/poll/poll-view-tracker.tsx
Normal file
59
apps/web/src/components/poll/poll-view-tracker.tsx
Normal 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;
|
||||
}
|
|
@ -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 });
|
||||
});
|
||||
};
|
|
@ -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 };
|
||||
|
|
|
@ -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 ({
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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;
|
|
@ -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"
|
|
@ -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
|
||||
|
|
43
packages/utils/src/safe-session-storage.ts
Normal file
43
packages/utils/src/safe-session-storage.ts
Normal 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);
|
||||
},
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue