Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
cad016b
feat(web): add anonymous name generator utility
richiemcilroy Mar 3, 2026
fbaa359
feat(database): add anon_view notification type and data fields
richiemcilroy Mar 3, 2026
d47a072
feat(web-api-contract): add anon_view variant to Notification schema
richiemcilroy Mar 3, 2026
7d7bfc4
feat(web): add anonymous view notification creation
richiemcilroy Mar 3, 2026
a11e2da
feat(api): trigger anonymous view notifications on video views
richiemcilroy Mar 3, 2026
b1d7d19
feat(api): handle anon_view type in notifications endpoint
richiemcilroy Mar 3, 2026
8d8229f
feat(dashboard): add anon_view to notification filter matching
richiemcilroy Mar 3, 2026
2ca2f56
feat(dashboard): render anonymous viewer notifications
richiemcilroy Mar 3, 2026
93a7d14
feat(database): add dedupKey column and unique index to notifications
richiemcilroy Mar 3, 2026
83031ad
refactor(web): use SHA-256 for anonymous session hashing
richiemcilroy Mar 3, 2026
4962452
style(web): remove comments from notification helpers
richiemcilroy Mar 3, 2026
9f35e61
refactor(web): simplify anon view notification with joined query and …
richiemcilroy Mar 3, 2026
530e728
feat(api): add rate limiting and filtering for anon view notifications
richiemcilroy Mar 3, 2026
04dd97d
fix(api): handle anon_view type correctly in notifications endpoint
richiemcilroy Mar 3, 2026
c1f59ee
refactor(web): harden anonymous-names with server-only guard and SHA-…
richiemcilroy Mar 3, 2026
57a49e9
feat(web): add transaction-based dedup and rate limiting for anon vie…
richiemcilroy Mar 3, 2026
cfccb35
refactor(api): remove in-memory rate limiter and validate sessionId i…
richiemcilroy Mar 3, 2026
f4d6cff
refactor(web): exclude anon_view from notification filter tabs
richiemcilroy Mar 3, 2026
e77a15b
refactor(web): use contract-derived types for notification filter count
richiemcilroy Mar 3, 2026
9136bb5
fix(web): use semantic button element for notification filter tabs
richiemcilroy Mar 3, 2026
ec6eef3
refactor(web): derive NotificationType from API contract in Notificat…
richiemcilroy Mar 3, 2026
d10e157
refactor(web): simplify notification avatar to use SignedImageUrl con…
richiemcilroy Mar 3, 2026
2a1e0df
fix(web): add exhaustive default case to notification link resolver
richiemcilroy Mar 3, 2026
8eae09f
fix(web): remove unnecessary type assertion in notification link reso…
richiemcilroy Mar 3, 2026
b804dc7
fix(api): decode URL-encoded city name in analytics tracking headers
richiemcilroy Mar 3, 2026
0e406c5
refactor(web): add LIKE pattern escaping and duplicate entry detectio…
richiemcilroy Mar 3, 2026
a75b9ec
fix(web): use safe LIKE query and handle race conditions in anon noti…
richiemcilroy Mar 3, 2026
4c9d904
Update analytics track route and add migration
richiemcilroy Mar 3, 2026
0c87ef5
Update 0013_snapshot.json
richiemcilroy Mar 3, 2026
915e508
refactor(api): simplify sanitizeString to avoid repeated trim calls
richiemcilroy Mar 3, 2026
509b9f5
fix(api): short-circuit owner check before database query
richiemcilroy Mar 3, 2026
d141666
perf(api): fork anon view notification as background daemon
richiemcilroy Mar 3, 2026
ced5ce2
fix(web): trim message in duplicate entry error detection
richiemcilroy Mar 3, 2026
aa2fb75
refactor(web): remove transaction and sessionHash from anon view noti…
richiemcilroy Mar 3, 2026
ed0ce98
perf(db): add composite index on notifications for type/recipient que…
richiemcilroy Mar 3, 2026
ff31881
feat(db): add pauseAnonViews preference and firstViewEmailSentAt column
richiemcilroy Mar 4, 2026
a4d1625
feat(db): add migration for firstViewEmailSentAt column
richiemcilroy Mar 4, 2026
935d152
feat(email): add first-view email template
richiemcilroy Mar 4, 2026
d5674e0
feat(api): add pauseAnonViews to notification preferences schema
richiemcilroy Mar 4, 2026
9da8943
feat(web): add pauseAnonViews to update-preferences action
richiemcilroy Mar 4, 2026
ee9a373
feat(web): add anonymous views toggle to notification settings UI
richiemcilroy Mar 4, 2026
883bbf8
feat(web): use pauseAnonViews preference for anon view notifications
richiemcilroy Mar 4, 2026
cb10822
perf(web): replace LIKE query with JSON_EXTRACT for rate limiting
richiemcilroy Mar 4, 2026
4a03a8a
fix(api): always verify video owner from database instead of trusting…
richiemcilroy Mar 4, 2026
4d3c1c7
feat(api): wire sendFirstViewEmail into analytics track route
richiemcilroy Mar 4, 2026
a734be2
fix(web): use tuple destructuring for Drizzle update result
richiemcilroy Mar 4, 2026
6825367
perf(api): skip owner DB query when ownerId does not match userId
richiemcilroy Mar 4, 2026
9e854c9
fix(api): restrict first-view email to authenticated viewers only
richiemcilroy Mar 4, 2026
3c69bca
fix(email): remove isAnonymous prop from first-view email template
richiemcilroy Mar 4, 2026
cb235a3
fix(notification): correct isDuplicateEntryError to only match dedup key
richiemcilroy Mar 4, 2026
0db13cc
refactor(api): remove redundant owner DB verification in analytics track
richiemcilroy Mar 4, 2026
cf495ee
feat(api): send first-view email for anonymous viewers
richiemcilroy Mar 4, 2026
2481d2b
fix(db): make firstViewEmailSentAt timestamp explicit NULL DEFAULT NULL
richiemcilroy Mar 4, 2026
2bcf989
fix(notifications): respect pauseAnonViews preference in sendFirstVie…
richiemcilroy Mar 4, 2026
5dfd9f1
fix(api): verify video ownership via DB instead of trusting client ow…
richiemcilroy Mar 4, 2026
c6c1eb2
fix(notifications): return defaults instead of 500 for users with nul…
richiemcilroy Mar 4, 2026
080c81f
fix(notifications): return HTTP 500 status code instead of embedding …
richiemcilroy Mar 4, 2026
4305c6a
fix(analytics): separate anon notification and email errors for clear…
richiemcilroy Mar 4, 2026
5ba1611
fix(notifications): respect pauseViews preference for authenticated f…
richiemcilroy Mar 4, 2026
f587ef1
fix(analytics): derive tenantId from DB-fetched ownerId instead of cl…
richiemcilroy Mar 4, 2026
f3530e7
fix(notifications): use fallback display name for null video names in…
richiemcilroy Mar 4, 2026
c1ec572
feat(analytics): send first-view email for anonymous viewers with no …
richiemcilroy Mar 4, 2026
0cb4ad4
Skip DB lookup when ownerId provided
richiemcilroy Mar 4, 2026
b4eb2d6
perf(db): add videoId column and index to notifications table
richiemcilroy Mar 4, 2026
c37ec53
perf(notifications): use videoId column instead of JSON_EXTRACT in qu…
richiemcilroy Mar 4, 2026
0223f5e
perf(analytics): pre-fetch video data once and gate first-view email …
richiemcilroy Mar 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ export const FilterLabels: Record<FilterType, string> = {
reply: "Replies",
view: "Views",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

anon_view label in FilterLabels is unreachable UI

anon_view is not present in the Filters array (lines 5–11), so this entry in FilterLabels is only there for TypeScript type completeness (because FilterType includes anon_view). This is not a bug, but a comment explaining why the entry exists would help future readers understand it is intentional and not dead code that can be removed:

Suggested change
view: "Views",
anon_view: "Views", // Not shown as a separate tab; anon_view notifications surface under the "view" filter
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/(org)/dashboard/_components/Notifications/Filter.ts
Line: 17

Comment:
**`anon_view` label in `FilterLabels` is unreachable UI**

`anon_view` is not present in the `Filters` array (lines 5–11), so this entry in `FilterLabels` is only there for TypeScript type completeness (because `FilterType` includes `anon_view`). This is not a bug, but a comment explaining why the entry exists would help future readers understand it is intentional and not dead code that can be removed:

```suggestion
	anon_view: "Views", // Not shown as a separate tab; anon_view notifications surface under the "view" filter
```

How can I resolve this? If you propose a fix, please make it concise.

reaction: "Reactions",
anon_view: "Views",
};

export const matchNotificationFilter = (
filter: FilterType,
type: NotificationType,
): boolean => {
if (filter === "all") return true;
if (filter === "view") return type === "view" || type === "anon_view";
return type === filter;
};
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const descriptionMap: Record<NotificationType, string> = {
reply: `replied to your comment`,
view: `viewed your video`,
reaction: `reacted to your video`,
// mention: `mentioned you in a comment`,
anon_view: `viewed your video`,
};

export const NotificationItem = ({
Expand All @@ -36,6 +36,11 @@ export const NotificationItem = ({
}
};

const isAnonView = notification.type === "anon_view";
const displayName = isAnonView
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor TS narrowing thing: using the discriminant inline avoids any chance isAnonView loses the union narrowing.

Suggested change
const displayName = isAnonView
const displayName =
notification.type === "anon_view" ? notification.anonName : notification.author.name;

? notification.anonName
: notification.author.name;

return (
<Link
href={link}
Expand All @@ -45,30 +50,39 @@ export const NotificationItem = ({
className,
)}
>
{/* Avatar */}
<div className="relative flex-shrink-0">
<SignedImageUrl
image={notification.author.avatar as ImageUpload.ImageUrl | null}
name={notification.author.name}
className="relative flex-shrink-0 size-7"
letterClass="text-sm"
/>
{isAnonView ? (
<div className="relative flex-shrink-0 size-7 rounded-full bg-gray-3 flex items-center justify-center text-sm">
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider reusing SignedImageUrl here for consistent avatar styling (and to avoid baking a special-case glyph into the UI).

Suggested change
<div className="relative flex-shrink-0 size-7 rounded-full bg-gray-3 flex items-center justify-center text-sm">
<SignedImageUrl
image={null}
name={notification.anonName}
className="relative flex-shrink-0 size-7"
letterClass="text-sm"
/>

🐾
</div>
) : (
<SignedImageUrl
image={notification.author.avatar as ImageUpload.ImageUrl | null}
name={notification.author.name}
className="relative flex-shrink-0 size-7"
letterClass="text-sm"
/>
)}
{notification.readAt === null && (
<div className="absolute top-0 right-0 size-2.5 rounded-full bg-red-500 border-2 border-gray-1"></div>
)}
</div>

{/* Content */}
<div className="flex flex-col flex-1 justify-center">
<div className="flex gap-1 items-center">
<span className="font-medium text-gray-12 text-[13px]">
{notification.author.name}
{displayName}
</span>
<span className="text-gray-10 text-[13px]">
{descriptionMap[notification.type]}
</span>
</div>

{isAnonView && notification.location && (
<p className="text-[13px] leading-4 text-gray-11">
{notification.location}
</p>
)}
{(notification.type === "comment" || notification.type === "reply") && (
<p className="mb-2 text-[13px] h-fit italic leading-4 text-gray-11 line-clamp-2">
{notification.comment.content}
Expand All @@ -79,15 +93,15 @@ export const NotificationItem = ({
</p>
</div>

{/* Icon */}
<div className="flex flex-shrink-0 items-center mt-1">
{notification.type === "comment" && (
<FontAwesomeIcon icon={faComment} className="text-gray-10 size-4" />
)}
{notification.type === "reply" && (
<FontAwesomeIcon icon={faReply} className="text-gray-10 size-4" />
)}
{notification.type === "view" && (
{(notification.type === "view" ||
notification.type === "anon_view") && (
<FontAwesomeIcon icon={faEye} className="text-gray-10 size-4" />
)}
{notification.type === "reaction" && (
Expand All @@ -104,9 +118,9 @@ function getLink(notification: APINotification) {
return `/s/${notification.videoId}/?reply=${notification.comment.id}`;
case "comment":
case "reaction":
// case "mention":
return `/s/${notification.videoId}/?comment=${notification.comment.id}`;
default:
case "view":
case "anon_view":
return `/s/${notification.videoId}`;
}
}
64 changes: 64 additions & 0 deletions apps/web/app/api/analytics/track/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,44 @@ import { Effect, Option } from "effect";
import type { NextRequest } from "next/server";
import UAParser from "ua-parser-js";

import { getAnonymousName } from "@/lib/anonymous-names";
import { createAnonymousViewNotification } from "@/lib/Notification";
import { runPromise } from "@/lib/server";

const anonNotifRateLimit = new Map<
string,
{ count: number; resetAt: number }
>();
const ANON_NOTIF_WINDOW_MS = 5 * 60 * 1000;
const ANON_NOTIF_MAX_PER_VIDEO = 50;
const ANON_NOTIF_MAX_ENTRIES = 10_000;
let anonNotifCleanupCounter = 0;

function checkAnonNotifRateLimit(videoId: string): boolean {
anonNotifCleanupCounter++;
if (anonNotifCleanupCounter % 100 === 0) {
const now = Date.now();
for (const [k, v] of anonNotifRateLimit) {
if (v.resetAt < now) anonNotifRateLimit.delete(k);
}
if (anonNotifRateLimit.size > ANON_NOTIF_MAX_ENTRIES)
anonNotifRateLimit.clear();
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overflow clear resets active rate limits

When anonNotifRateLimit.size > ANON_NOTIF_MAX_ENTRIES after the expired-entry sweep, the entire map is cleared with anonNotifRateLimit.clear(). This wipes the rate-limit counters for all currently-tracked videos simultaneously, creating a brief unprotected window where every video's counter is reset to zero. In practice this matters under heavy load: an attacker monitoring for the periodic counter-spike (every 100 requests) could exploit the reset window to inject another burst of 50 fake notifications per video per instance. A safer approach is to evict the oldest or least-recently-used entries rather than clearing the whole map.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/api/analytics/track/route.ts
Line: 21-29

Comment:
**Overflow clear resets active rate limits**

When `anonNotifRateLimit.size > ANON_NOTIF_MAX_ENTRIES` after the expired-entry sweep, the entire map is cleared with `anonNotifRateLimit.clear()`. This wipes the rate-limit counters for *all* currently-tracked videos simultaneously, creating a brief unprotected window where every video's counter is reset to zero. In practice this matters under heavy load: an attacker monitoring for the periodic counter-spike (every 100 requests) could exploit the reset window to inject another burst of 50 fake notifications per video per instance. A safer approach is to evict the oldest or least-recently-used entries rather than clearing the whole map.

How can I resolve this? If you propose a fix, please make it concise.


const now = Date.now();
const entry = anonNotifRateLimit.get(videoId);
if (!entry || entry.resetAt < now) {
anonNotifRateLimit.set(videoId, {
count: 1,
resetAt: now + ANON_NOTIF_WINDOW_MS,
});
return true;
}
if (entry.count >= ANON_NOTIF_MAX_PER_VIDEO) return false;
entry.count++;
return true;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In-memory rate limiter is unreliable in serverless/multi-instance deployments

anonNotifRateLimit is a module-level Map that lives only in the memory of a single server instance. In a serverless deployment (Vercel, AWS Lambda, etc.) each function invocation may run in a separate process with its own blank copy of this map. This means the ANON_NOTIF_MAX_PER_VIDEO = 50 limit applies per-instance, not globally. In practice a busy video could receive many multiples of 50 notification-creation attempts per window as traffic is spread across instances.

Additionally, a client that rotates its sessionId on every request can generate a fresh DB row (bypassing the dedup key) on every request until the per-instance limit is hit, allowing up to 50 spam notifications per instance per 5-minute window.

The database-level dedup (onDuplicateKeyUpdate) is the only true cross-instance guard, but it only deduplicates the same session — it does not protect against many different fake sessions. Consider persisting rate-limit state in Redis/KV (the same store likely used elsewhere in this project) so the limit is enforced globally across all instances.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/api/analytics/track/route.ts
Line: 11-43

Comment:
**In-memory rate limiter is unreliable in serverless/multi-instance deployments**

`anonNotifRateLimit` is a module-level `Map` that lives only in the memory of a single server instance. In a serverless deployment (Vercel, AWS Lambda, etc.) each function invocation may run in a separate process with its own blank copy of this map. This means the `ANON_NOTIF_MAX_PER_VIDEO = 50` limit applies *per-instance*, not globally. In practice a busy video could receive many multiples of 50 notification-creation attempts per window as traffic is spread across instances.

Additionally, a client that rotates its `sessionId` on every request can generate a fresh DB row (bypassing the dedup key) on every request until the per-instance limit is hit, allowing up to 50 spam notifications per instance per 5-minute window.

The database-level dedup (`onDuplicateKeyUpdate`) is the only true cross-instance guard, but it only deduplicates the *same* session — it does not protect against many different fake sessions. Consider persisting rate-limit state in Redis/KV (the same store likely used elsewhere in this project) so the limit is enforced globally across all instances.

How can I resolve this? If you propose a fix, please make it concise.


interface TrackPayload {
videoId: string;
orgId?: string | null;
Expand Down Expand Up @@ -100,6 +136,34 @@ export async function POST(request: NextRequest) {
user_id: userId,
},
]);

if (
!userId &&
sessionId !== "anon" &&
checkAnonNotifRateLimit(body.videoId)
) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor edge case: an empty/whitespace sessionId (e.g. "") will currently pass the sessionId !== "anon" guard and hash/dedup as the empty string.

Suggested change
if (
!userId &&
sessionId !== "anon" &&
checkAnonNotifRateLimit(body.videoId)
) {
if (
!userId &&
sessionId.trim() !== "" &&
sessionId !== "anon" &&
checkAnonNotifRateLimit(body.videoId)
) {

const anonName = getAnonymousName(sessionId);
const locationParts = [city, country].filter(Boolean);
const location =
locationParts.length > 0 ? locationParts.join(", ") : null;

yield* Effect.tryPromise(() =>
createAnonymousViewNotification({
videoId: body.videoId,
sessionId,
anonName,
location,
}),
).pipe(
Effect.catchAll((error) => {
console.error(
"Failed to create anonymous view notification:",
error,
);
return Effect.void;
}),
);
}
}).pipe(provideOptionalAuth),
);

Expand Down
47 changes: 26 additions & 21 deletions apps/web/app/api/notifications/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,13 @@ import { getCurrentUser } from "@cap/database/auth/session";
import { notifications, users } from "@cap/database/schema";
import { Notification as APINotification } from "@cap/web-api-contract";
import { ImageUploads } from "@cap/web-backend";
import { and, desc, eq, isNull, sql } from "drizzle-orm";
import { and, desc, eq, isNull, ne, sql } from "drizzle-orm";
import { Effect } from "effect";
import { NextResponse } from "next/server";
import { z } from "zod";
import type { NotificationType } from "@/lib/Notification";
import { runPromise } from "@/lib/server";
import { jsonExtractString } from "@/utils/sql";

const _notificationDataSchema = z.object({
authorId: z.string(),
content: z.string().optional(),
videoId: z.string(),
});

type NotificationsKeys = (typeof notifications.$inferSelect)["type"];
type NotificationsKeysWithReplies =
| Exclude<`${NotificationsKeys}s`, "replys">
| "replies";

export const dynamic = "force-dynamic";

export async function GET() {
Expand Down Expand Up @@ -51,7 +39,10 @@ export async function GET() {
.from(notifications)
.leftJoin(
users,
and(eq(jsonExtractString(notifications.data, "authorId"), users.id)),
and(
ne(notifications.type, "anon_view"),
eq(jsonExtractString(notifications.data, "authorId"), users.id),
),
)
.where(
and(
Expand All @@ -78,26 +69,40 @@ export async function GET() {
)
.groupBy(notifications.type);

const formattedCountResults: Record<NotificationType, number> = {
type NotificationCountKey = Exclude<NotificationType, "anon_view">;
const formattedCountResults: Record<NotificationCountKey, number> = {
view: 0,
comment: 0,
reply: 0,
reaction: 0,
// recordings: 0,
// mentions: 0,
};

countResults.forEach(({ type, count }) => {
formattedCountResults[type] = Number(count);
});
for (const { type, count } of countResults) {
if (type === "anon_view") {
formattedCountResults.view += Number(count);
} else if (type in formattedCountResults) {
formattedCountResults[type as NotificationCountKey] += Number(count);
}
}

const formattedNotifications = await Effect.gen(function* () {
const imageUploads = yield* ImageUploads;

return yield* Effect.all(
notificationsWithAuthors.map(({ notification, author }) =>
Effect.gen(function* () {
// all notifications currently require an author
if (notification.type === "anon_view") {
return APINotification.parse({
id: notification.id,
type: "anon_view",
readAt: notification.readAt,
videoId: notification.data.videoId,
createdAt: notification.createdAt,
anonName: notification.data.anonName ?? "Anonymous Viewer",
location: notification.data.location ?? null,
});
}

if (!author) return null;

const resolvedAvatar = author.avatar
Expand Down
Loading
Loading