Skip to content

Commit 6b44b73

Browse files
committed
fix(analytics): track CardView events on direct card route
The GET /api/u/card/:cardId endpoint fetched card data without recording a CardView, bypassing analytics entirely for share-link access. - publicService.getCardById now accepts viewerId and request params and creates a CardView when an authenticated non-owner requests the card - The /card/:cardId route handler performs soft JWT verification (same pattern as /:username and /:username/card/:cardId) to extract viewerId - Source defaults to link for direct card access, consistent with the profile view path; callers may override via ?source= query param - Owner self-views are excluded to match the existing tracking contract Closes #495
1 parent ae50a6a commit 6b44b73

3 files changed

Lines changed: 175 additions & 3 deletions

File tree

apps/backend/src/__tests__/public.test.ts

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ const mockPrisma = {
3939
followLog: {
4040
findMany: vi.fn().mockResolvedValue([]),
4141
},
42-
card: {} as any,
42+
card: {
43+
findUnique: vi.fn(),
44+
},
4345
};
4446

4547
// ── Redis mock ────────────────────────────────────────────────────────────────
@@ -464,3 +466,147 @@ describe('GET /api/public/:username/qr-session', () => {
464466
);
465467
});
466468
});
469+
470+
// ─── Direct card view tracking (Issue #495) ───────────────────────────────────
471+
472+
const mockCard = {
473+
id: 'card-abc',
474+
title: 'My Dev Card',
475+
user: {
476+
id: 'user-123',
477+
username: 'testuser',
478+
displayName: 'Test User',
479+
bio: null,
480+
avatarUrl: null,
481+
accentColor: '#ffffff',
482+
},
483+
cardLinks: [],
484+
};
485+
486+
describe('GET /api/public/card/:cardId — direct card view tracking', () => {
487+
beforeEach(() => {
488+
vi.clearAllMocks();
489+
mockRedis.get.mockResolvedValue(null);
490+
mockRedis.set.mockResolvedValue('OK');
491+
mockPrisma.cardView.create.mockReturnValue({ catch: vi.fn() });
492+
mockPrisma.card.findUnique.mockResolvedValue(mockCard);
493+
});
494+
495+
it('returns 200 with correct card shape', async () => {
496+
const app = await buildApp();
497+
const res = await app.inject({
498+
method: 'GET',
499+
url: '/api/public/card/card-abc',
500+
});
501+
502+
expect(res.statusCode).toBe(200);
503+
const body = res.json();
504+
expect(body.id).toBe('card-abc');
505+
expect(body.title).toBe('My Dev Card');
506+
expect(body.owner.username).toBe('testuser');
507+
expect(Array.isArray(body.links)).toBe(true);
508+
});
509+
510+
it('returns 404 when card does not exist', async () => {
511+
mockPrisma.card.findUnique.mockResolvedValue(null);
512+
513+
const app = await buildApp();
514+
const res = await app.inject({
515+
method: 'GET',
516+
url: '/api/public/card/nonexistent',
517+
});
518+
519+
expect(res.statusCode).toBe(404);
520+
expect(res.json().error).toBe('Card not found');
521+
});
522+
523+
it('records CardView when authenticated viewer requests card', async () => {
524+
const app = await buildApp();
525+
526+
// Sign a JWT for a different user (viewer-456 ≠ owner user-123)
527+
const token = app.jwt.sign({ id: 'viewer-456' });
528+
529+
const res = await app.inject({
530+
method: 'GET',
531+
url: '/api/public/card/card-abc',
532+
headers: { Authorization: `Bearer ${token}` },
533+
});
534+
535+
expect(res.statusCode).toBe(200);
536+
expect(mockPrisma.cardView.create).toHaveBeenCalledWith(
537+
expect.objectContaining({
538+
data: expect.objectContaining({
539+
ownerId: 'user-123',
540+
cardId: 'card-abc',
541+
viewerId: 'viewer-456',
542+
}),
543+
}),
544+
);
545+
});
546+
547+
it('does not record CardView for anonymous (unauthenticated) request', async () => {
548+
const app = await buildApp();
549+
550+
const res = await app.inject({
551+
method: 'GET',
552+
url: '/api/public/card/card-abc',
553+
});
554+
555+
expect(res.statusCode).toBe(200);
556+
expect(mockPrisma.cardView.create).not.toHaveBeenCalled();
557+
});
558+
559+
it('does not record CardView when viewer is the card owner', async () => {
560+
const app = await buildApp();
561+
562+
// Sign JWT as the owner (user-123 === card.user.id)
563+
const token = app.jwt.sign({ id: 'user-123' });
564+
565+
const res = await app.inject({
566+
method: 'GET',
567+
url: '/api/public/card/card-abc',
568+
headers: { Authorization: `Bearer ${token}` },
569+
});
570+
571+
expect(res.statusCode).toBe(200);
572+
expect(mockPrisma.cardView.create).not.toHaveBeenCalled();
573+
});
574+
575+
it('uses "link" as default source for direct card views', async () => {
576+
const app = await buildApp();
577+
const token = app.jwt.sign({ id: 'viewer-456' });
578+
579+
await app.inject({
580+
method: 'GET',
581+
url: '/api/public/card/card-abc',
582+
headers: { Authorization: `Bearer ${token}` },
583+
});
584+
585+
expect(mockPrisma.cardView.create).toHaveBeenCalledWith(
586+
expect.objectContaining({
587+
data: expect.objectContaining({
588+
source: 'link',
589+
}),
590+
}),
591+
);
592+
});
593+
594+
it('records CardView with custom source query param when provided', async () => {
595+
const app = await buildApp();
596+
const token = app.jwt.sign({ id: 'viewer-456' });
597+
598+
await app.inject({
599+
method: 'GET',
600+
url: '/api/public/card/card-abc?source=web',
601+
headers: { Authorization: `Bearer ${token}` },
602+
});
603+
604+
expect(mockPrisma.cardView.create).toHaveBeenCalledWith(
605+
expect.objectContaining({
606+
data: expect.objectContaining({
607+
source: 'web',
608+
}),
609+
}),
610+
);
611+
});
612+
});

apps/backend/src/routes/public.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,20 @@ export async function publicRoutes(app: FastifyInstance): Promise<void> {
7171
}, async (request: FastifyRequest<{ Params: { cardId: string } }>, reply: FastifyReply) => {
7272
const { cardId } = request.params;
7373

74+
let viewerId: string | null = null;
75+
let authenticatedUserId: string | null = null;
76+
try {
77+
if (request.headers.authorization) {
78+
const decoded = (await request.jwtVerify()) as { id?: string };
79+
authenticatedUserId = decoded?.id ?? null;
80+
viewerId = authenticatedUserId;
81+
}
82+
} catch {
83+
// ignored
84+
}
85+
7486
try {
75-
const card = await publicService.getCardById(app, cardId);
87+
const card = await publicService.getCardById(app, cardId, viewerId, request, authenticatedUserId);
7688
if (!card) {
7789
return reply.status(404).send({ error: 'Card not found' });
7890
}

apps/backend/src/services/publicService.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,22 @@ export async function getPublicProfile(
5757
return { cached: false, data: response, cacheKey }
5858
}
5959

60-
export async function getCardById(app: FastifyInstance, cardId: string): Promise<any> {
60+
export async function getCardById(
61+
app: FastifyInstance,
62+
cardId: string,
63+
viewerId: string | null,
64+
request: any,
65+
authenticatedUserId: string | null = null,
66+
): Promise<any> {
6167
const card = await app.prisma.card.findUnique({ where: { id: cardId }, include: { user: true, cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } } })
68+
if (!card) { return null }
69+
70+
// Block self-views: don't record a cardView if the authenticated user is the owner
71+
const isSelfView = authenticatedUserId !== null && authenticatedUserId === card.user.id
72+
if (viewerId && !isSelfView) {
73+
app.prisma.cardView.create({ data: { ownerId: card.user.id, cardId: card.id, viewerId, viewerIp: request.ip || null, viewerAgent: request.headers['user-agent'] || null, source: request.query?.source || 'link' } }).catch((error: unknown) => app.log.error(`Failed to log view: ${getErrorMessage(error)}`))
74+
}
75+
6276
return card
6377
}
6478

0 commit comments

Comments
 (0)