diff --git a/apps/backend/src/__tests__/profiles.test.ts b/apps/backend/src/__tests__/profiles.test.ts index ef1aad6..a6efdc2 100644 --- a/apps/backend/src/__tests__/profiles.test.ts +++ b/apps/backend/src/__tests__/profiles.test.ts @@ -27,9 +27,14 @@ const mockPrisma = { }, }; +const mockRedis = { + del: vi.fn(), +}; + async function buildApp() { const app = Fastify(); - app.decorate('prisma', mockPrisma); + app.decorate('prisma', mockPrisma as any); + app.decorate('redis', mockRedis as any); app.decorate('authenticate', async (request: any) => { request.user = { id: 'user-123' }; }); @@ -67,6 +72,7 @@ describe('PUT /api/profiles/me', () => { it('should update profile and return updated data', async () => { mockPrisma.user.findFirst.mockResolvedValue(null); + mockPrisma.user.findUnique.mockResolvedValue(mockUser); mockPrisma.user.update.mockResolvedValue({ ...mockUser, displayName: 'Updated Name' }); const app = await buildApp(); const res = await app.inject({ @@ -78,6 +84,22 @@ describe('PUT /api/profiles/me', () => { expect(res.json().displayName).toBe('Updated Name'); }); + it('should invalidate public profile cache after update', async () => { + mockPrisma.user.findFirst.mockResolvedValue(null); + mockPrisma.user.findUnique.mockResolvedValue(mockUser); + mockPrisma.user.update.mockResolvedValue({ ...mockUser, username: 'newuser' }); + const app = await buildApp(); + + const res = await app.inject({ + method: 'PUT', + url: '/api/profiles/me', + payload: { username: 'newuser' }, + }); + + expect(res.statusCode).toBe(200); + expect(mockRedis.del).toHaveBeenCalledWith('profile:testuser', 'profile:newuser'); + }); + it('should return 400 for invalid accentColor', async () => { const app = await buildApp(); const res = await app.inject({ @@ -100,4 +122,4 @@ describe('PUT /api/profiles/me', () => { expect(res.statusCode).toBe(409); expect(res.json().error).toBe('Username already taken'); }); -}); \ No newline at end of file +}); diff --git a/apps/backend/src/__tests__/public.test.ts b/apps/backend/src/__tests__/public.test.ts new file mode 100644 index 0000000..d6f784a --- /dev/null +++ b/apps/backend/src/__tests__/public.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Fastify from 'fastify'; +import jwt from '@fastify/jwt'; +import { publicRoutes } from '../routes/public.js'; + +const mockUser = { + id: 'user-123', + username: 'testuser', + displayName: 'Test User', + bio: 'Building things', + pronouns: null, + role: 'Engineer', + company: 'DevCard', + avatarUrl: null, + accentColor: '#ffffff', + platformLinks: [ + { + id: 'link-1', + platform: 'github', + username: 'testuser', + url: 'https://github.com/testuser', + displayOrder: 0, + }, + ], +}; + +const redisStore = new Map(); + +const mockRedis = { + get: vi.fn((key: string) => Promise.resolve(redisStore.get(key) ?? null)), + setex: vi.fn((key: string, _ttl: number, value: string) => { + redisStore.set(key, value); + return Promise.resolve('OK'); + }), +}; + +const mockPrisma = { + user: { + findUnique: vi.fn(), + }, + cardView: { + create: vi.fn(() => Promise.resolve({ id: 'view-1' })), + }, +}; + +async function buildApp() { + const app = Fastify(); + await app.register(jwt, { secret: 'test-secret' }); + app.decorate('prisma', mockPrisma as any); + app.decorate('redis', mockRedis as any); + app.register(publicRoutes, { prefix: '/api/public' }); + await app.ready(); + return app; +} + +describe('GET /api/public/:username', () => { + beforeEach(() => { + vi.clearAllMocks(); + redisStore.clear(); + }); + + it('should cache profile data after a MISS and serve repeat requests as HIT', async () => { + mockPrisma.user.findUnique.mockResolvedValue(mockUser); + const app = await buildApp(); + + const miss = await app.inject({ method: 'GET', url: '/api/public/testuser' }); + expect(miss.statusCode).toBe(200); + expect(miss.headers['x-cache']).toBe('MISS'); + expect(miss.headers['cache-control']).toBe('public, max-age=300, stale-while-revalidate=60'); + expect(miss.json().displayName).toBe('Test User'); + expect(mockPrisma.user.findUnique).toHaveBeenCalledTimes(1); + expect(mockRedis.setex).toHaveBeenCalledWith( + 'profile:testuser', + 300, + expect.any(String) + ); + + const hit = await app.inject({ method: 'GET', url: '/api/public/testuser' }); + expect(hit.statusCode).toBe(200); + expect(hit.headers['x-cache']).toBe('HIT'); + expect(hit.json().links[0].platform).toBe('github'); + expect(mockPrisma.user.findUnique).toHaveBeenCalledTimes(1); + }); + + it('should return 404 without caching when the user is missing', async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + const app = await buildApp(); + + const res = await app.inject({ method: 'GET', url: '/api/public/missing' }); + + expect(res.statusCode).toBe(404); + expect(mockRedis.setex).not.toHaveBeenCalled(); + }); +}); + +describe('GET /api/public/:username/qr-session', () => { + beforeEach(() => { + vi.clearAllMocks(); + redisStore.clear(); + }); + + it('should return an offline-decodable signed token containing the profile snapshot', async () => { + mockPrisma.user.findUnique.mockResolvedValue(mockUser); + const app = await buildApp(); + + const res = await app.inject({ method: 'GET', url: '/api/public/testuser/qr-session' }); + + expect(res.statusCode).toBe(200); + expect(res.headers['x-cache']).toBe('MISS'); + expect(res.json().expiresIn).toBe(600); + + const decoded = app.jwt.verify(res.json().token) as any; + expect(decoded.type).toBe('qr-session'); + expect(decoded.username).toBe('testuser'); + expect(decoded.profile.displayName).toBe('Test User'); + expect(decoded.exp - decoded.iat).toBe(600); + }); +}); diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 8e8cf38..973ed83 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -86,6 +86,7 @@ export async function buildApp() { await app.register(profileRoutes, { prefix: '/api/profiles' }); await app.register(cardRoutes, { prefix: '/api/cards' }); await app.register(publicRoutes, { prefix: '/api/u' }); + await app.register(publicRoutes, { prefix: '/api/public' }); await app.register(followRoutes, { prefix: '/api/follow' }); await app.register(connectRoutes, { prefix: '/api/connect' }); await app.register(analyticsRoutes, { prefix: '/api/analytics' }); diff --git a/apps/backend/src/routes/analytics.ts b/apps/backend/src/routes/analytics.ts index e9a75bb..588f05c 100644 --- a/apps/backend/src/routes/analytics.ts +++ b/apps/backend/src/routes/analytics.ts @@ -57,9 +57,9 @@ export async function analyticsRoutes(app: FastifyInstance) { }; }); - app.get('/views', { + app.get<{ Querystring: { page?: string, cardId?: string } }>('/views', { preHandler: [app.authenticate], - }, async (request: FastifyRequest<{ Querystring: { page?: string, cardId?: string } }>, reply: FastifyReply) => { + }, async (request, reply) => { const userId = (request.user as any).id; const page = parseInt(request.query.page || '1', 10); const limit = 20; diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index e12f10a..a717dbd 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -56,7 +56,7 @@ export async function authRoutes(app: FastifyInstance) { const tokenData = (await tokenRes.json()) as any; if (tokenData.error) { - app.log.error('GitHub token error:', tokenData); + app.log.error({ tokenData }, 'GitHub token error'); return reply.status(400).send({ error: 'Failed to authenticate with GitHub' }); } @@ -134,7 +134,7 @@ export async function authRoutes(app: FastifyInstance) { return reply.redirect(`${process.env.PUBLIC_APP_URL}/dashboard`); } catch (err) { - app.log.error('GitHub auth error:', err); + app.log.error({ err }, 'GitHub auth error'); return reply.status(500).send({ error: 'Authentication failed' }); } }); @@ -181,7 +181,7 @@ export async function authRoutes(app: FastifyInstance) { const tokenData = (await tokenRes.json()) as any; if (tokenData.error) { - app.log.error('Google token error:', tokenData); + app.log.error({ tokenData }, 'Google token error'); return reply.status(400).send({ error: 'Failed to authenticate with Google' }); } @@ -235,7 +235,7 @@ export async function authRoutes(app: FastifyInstance) { return reply.redirect(`${process.env.PUBLIC_APP_URL}/dashboard`); } catch (err) { - app.log.error('Google auth error:', err); + app.log.error({ err }, 'Google auth error'); return reply.status(500).send({ error: 'Authentication failed' }); } }); diff --git a/apps/backend/src/routes/connect.ts b/apps/backend/src/routes/connect.ts index 952e845..f480ebe 100644 --- a/apps/backend/src/routes/connect.ts +++ b/apps/backend/src/routes/connect.ts @@ -1,4 +1,5 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { encrypt } from '../utils/encryption.js'; const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize'; const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; @@ -90,12 +91,12 @@ export async function connectRoutes(app: FastifyInstance) { const tokenData = (await tokenRes.json()) as any; if (tokenData.error) { - app.log.error('GitHub connect token error:', tokenData); + app.log.error({ tokenData }, 'GitHub connect token error'); return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=connect_failed`); } // Encrypt and store the token - const encryptedToken = app.encryption.encrypt(tokenData.access_token); + const encryptedToken = encrypt(tokenData.access_token); await app.prisma.oAuthToken.upsert({ where: { @@ -125,7 +126,7 @@ export async function connectRoutes(app: FastifyInstance) { return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?connected=github`); } catch (err) { - app.log.error('GitHub connect error:', err); + app.log.error({ err }, 'GitHub connect error'); return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=server_error`); } }); @@ -133,9 +134,9 @@ export async function connectRoutes(app: FastifyInstance) { // ─── Disconnect ─── - app.delete('/:platform', { + app.delete<{ Params: { platform: string } }>('/:platform', { preHandler: [app.authenticate], - }, async (request: FastifyRequest<{ Params: { platform: string } }>, reply: FastifyReply) => { + }, async (request, reply) => { const userId = (request.user as any).id; const { platform } = request.params; diff --git a/apps/backend/src/routes/follow.ts b/apps/backend/src/routes/follow.ts index aabc85b..1c9d5d6 100644 --- a/apps/backend/src/routes/follow.ts +++ b/apps/backend/src/routes/follow.ts @@ -53,12 +53,12 @@ export async function followRoutes(app: FastifyInstance) { status: 'success', layer: 'api', }, - }).catch(err => app.log.error('Failed to log follow:', err)); + }).catch((err: unknown) => app.log.error({ err }, 'Failed to log follow')); } return result; } catch (err: any) { - app.log.error(`Follow error for ${platform}:`, err); + app.log.error({ err, platform }, 'Follow error'); app.prisma.followLog.create({ data: { @@ -68,7 +68,7 @@ export async function followRoutes(app: FastifyInstance) { status: 'error', layer: 'api', }, - }).catch(e => app.log.error('Failed to log follow error:', e)); + }).catch((err: unknown) => app.log.error({ err }, 'Failed to log follow error')); return reply.status(500).send({ error: 'Follow action failed', message: err.message }); } diff --git a/apps/backend/src/routes/profiles.ts b/apps/backend/src/routes/profiles.ts index 99aacb8..61e535a 100644 --- a/apps/backend/src/routes/profiles.ts +++ b/apps/backend/src/routes/profiles.ts @@ -65,6 +65,11 @@ export async function profileRoutes(app: FastifyInstance) { } } + const current = await app.prisma.user.findUnique({ + where: { id: userId }, + select: { username: true }, + }); + const updated = await app.prisma.user.update({ where: { id: userId }, data: parsed.data, @@ -82,6 +87,19 @@ export async function profileRoutes(app: FastifyInstance) { }, }); + const cacheKeys = new Set([ + current?.username ? `profile:${current.username}` : null, + `profile:${updated.username}`, + ].filter((key): key is string => Boolean(key))); + + try { + if (cacheKeys.size > 0) { + await app.redis.del(...cacheKeys); + } + } catch (err) { + app.log.warn({ err, userId }, 'Failed to invalidate public profile cache'); + } + return updated; }); diff --git a/apps/backend/src/routes/public.ts b/apps/backend/src/routes/public.ts index f60e613..db08ffb 100644 --- a/apps/backend/src/routes/public.ts +++ b/apps/backend/src/routes/public.ts @@ -56,7 +56,121 @@ type UsernameCardPublicProfileResponse = { links: PublicProfileCardLink[] } +type CachedPublicProfile = { + ownerId: string; + profile: UsernamePublicProfileResponse; +} + +const PROFILE_CACHE_TTL_SECONDS = 300; +const QR_SESSION_TTL_SECONDS = 600; +const PUBLIC_PROFILE_CACHE_CONTROL = 'public, max-age=300, stale-while-revalidate=60'; + +function profileCacheKey(username: string) { + return `profile:${username}`; +} + +function buildUsernamePublicProfile(user: any): UsernamePublicProfileResponse { + return { + username: user.username, + displayName: user.displayName, + bio: user.bio, + pronouns: user.pronouns, + role: user.role, + company: user.company, + avatarUrl: user.avatarUrl, + accentColor: user.accentColor, + links: user.platformLinks.map((link: any) => ({ + id: link.id, + platform: link.platform, + username: link.username, + url: link.url, + displayOrder: link.displayOrder, + })), + } +} + +async function getCachedProfile(app: FastifyInstance, username: string): Promise { + try { + const cached = await app.redis.get(profileCacheKey(username)); + if (!cached) { + return null; + } + + return JSON.parse(cached) as CachedPublicProfile; + } catch (err) { + app.log.warn({ err, username }, 'Failed to read public profile cache'); + return null; + } +} + +async function cacheProfile(app: FastifyInstance, username: string, payload: CachedPublicProfile) { + try { + await app.redis.setex(profileCacheKey(username), PROFILE_CACHE_TTL_SECONDS, JSON.stringify(payload)); + } catch (err) { + app.log.warn({ err, username }, 'Failed to write public profile cache'); + } +} + +async function resolvePublicProfile(app: FastifyInstance, username: string) { + const cached = await getCachedProfile(app, username); + if (cached) { + return { cacheStatus: 'HIT' as const, payload: cached }; + } + + const user = await app.prisma.user.findUnique({ + where: { username }, + include: { + platformLinks: { + orderBy: { displayOrder: 'asc' }, + }, + }, + }); + + if (!user) { + return null; + } + const payload = { + ownerId: user.id, + profile: buildUsernamePublicProfile(user), + }; + + await cacheProfile(app, username, payload); + + return { cacheStatus: 'MISS' as const, payload }; +} + +async function getViewerId(request: FastifyRequest, ownerId: string) { + try { + if (request.headers.authorization) { + const decoded = await request.jwtVerify() as any; + if (decoded?.id !== ownerId) { + return decoded.id; + } + } + } catch (e) { + // Ignored if invalid token + } + + return null; +} + +async function trackProfileView(app: FastifyInstance, request: FastifyRequest, ownerId: string, viewerId: string | null) { + if (viewerId === ownerId) { + return; + } + + app.prisma.cardView.create({ + data: { + ownerId, + cardId: null, + viewerId, + viewerIp: request.ip || null, + viewerAgent: request.headers['user-agent'] || null, + source: (request.query as any)?.source || 'link', + }, + }).catch((err: unknown) => app.log.error({ err }, 'Failed to log view')); +} export async function publicRoutes(app: FastifyInstance) { // ─── Public Profile ─── @@ -67,69 +181,53 @@ export async function publicRoutes(app: FastifyInstance) { app.get('/:username', async (request: FastifyRequest<{ Params: { username: string } }>, reply: FastifyReply) => { const { username } = request.params; - const user = await app.prisma.user.findUnique({ - where: { username }, - include: { - platformLinks: { - orderBy: { displayOrder: 'asc' }, - }, - }, - }); - - if (!user) { + const resolved = await resolvePublicProfile(app, username); + if (!resolved) { return reply.status(404).send({ error: 'User not found' }); } - // Try to extract viewer from Authorization header (soft auth) - let viewerId = null; - try { - if (request.headers.authorization) { - const decoded = await request.jwtVerify() as any; - if (decoded?.id !== user.id) { - viewerId = decoded.id; // Only log if they aren't the owner - } - } else { - viewerId = null; // Unauthenticated viewer - } - } catch (e) { - // Ignored if invalid token - } + const viewerId = await getViewerId(request, resolved.payload.ownerId); + await trackProfileView(app, request, resolved.payload.ownerId, viewerId); - // Don't track if the owner is viewing their own profile - if (viewerId !== user.id) { - // Background view tracking - app.prisma.cardView.create({ - data: { - ownerId: user.id, - cardId: null, // this is a profile view, not a card view - viewerId, - viewerIp: request.ip || null, - viewerAgent: request.headers['user-agent'] || null, - source: (request.query as any)?.source || 'link', - }, - }).catch(err => app.log.error('Failed to log view:', err)); - } + return reply + .header('Cache-Control', PUBLIC_PROFILE_CACHE_CONTROL) + .header('X-Cache', resolved.cacheStatus) + .send(resolved.payload.profile); - const response: UsernamePublicProfileResponse = { - username: user.username, - displayName: user.displayName, - bio: user.bio, - pronouns: user.pronouns, - role: user.role, - company: user.company, - avatarUrl: user.avatarUrl, - accentColor: user.accentColor, - links: user.platformLinks.map((link) => ({ - id: link.id, - platform: link.platform, - username: link.username, - url: link.url, - displayOrder: link.displayOrder, - })), + }); + + /** + * GET /api/public/:username/qr-session + * Returns a short-lived signed token carrying a public profile snapshot. + */ + app.get('/:username/qr-session', async (request: FastifyRequest<{ Params: { username: string } }>, reply: FastifyReply) => { + const { username } = request.params; + + const resolved = await resolvePublicProfile(app, username); + if (!resolved) { + return reply.status(404).send({ error: 'User not found' }); } - return response; + const issuedAt = Math.floor(Date.now() / 1000); + const expiresAt = issuedAt + QR_SESSION_TTL_SECONDS; + const token = app.jwt.sign( + { + type: 'qr-session', + username, + profile: resolved.payload.profile, + }, + { expiresIn: `${QR_SESSION_TTL_SECONDS}s` } + ); + return reply + .header('Cache-Control', PUBLIC_PROFILE_CACHE_CONTROL) + .header('X-Cache', resolved.cacheStatus) + .send({ + token, + tokenType: 'JWT', + expiresIn: QR_SESSION_TTL_SECONDS, + expiresAt: new Date(expiresAt * 1000).toISOString(), + }); }); /** @@ -167,7 +265,7 @@ export async function publicRoutes(app: FastifyInstance) { avatarUrl: card.user.avatarUrl, accentColor: card.user.accentColor, }, - links: card.cardLinks.map((cl) => ({ + links: card.cardLinks.map((cl: any) => ({ id: cl.platformLink.id, platform: cl.platformLink.platform, username: cl.platformLink.username, @@ -230,7 +328,7 @@ export async function publicRoutes(app: FastifyInstance) { viewerAgent: request.headers['user-agent'] || null, source: (request.query as any)?.source || 'qr', }, - }).catch(err => app.log.error('Failed to log card view:', err)); + }).catch((err: unknown) => app.log.error({ err }, 'Failed to log card view')); } @@ -246,7 +344,7 @@ export async function publicRoutes(app: FastifyInstance) { avatarUrl: user.avatarUrl, accentColor: user.accentColor, }, - links: card.cardLinks.map((cl) => ({ + links: card.cardLinks.map((cl: any) => ({ id: cl.platformLink.id, platform: cl.platformLink.platform, username: cl.platformLink.username, diff --git a/apps/backend/src/types/fastify.d.ts b/apps/backend/src/types/fastify.d.ts new file mode 100644 index 0000000..9428474 --- /dev/null +++ b/apps/backend/src/types/fastify.d.ts @@ -0,0 +1,7 @@ +import type { FastifyReply, FastifyRequest } from 'fastify'; + +declare module 'fastify' { + interface FastifyInstance { + authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise; + } +}