Skip to content
Merged
Show file tree
Hide file tree
Changes from 55 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
1 change: 1 addition & 0 deletions apps/web/actions/notifications/update-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const updatePreferences = async ({
pauseReplies: boolean;
pauseViews: boolean;
pauseReactions: boolean;
pauseAnonViews?: boolean;
};
}) => {
const currentUser = await getCurrentUser();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { NotificationType } from "@/lib/Notification";
import type { Notification } from "@cap/web-api-contract";

export type FilterType = "all" | NotificationType;
type NotificationType = Notification["type"];

export type FilterType = "all" | Exclude<NotificationType, "anon_view">;

export const Filters: Array<FilterType> = [
"all",
Expand All @@ -23,5 +25,6 @@ export const matchNotificationFilter = (
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
@@ -1,14 +1,13 @@
import clsx from "clsx";
import { motion } from "framer-motion";
import { useEffect, useMemo, useRef } from "react";
import type { NotificationType } from "@/lib/Notification";
import { FilterLabels, Filters, type FilterType } from "./Filter";

type FilterTabsProps = {
activeFilter: FilterType;
setActiveFilter: (filter: FilterType) => void;
loading: boolean;
count?: Record<NotificationType, number>;
count?: Record<Exclude<FilterType, "all">, number>;
};

export const FilterTabs = ({
Expand Down Expand Up @@ -49,9 +48,10 @@ export const FilterTabs = ({
>
{Filters.map((filter) => (
<div key={filter} className="relative min-w-fit">
<div
<button
type="button"
onClick={() => setActiveFilter(filter)}
className="flex relative gap-2 items-center py-4 cursor-pointer group"
className="flex relative gap-2 items-center py-4 cursor-pointer group bg-transparent border-0"
>
<p
className={clsx(
Expand Down Expand Up @@ -79,7 +79,7 @@ export const FilterTabs = ({
</p>
)}
</div>
</div>
</button>

{/* Indicator */}
{activeFilter === filter && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,20 @@ import moment from "moment";
import Link from "next/link";
import { markAsRead } from "@/actions/notifications/mark-as-read";
import { SignedImageUrl } from "@/components/SignedImageUrl";
import type { NotificationType } from "@/lib/Notification";

type NotificationItemProps = {
notification: APINotification;
className?: string;
};

type NotificationType = APINotification["type"];

const descriptionMap: Record<NotificationType, string> = {
comment: `commented on your video`,
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 +37,12 @@ export const NotificationItem = ({
}
};

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

return (
<Link
href={link}
Expand All @@ -45,11 +52,14 @@ export const NotificationItem = ({
className,
)}
>
{/* Avatar */}
<div className="relative flex-shrink-0">
<SignedImageUrl
image={notification.author.avatar as ImageUpload.ImageUrl | null}
name={notification.author.name}
image={
isAnonView
? null
: (notification.author.avatar as ImageUpload.ImageUrl | null)
}
name={displayName}
className="relative flex-shrink-0 size-7"
letterClass="text-sm"
/>
Expand All @@ -58,17 +68,21 @@ export const NotificationItem = ({
)}
</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,8 +118,10 @@ 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}`;
case "view":
case "anon_view":
return `/s/${notification.videoId}`;
default:
return `/s/${notification.videoId}`;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
faCog,
faComment,
faEye,
faEyeSlash,
faReply,
faThumbsUp,
type IconDefinition,
Expand All @@ -20,13 +21,19 @@ import { useDashboardContext } from "../../Contexts";
type NotificationOption = {
icon: IconDefinition;
label: string;
value: "pauseComments" | "pauseViews" | "pauseReactions" | "pauseReplies";
value:
| "pauseComments"
| "pauseViews"
| "pauseReactions"
| "pauseReplies"
| "pauseAnonViews";
};

const notificationOptions: NotificationOption[] = [
{ icon: faComment, label: "Comments", value: "pauseComments" },
{ icon: faReply, label: "Replies", value: "pauseReplies" },
{ icon: faEye, label: "Views", value: "pauseViews" },
{ icon: faEyeSlash, label: "Anonymous views", value: "pauseAnonViews" },
{ icon: faThumbsUp, label: "Reactions", value: "pauseReactions" },
];

Expand All @@ -41,6 +48,7 @@ export const SettingsDropdown = () => {
pauseReplies: false,
pauseViews: false,
pauseReactions: false,
pauseAnonViews: false,
};

await updatePreferences({
Expand Down Expand Up @@ -131,7 +139,7 @@ export const SettingsDropdown = () => {
</p>
</div>

{userPreferences?.notifications[option.value] && (
{userPreferences?.notifications?.[option.value] && (
<FontAwesomeIcon
icon={faCheck}
className="text-gray-10 size-2.5 transition-colors group-hover:text-gray-12"
Expand Down
106 changes: 94 additions & 12 deletions apps/web/app/api/analytics/track/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { db } from "@cap/database";
import { videos } from "@cap/database/schema";
import { provideOptionalAuth, Tinybird } from "@cap/web-backend";
import { CurrentUser } from "@cap/web-domain";
import { CurrentUser, Video } from "@cap/web-domain";
import { eq } from "drizzle-orm";
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,
sendFirstViewEmail,
} from "@/lib/Notification";
import { runPromise } from "@/lib/server";

interface TrackPayload {
Expand All @@ -17,10 +25,19 @@ interface TrackPayload {
occurredAt?: string;
}

const sanitizeString = (value?: string | null) =>
value?.trim() && value.trim() !== "unknown"
? value.trim().slice(0, 256)
: undefined;
const sanitizeString = (value?: string | null) => {
const trimmed = value?.trim();
return trimmed && trimmed !== "unknown" ? trimmed.slice(0, 256) : undefined;
};

const decodeUrlEncodedHeaderValue = (value?: string | null) => {
if (!value) return value;
try {
return decodeURIComponent(value);
} catch {
return value;
}
};

export async function POST(request: NextRequest) {
let body: TrackPayload;
Expand All @@ -34,7 +51,12 @@ export async function POST(request: NextRequest) {
return Response.json({ error: "videoId is required" }, { status: 400 });
}

const sessionId = body.sessionId?.slice(0, 128) ?? "anon";
const parsedSessionId =
typeof body.sessionId === "string"
? body.sessionId.trim().slice(0, 128) || null
: null;
const sessionId =
parsedSessionId && parsedSessionId !== "anonymous" ? parsedSessionId : null;
const userAgent =
sanitizeString(request.headers.get("user-agent")) ||
sanitizeString(body.userAgent) ||
Expand All @@ -50,15 +72,17 @@ export async function POST(request: NextRequest) {
sanitizeString(request.headers.get("x-vercel-ip-country")) || "";
const region =
sanitizeString(request.headers.get("x-vercel-ip-country-region")) || "";
const city = sanitizeString(request.headers.get("x-vercel-ip-city")) || "";
const city =
sanitizeString(
decodeUrlEncodedHeaderValue(request.headers.get("x-vercel-ip-city")),
) || "";

const hostname =
sanitizeString(body.hostname) ||
sanitizeString(request.nextUrl.hostname) ||
"";

const tenantId =
body.orgId || body.ownerId || (hostname ? `domain:${hostname}` : "public");
const tenantId = body.orgId || (hostname ? `domain:${hostname}` : "public");
Copy link
Contributor

Choose a reason for hiding this comment

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

ownerId removed from tenantId fallback — silent analytics attribution regression

Before this PR, body.ownerId was the second fallback in the tenantId chain:

// before
const tenantId = body.orgId || body.ownerId || (hostname ? `domain:${hostname}` : "public");

Any video player that sends ownerId but omits orgId will now have its analytics events bucketed under domain:{hostname} or "public" instead of the owner-specific tenant. If any active clients rely on this fallback for Tinybird segmentation, their historical data will appear to come from a different tenant going forward — with no error or warning.

If this removal is intentional (e.g. ownerId should never have been a valid tenant identifier), it's worth leaving a comment explaining why, and verifying that no client currently omits orgId.

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

Comment:
**`ownerId` removed from `tenantId` fallback — silent analytics attribution regression**

Before this PR, `body.ownerId` was the second fallback in the `tenantId` chain:

```ts
// before
const tenantId = body.orgId || body.ownerId || (hostname ? `domain:${hostname}` : "public");
```

Any video player that sends `ownerId` but omits `orgId` will now have its analytics events bucketed under `domain:{hostname}` or `"public"` instead of the owner-specific tenant. If any active clients rely on this fallback for Tinybird segmentation, their historical data will appear to come from a different tenant going forward — with no error or warning.

If this removal is intentional (e.g. `ownerId` should never have been a valid tenant identifier), it's worth leaving a comment explaining why, and verifying that no client currently omits `orgId`.

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


const pathname = body.pathname ?? `/s/${body.videoId}`;

Expand All @@ -77,15 +101,24 @@ export async function POST(request: NextRequest) {
return currentUser.id;
},
});
if (userId && body.ownerId && userId === body.ownerId) {
return;
if (userId) {
const [videoRecord] = yield* Effect.tryPromise(() =>
db()
.select({ ownerId: videos.ownerId })
.from(videos)
.where(eq(videos.id, Video.VideoId.make(body.videoId)))
.limit(1),
).pipe(Effect.orElseSucceed(() => [] as { ownerId: string }[]));
if (videoRecord && userId === videoRecord.ownerId) {
return;
}
}

const tinybird = yield* Tinybird;
yield* tinybird.appendEvents([
{
timestamp: timestamp.toISOString(),
session_id: sessionId,
session_id: sessionId ?? "anon",
action: "page_hit",
version: "1.0",
tenant_id: tenantId,
Expand All @@ -100,6 +133,55 @@ export async function POST(request: NextRequest) {
user_id: userId,
},
]);

if (userId) {
yield* Effect.forkDaemon(
Copy link

Choose a reason for hiding this comment

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

sendFirstViewEmail already catches/logs internally, so this Effect.catchAll looks effectively unreachable (the promise shouldn’t reject). Either let the helper throw and handle errors here, or simplify the fork.

Suggested change
yield* Effect.forkDaemon(
yield* Effect.forkDaemon(
Effect.tryPromise(() =>
sendFirstViewEmail({
videoId: body.videoId,
viewerUserId: userId,
isAnonymous: false,
}),
),
);

Effect.tryPromise(() =>
sendFirstViewEmail({
videoId: body.videoId,
viewerUserId: userId,
isAnonymous: false,
Copy link
Contributor

Choose a reason for hiding this comment

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

Anonymous viewers without a session ID skip sendFirstViewEmail entirely

The anonymous path on line 146 is gated on !userId && sessionId. When sessionId is null or falsy, this entire block is skipped — neither createAnonymousViewNotification nor sendFirstViewEmail is called, even though the Tinybird analytics event is still recorded above.

If the very first viewer of a video is a session-less anonymous visitor, firstViewEmailSentAt remains null permanently, and the owner will never receive the first-view email notification for that visit.

The authenticated path (lines 137–145) handles this correctly by calling sendFirstViewEmail regardless of whether the user has identity. Consider adding a fallback for anonymous views without a session:

Suggested change
isAnonymous: false,
if (userId) {
yield* Effect.forkDaemon(
Effect.tryPromise(() =>
sendFirstViewEmail({
videoId: body.videoId,
viewerUserId: userId,
isAnonymous: false,
}),
).pipe(
Effect.catchAll((error) => {
console.error("Failed to send first view email:", error);
return Effect.void;
}),
),
);
}
if (!userId) {
// Always attempt to send first-view email, even if no sessionId
const viewerName = sessionId ? getAnonymousName(sessionId) : "Anonymous Viewer";
yield* Effect.forkDaemon(
Effect.tryPromise(() =>
sendFirstViewEmail({
videoId: body.videoId,
viewerName,
isAnonymous: true,
}),
).pipe(
Effect.catchAll((error) => {
console.error("Failed to send first view email:", error);
return Effect.void;
}),
),
);
if (sessionId) {
const anonName = getAnonymousName(sessionId);
const location =
city && country ? `${city}, ${country}` : city || country || null;
yield* Effect.forkDaemon(
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;
}),
),
);
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/api/analytics/track/route.ts
Line: 146

Comment:
**Anonymous viewers without a session ID skip `sendFirstViewEmail` entirely**

The anonymous path on line 146 is gated on `!userId && sessionId`. When `sessionId` is null or falsy, this entire block is skipped — neither `createAnonymousViewNotification` nor `sendFirstViewEmail` is called, even though the Tinybird analytics event is still recorded above.

If the very first viewer of a video is a session-less anonymous visitor, `firstViewEmailSentAt` remains `null` permanently, and the owner will never receive the first-view email notification for that visit.

The authenticated path (lines 137–145) handles this correctly by calling `sendFirstViewEmail` regardless of whether the user has identity. Consider adding a fallback for anonymous views without a session:

```suggestion
		if (userId) {
			yield* Effect.forkDaemon(
				Effect.tryPromise(() =>
					sendFirstViewEmail({
						videoId: body.videoId,
						viewerUserId: userId,
						isAnonymous: false,
					}),
				).pipe(
					Effect.catchAll((error) => {
						console.error("Failed to send first view email:", error);
						return Effect.void;
					}),
				),
			);
		}

		if (!userId) {
			// Always attempt to send first-view email, even if no sessionId
			const viewerName = sessionId ? getAnonymousName(sessionId) : "Anonymous Viewer";
			
			yield* Effect.forkDaemon(
				Effect.tryPromise(() =>
					sendFirstViewEmail({
						videoId: body.videoId,
						viewerName,
						isAnonymous: true,
					}),
				).pipe(
					Effect.catchAll((error) => {
						console.error("Failed to send first view email:", error);
						return Effect.void;
					}),
				),
			);

			if (sessionId) {
				const anonName = getAnonymousName(sessionId);
				const location =
					city && country ? `${city}, ${country}` : city || country || null;

				yield* Effect.forkDaemon(
					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;
						}),
					),
				);
			}
		}
```

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

}),
).pipe(
Effect.catchAll((error) => {
console.error("Failed to send first view email:", error);
return Effect.void;
}),
),
);
}

if (!userId && sessionId) {
const anonName = getAnonymousName(sessionId);
const location =
city && country ? `${city}, ${country}` : city || country || null;

yield* Effect.forkDaemon(
Effect.tryPromise(() =>
Promise.all([
createAnonymousViewNotification({
videoId: body.videoId,
sessionId,
anonName,
location,
}),
sendFirstViewEmail({
videoId: body.videoId,
viewerName: anonName,
isAnonymous: true,
}),
]),
).pipe(
Effect.catchAll((error) => {
console.error(
"Failed to create anonymous view notification:",
error,
);
return Effect.void;
}),
),
Comment on lines +228 to +233
Copy link
Contributor

Choose a reason for hiding this comment

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

Error message is ambiguous when sendFirstViewEmail fails

Both createAnonymousViewNotification and sendFirstViewEmail are awaited inside a single Promise.all, but the error handler logs "Failed to create anonymous view notification" regardless of which function actually threw. If sendFirstViewEmail is the one that fails, the error message will be misleading and complicate debugging.

Suggested change
).pipe(
Effect.catchAll((error) => {
console.error(
"Failed to create anonymous view notification:",
error,
);
return Effect.void;
}),
),
Effect.catchAll((error) => {
console.error(
"Failed to create anonymous view notification or send first view email:",
error,
);
return Effect.void;
}),
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/api/analytics/track/route.ts
Line: 174-182

Comment:
**Error message is ambiguous when `sendFirstViewEmail` fails**

Both `createAnonymousViewNotification` and `sendFirstViewEmail` are awaited inside a single `Promise.all`, but the error handler logs `"Failed to create anonymous view notification"` regardless of which function actually threw. If `sendFirstViewEmail` is the one that fails, the error message will be misleading and complicate debugging.

```suggestion
					Effect.catchAll((error) => {
						console.error(
							"Failed to create anonymous view notification or send first view email:",
							error,
						);
						return Effect.void;
					}),
```

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

);
}
}).pipe(provideOptionalAuth),
);

Expand Down
1 change: 1 addition & 0 deletions apps/web/app/api/notifications/preferences/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const PreferencesSchema = z.object({
pauseReplies: z.boolean(),
pauseViews: z.boolean(),
pauseReactions: z.boolean(),
pauseAnonViews: z.boolean().optional().default(false),
}),
});

Expand Down
Loading
Loading