Skip to content

Profile view tracking incorrectly records owner's own views when unauthenticated — viewerId !== user.id is always true when viewerId is null #203

@hariom888

Description

@hariom888

Root cause: In apps/backend/src/routes/public.ts, the GET /:username handler (and the /:username/card/:cardId handler) attempts to suppress self-view tracking with the guard if (viewerId !== user.id). However, when the request has no Authorization header, viewerId is set to null (line 92). The condition null !== user.id is always true (since user.id is a non-null UUID string), so the view is tracked even for unauthenticated requests. More importantly, when an authenticated owner visits their own profile without a token (e.g., logged out browser tab, direct URL), the same null-path fires and records a self-view. The comment on line 91 says "Only log if they aren't the owner" but the null branch at line 92 does not enforce this intent. This silently inflates totalViews and uniqueViewers in the analytics dashboard for every profile owner who views their own card while unauthenticated.

Why it matters: Analytics data is a core product feature. Inflated self-view counts corrupt the metrics users see on their dashboard. The bug is completely silent — no error, no log, just wrong data. It is also present in both the profile view handler and the card view handler (/:username/card/:cardId), making it a two-location fix.

Affected files/functions: apps/backend/src/routes/public.ts, GET /:username handler lines 84–108, GET /:username/card/:cardId handler lines 213–232.

Current behavior: An unauthenticated GET to /api/public/johndoe records a cardView row in the database even though the viewer is unknown. The condition null !== 'some-uuid' is always true, so the guard never blocks tracking.

Expected behavior: Unauthenticated requests should still be tracked (with viewerId: null and viewerIp) — that is intentional for analytics. The real fix is: the owner self-view check should only apply to authenticated requests where viewerId is set. The current structure already handles this correctly for the authenticated self-view case (line 89 skips setting viewerId if decoded.id === user.id). The issue is the redundant and misleading guard on line 99 which combines the two cases incorrectly. The condition should be refactored so it is clear: always track if viewerId is null (unauthenticated), always skip if viewerId === user.id (owner).

Minimal fix plan:
Change the condition on line 99 from if (viewerId !== user.id) to if (viewerId === null || viewerId !== user.id). Wait — null !== user.id is already true, so unauthenticated views ARE being tracked, which is correct. The real documentation/logic problem is: the code comment on line 91 (// Only log if they aren't the owner) is misleading, and the guard on line 99 is redundant because the authenticated owner path already sets viewerId = null (not decoded.id) when decoded.id === user.id — wait, re-reading line 89: it sets viewerId = decoded.id only when decoded.id !== user.id. So for an authenticated owner, viewerId stays null, and null !== user.id is true, causing an owner self-view to still be tracked.

The actual fix: in the authenticated branch where decoded.id === user.id, set a sentinel like viewerId = user.id (or use a separate boolean flag isSelfView), and change the outer guard to if (viewerId !== user.id). This correctly blocks the authenticated owner self-view. Apply the same two-line fix to the /:username/card/:cardId handler.

Suggested tests: Add unit tests for the public route: (1) authenticated request where decoded.id === user.id — assert prisma.cardView.create is never called, (2) authenticated request where decoded.id !== user.id — assert cardView.create IS called with viewerId = otherUserId, (3) unauthenticated request — assert cardView.create IS called with viewerId: null.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions