diff --git a/.gitignore b/.gitignore index cbb1b97..81a87fe 100644 --- a/.gitignore +++ b/.gitignore @@ -8,10 +8,10 @@ # testing /coverage -# next.js -/.next/ -/.next-stale-module-cache/ -/out/ +# next.js +/.next/ +/.next-stale-module-cache/ +/out/ # production /build @@ -43,6 +43,9 @@ next-env.d.ts docker-compose.override.yml *.sql !prisma/migrations/**/*.sql +# Hand-applied schema changes and analysis queries belong in history. +# Backup dumps land in the repo root, so they stay ignored. +!sql/**/*.sql /backups/ # logs @@ -71,22 +74,22 @@ tmp/ .codex-temp/ *.report.html *.report.json -tmp_*.js -test_email.py -meta-fix.js -read-inbox.mjs -quora_antwort_statisch_dynamisch.txt - -# Local blog audit reports and temporary snapshots -scratch_blog_analysis.json -scratch_scored_blog_posts.json -src/lib/blog-data.snapshot-*.ts - -# Local developer-package workspaces and unreferenced generated media -/packages/ -/public/Events/ -/public/Gyms/ -/public/Hotels/ -/public/Real Estate/ -/public/restaurant/ -/.qr-master-api-health-state +tmp_*.js +test_email.py +meta-fix.js +read-inbox.mjs +quora_antwort_statisch_dynamisch.txt + +# Local blog audit reports and temporary snapshots +scratch_blog_analysis.json +scratch_scored_blog_posts.json +src/lib/blog-data.snapshot-*.ts + +# Local developer-package workspaces and unreferenced generated media +/packages/ +/public/Events/ +/public/Gyms/ +/public/Hotels/ +/public/Real Estate/ +/public/restaurant/ +/.qr-master-api-health-state diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7625d65..36406e2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -38,6 +38,11 @@ model User { thirtyDayNudgeSentAt DateTime? limitReachedNudgeSentAt DateTime? firstScanNudgeSentAt DateTime? + qrPulseSentAt DateTime? + + /// When the user last looked at their own scan numbers. A live session is not + /// the same as someone having seen a number, so this is what "inactive" means. + lastAnalyticsViewAt DateTime? // RevOps attribution signupSource String? diff --git a/src/app/(main)/api/analytics/summary/route.ts b/src/app/(main)/api/analytics/summary/route.ts index 1202e71..923306d 100644 --- a/src/app/(main)/api/analytics/summary/route.ts +++ b/src/app/(main)/api/analytics/summary/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getSessionUserId } from '@/lib/session'; import { db } from '@/lib/db'; import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; +import { touchAnalyticsView } from '@/lib/analyticsActivity'; import { TrendData } from '@/types/analytics'; export const dynamic = 'force-dynamic'; @@ -67,6 +68,10 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } + // Serves both the analytics page and the dashboard, so this is the one + // place that knows the user actually saw their numbers. Fire and forget. + touchAnalyticsView(userId); + // Get date range from query params (default: last 30 days) const { searchParams } = request.nextUrl; const range = searchParams.get('range') || '30'; diff --git a/src/lib/analyticsActivity.ts b/src/lib/analyticsActivity.ts new file mode 100644 index 0000000..330e231 --- /dev/null +++ b/src/lib/analyticsActivity.ts @@ -0,0 +1,41 @@ +import { db } from '@/lib/db'; + +/** + * Records that a signed-in user looked at their own scan numbers. + * + * This exists because "inactive" cannot be answered from anything else we + * store. A session can stay alive for weeks without the user ever opening their + * analytics, so a login timestamp would call someone active who has not seen a + * number in a month. PostHog cannot answer it either - capture there is gated on + * cookie consent and runs client-side, so it covers an unknown subset. + * + * Written server-side, on the endpoint that serves the numbers. That endpoint is + * the single choke point for both the analytics page and the dashboard. + */ + +/** Repeat views inside this window do not cause another write. */ +const THROTTLE_MS = 60 * 60 * 1000; + +export function touchAnalyticsView(userId: string): void { + const now = new Date(); + const staleBefore = new Date(now.getTime() - THROTTLE_MS); + + // updateMany, not update: the throttle lives in the WHERE clause, so a repeat + // view inside the window matches no rows instead of racing a read. + db.user + .updateMany({ + where: { + id: userId, + OR: [ + { lastAnalyticsViewAt: null }, + { lastAnalyticsViewAt: { lt: staleBefore } }, + ], + }, + data: { lastAnalyticsViewAt: now }, + }) + .catch((error) => { + // Fire and forget. Analytics must still render if this write fails - it + // also fails harmlessly if the column has not been added yet. + console.error('Failed to record analytics view:', error); + }); +}