diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 28458021..333def89 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -1,10 +1,10 @@ generator client { provider = "prisma-client-js" } + datasource db { provider = "postgresql" url = env("DATABASE_URL") - } model User { @@ -23,15 +23,14 @@ model User { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - platformLinks PlatformLink[] - cards Card[] - oauthTokens OAuthToken[] - ownedViews CardView[] @relation("cardOwner") - viewedCards CardView[] @relation("cardViewer") - followLogs FollowLog[] - organizer Event[] - attendedEvents EventAttendee[] - + platformLinks PlatformLink[] + cards Card[] + oauthTokens OAuthToken[] + ownedViews CardView[] @relation("cardOwner") + viewedCards CardView[] @relation("cardViewer") + followLogs FollowLog[] + organizer Event[] + attendedEvents EventAttendee[] ownedTeams Team[] @relation("TeamOwner") teamMemberships TeamMember[] @relation("TeamMember") @@ -40,13 +39,13 @@ model User { } model PlatformLink { - id String @id @default(uuid()) - userId String @map("user_id") + id String @id @default(uuid()) + userId String @map("user_id") platform String username String url String - displayOrder Int @default(0) @map("display_order") - createdAt DateTime @default(now()) @map("created_at") + displayOrder Int @default(0) @map("display_order") + createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) cardLinks CardLink[] @@ -55,12 +54,12 @@ model PlatformLink { } model Card { - id String @id @default(uuid()) - userId String @map("user_id") + id String @id @default(uuid()) + userId String @map("user_id") title String - isDefault Boolean @default(false) @map("is_default") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + isDefault Boolean @default(false) @map("is_default") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) cardLinks CardLink[] @@ -101,17 +100,17 @@ model OAuthToken { model CardView { id String @id @default(uuid()) - cardId String? @map("card_id") // null = default profile view - ownerId String @map("owner_id") // card/profile owner - viewerId String? @map("viewer_id") // null = anonymous web viewer - viewerIp String? @map("viewer_ip") + cardId String? @map("card_id") // null = default profile view + ownerId String @map("owner_id") // card/profile owner + viewerId String? @map("viewer_id") // null = anonymous web viewer + viewerIp String? @map("viewer_ip") // SHA-256 hash of viewer IP viewerAgent String? @map("viewer_agent") - source String @default("qr") // "qr" | "link" | "web" | "app" + source String @default("qr") // "qr" | "link" | "web" | "app" createdAt DateTime @default(now()) @map("created_at") - card Card? @relation(fields: [cardId], references: [id], onDelete: SetNull) - owner User @relation("cardOwner", fields: [ownerId], references: [id], onDelete: Cascade) - viewer User? @relation("cardViewer", fields: [viewerId], references: [id], onDelete: SetNull) + card Card? @relation(fields: [cardId], references: [id], onDelete: SetNull) + owner User @relation("cardOwner", fields: [ownerId], references: [id], onDelete: Cascade) + viewer User? @relation("cardViewer", fields: [viewerId], references: [id], onDelete: SetNull) @@map("card_views") } @@ -121,8 +120,8 @@ model FollowLog { followerId String @map("follower_id") targetUsername String @map("target_username") platform String - status String @default("success") // "success" | "error" - layer String // "api" | "webview" | "link" + status String @default("success") // "success" | "error" + layer String // "api" | "webview" | "link" createdAt DateTime @default(now()) @map("created_at") follower User @relation(fields: [followerId], references: [id], onDelete: Cascade) @@ -131,29 +130,29 @@ model FollowLog { } model Event { - id String @id @default(uuid()) - name String - slug String @unique - location String + id String @id @default(uuid()) + name String + slug String @unique + location String description String? - organizerId String - startDate DateTime - endDate DateTime - isPublic Boolean @default(true) - createdAt DateTime @default(now()) @map("created_at") - attendees EventAttendee[] + organizerId String + startDate DateTime + endDate DateTime + isPublic Boolean @default(true) + createdAt DateTime @default(now()) @map("created_at") + attendees EventAttendee[] organizer User @relation(fields: [organizerId], references: [id]) } model EventAttendee { - id String @id @default(uuid()) - userId String - eventId String - joinedAt DateTime + id String @id @default(uuid()) + userId String + eventId String + joinedAt DateTime - event Event @relation(fields: [eventId] , references: [id]) - user User @relation(fields: [userId],references: [id]) + event Event @relation(fields: [eventId], references: [id]) + user User @relation(fields: [userId], references: [id]) @@unique([userId, eventId]) } @@ -164,34 +163,34 @@ enum TeamRole { MEMBER } -model Team{ - id String @id @default(uuid()) - name String - slug String @unique - description String? - avatarUrl String? - ownerId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - owner User @relation("TeamOwner", fields: [ownerId], references: [id], onDelete: Restrict) +model Team { + id String @id @default(uuid()) + name String + slug String @unique + description String? + avatarUrl String? + ownerId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + owner User @relation("TeamOwner", fields: [ownerId], references: [id], onDelete: Restrict) members TeamMember[] @relation("TeamMember") @@map("teams") @@index([slug]) } -model TeamMember{ - id String @id @default(uuid()) - teamId String - userId String - role TeamRole - joinedAt DateTime +model TeamMember { + id String @id @default(uuid()) + teamId String + userId String + role TeamRole + joinedAt DateTime - team Team @relation("TeamMember",fields: [teamId] , references: [id], onDelete: Cascade) - user User @relation("TeamMember",fields: [userId] , references: [id]) + team Team @relation("TeamMember", fields: [teamId], references: [id], onDelete: Cascade) + user User @relation("TeamMember", fields: [userId], references: [id]) @@unique([userId, teamId]) @@index([userId]) @@map("team_members") -} \ No newline at end of file +} diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index c14949e1..09fac2f1 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -30,8 +30,8 @@ export async function authRoutes(app: FastifyInstance) { // GitHub OAuth start app.get('/github', async (request: FastifyRequest, reply: FastifyReply) => { const redirectUri = `${process.env.BACKEND_URL}/auth/github/callback`; - const clientState = (request.query as any).state || ''; - const mobileRedirectUri = (request.query as any).mobile_redirect_uri || ''; + const clientState = (request.query as { state?: string }).state || ''; + const mobileRedirectUri = (request.query as { mobile_redirect_uri?: string }).mobile_redirect_uri || ''; const state = buildOAuthState(clientState, mobileRedirectUri); reply.setCookie('oauth_state', state, { @@ -79,22 +79,22 @@ export async function authRoutes(app: FastifyInstance) { }), }); - const tokenData = (await tokenRes.json()) as any; + const tokenData = (await tokenRes.json()) as Record; if (tokenData.error) { app.log.error({ tokenData }, 'GitHub token error'); return reply.status(400).send({ error: 'Failed to authenticate with GitHub' }); } const userRes = await fetch(GITHUB_USER_URL, { headers: { Authorization: `Bearer ${tokenData.access_token}` } }); - const githubUser = (await userRes.json()) as any; + const githubUser = (await userRes.json()) as Record; - let email = githubUser.email; + let email = githubUser.email as string | undefined; if (!email) { const emailsRes = await fetch('https://api.github.com/user/emails', { headers: { Authorization: `Bearer ${tokenData.access_token}` }, }); - const emails = (await emailsRes.json()) as any[]; - const primary = emails.find((e: any) => e.primary && e.verified); + const emails = (await emailsRes.json()) as Array<{ primary?: boolean; verified?: boolean; email?: string }>; + const primary = emails.find((e) => e.primary && e.verified); email = primary?.email || emails[0]?.email; } @@ -102,23 +102,23 @@ export async function authRoutes(app: FastifyInstance) { where: { provider_providerId: { provider: 'github', providerId: String(githubUser.id) } }, update: { email: email || `${githubUser.login}@github.local`, - displayName: githubUser.name || githubUser.login, - avatarUrl: githubUser.avatar_url, + displayName: (githubUser.name as string) || (githubUser.login as string), + avatarUrl: githubUser.avatar_url as string, }, create: { email: email || `${githubUser.login}@github.local`, - username: githubUser.login, - displayName: githubUser.name || githubUser.login, - bio: githubUser.bio, - company: githubUser.company, - avatarUrl: githubUser.avatar_url, + username: githubUser.login as string, + displayName: (githubUser.name as string) || (githubUser.login as string), + bio: githubUser.bio as string | undefined, + company: githubUser.company as string | undefined, + avatarUrl: githubUser.avatar_url as string, provider: 'github', providerId: String(githubUser.id), }, }); try { - const encryptedToken = encrypt(tokenData.access_token); + const encryptedToken = encrypt(tokenData.access_token as string); await app.prisma.oAuthToken.upsert({ where: { userId_platform: { userId: user.id, platform: 'github' } }, update: { accessToken: encryptedToken, scopes: 'read:user user:email' }, @@ -153,8 +153,8 @@ export async function authRoutes(app: FastifyInstance) { // Google OAuth start app.get('/google', async (request: FastifyRequest, reply: FastifyReply) => { const redirectUri = `${process.env.BACKEND_URL}/auth/google/callback`; - const clientState = (request.query as any).state || ''; - const mobileRedirectUri = (request.query as any).mobile_redirect_uri || ''; + const clientState = (request.query as { state?: string }).state || ''; + const mobileRedirectUri = (request.query as { mobile_redirect_uri?: string }).mobile_redirect_uri || ''; const state = buildOAuthState(clientState, mobileRedirectUri); reply.setCookie('oauth_state', state, { @@ -206,27 +206,31 @@ export async function authRoutes(app: FastifyInstance) { }), }); - const tokenData = (await tokenRes.json()) as any; + const tokenData = (await tokenRes.json()) as Record; if (tokenData.error) { app.log.error({ tokenData }, 'Google token error'); return reply.status(400).send({ error: 'Failed to authenticate with Google' }); } const userRes = await fetch(GOOGLE_USER_URL, { headers: { Authorization: `Bearer ${tokenData.access_token}` } }); - const googleUser = (await userRes.json()) as any; + const googleUser = (await userRes.json()) as Record; - const baseUsername = googleUser.email.split('@')[0].replace(/[^a-zA-Z0-9_-]/g, ''); + const baseUsername = (googleUser.email as string).split('@')[0].replace(/[^a-zA-Z0-9_-]/g, ''); const user = await app.prisma.user.upsert({ - where: { provider_providerId: { provider: 'google', providerId: googleUser.id } }, - update: { email: googleUser.email, displayName: googleUser.name || baseUsername, avatarUrl: googleUser.picture }, + where: { provider_providerId: { provider: 'google', providerId: googleUser.id as string } }, + update: { + email: googleUser.email as string, + displayName: (googleUser.name as string) || baseUsername, + avatarUrl: googleUser.picture as string, + }, create: { - email: googleUser.email, + email: googleUser.email as string, username: `${baseUsername}_${Date.now().toString(36)}`, - displayName: googleUser.name || baseUsername, - avatarUrl: googleUser.picture, + displayName: (googleUser.name as string) || baseUsername, + avatarUrl: googleUser.picture as string, provider: 'google', - providerId: googleUser.id, + providerId: googleUser.id as string, }, }); @@ -254,12 +258,12 @@ export async function authRoutes(app: FastifyInstance) { // Current user app.get('/me', { preHandler: [async (request, reply) => { - const server = request.server as any; - if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } - if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + const server = request.server as FastifyInstance; + if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return; } + if (typeof app.authenticate === 'function') { await app.authenticate(request, reply); return; } + try { await request.jwtVerify(); } catch { reply.status(401).send({ error: 'Unauthorized' }); } }] }, async (request: FastifyRequest, reply: FastifyReply) => { - const userId = (request.user as any).id; + const userId = (request.user as { id: string }).id; const user = await app.prisma.user.findUnique({ where: { id: userId }, select: { @@ -286,7 +290,7 @@ export async function authRoutes(app: FastifyInstance) { return { ...userData, connectedPlatforms: oauthTokens }; }); - app.post('/logout', async (request: FastifyRequest, reply: FastifyReply) => { + app.post('/logout', async (_request: FastifyRequest, reply: FastifyReply) => { reply.clearCookie('token', { path: '/' }); return { message: 'Logged out' }; }); diff --git a/apps/backend/src/routes/public.ts b/apps/backend/src/routes/public.ts index 27f544d8..b21dd817 100644 --- a/apps/backend/src/routes/public.ts +++ b/apps/backend/src/routes/public.ts @@ -1,9 +1,7 @@ import type { FastifyContextConfig, FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { generateQRBuffer, generateQRSvg } from '../utils/qr.js'; import type { PlatformLink } from '@devcard/shared'; -import { getErrorMessage } from '../utils/error.util.js'; -import * as publicService from '../services/publicService' - +import * as publicService from '../services/publicService.js'; // ── QR size bounds ──────────────────────────────────────────────────────────── // Enforced before any DB query or image allocation. Values outside this range @@ -16,7 +14,6 @@ const MAX_QR_SIZE = 2048; // Public profile cache TTL matches the Cache-Control max-age (5 minutes). // The QR session JWT TTL is 10 minutes so an offline scan remains valid well // beyond the HTTP cache window. -const PROFILE_CACHE_TTL = 300; // seconds (5 minutes) const CACHE_CONTROL_HEADER = 'public, max-age=300, stale-while-revalidate=60'; type PublicProfileLink = { @@ -26,7 +23,7 @@ type PublicProfileLink = { url: string; displayOrder: number; followed?: boolean; -} +}; type UsernamePublicProfileResponse = { username: string; @@ -37,8 +34,8 @@ type UsernamePublicProfileResponse = { company: string | null; avatarUrl: string | null; accentColor: string; - links: PublicProfileLink[] -} + links: PublicProfileLink[]; +}; type PublicProfileCardLink = { id: string; @@ -46,7 +43,7 @@ type PublicProfileCardLink = { username: string; url: string; followed?: boolean; -} +}; type CardPublicProfileResponse = { id: string; @@ -58,8 +55,8 @@ type CardPublicProfileResponse = { avatarUrl: string | null; accentColor: string; }; - links: PublicProfileCardLink[] -} + links: PublicProfileCardLink[]; +}; type UsernameCardPublicProfileResponse = { title: string; @@ -73,26 +70,17 @@ type UsernameCardPublicProfileResponse = { avatarUrl: string | null; accentColor: string; }; - links: PublicProfileCardLink[] -} + links: PublicProfileCardLink[]; +}; -// Represents a CardLink record with the joined PlatformLink relation interface CardLinkWithPlatform { id: string; displayOrder: number; platformLink: PlatformLink; } -// ── Internal Redis cache shape ──────────────────────────────────────────────── -// Extends the public response with the owner's DB id so that background view -// tracking can still fire on cache-HIT requests without an extra DB read. -type CachedProfileEntry = UsernamePublicProfileResponse & { _userId: string }; - - export async function publicRoutes(app: FastifyInstance) { - // ─── Public Profile ─────────────────────────────────────────────────────── - // ─── Public Profile ─── - /** + /** * GET /api/u/:username * Returns the public profile information for a user. */ @@ -105,66 +93,72 @@ export async function publicRoutes(app: FastifyInstance) { }, }, async (request: FastifyRequest<{ Params: { username: string } }>, reply: FastifyReply) => { const { username } = request.params; - const cacheKey = `profile:${username}`; - // Try to extract viewer from Authorization header (soft auth). - let viewerId: string | null = null + let viewerId: string | null = null; try { if (request.headers.authorization) { - const decoded = (await request.jwtVerify()) as { id?: string } - viewerId = decoded?.id ?? null - } else { - viewerId = null + const decoded = (await request.jwtVerify()) as { id?: string }; + viewerId = decoded?.id ?? null; } } catch { // ignored } try { - const result = await publicService.getPublicProfile(app, username, viewerId, request) - if (!result) return reply.status(404).send({ error: 'User not found' }) - reply.header('X-Cache', result.cached ? 'HIT' : 'MISS').header('Cache-Control', CACHE_CONTROL_HEADER) - return result.data - } catch (err: any) { - app.log.error({ err }, 'Failed to fetch public profile') - return reply.status(500).send({ error: 'Internal server error' }) + const result = await publicService.getPublicProfile(app, username, viewerId, request); + if (!result) return reply.status(404).send({ error: 'User not found' }); + reply.header('X-Cache', result.cached ? 'HIT' : 'MISS').header('Cache-Control', CACHE_CONTROL_HEADER); + return result.data; + } catch (err: unknown) { + app.log.error({ err }, 'Failed to fetch public profile'); + return reply.status(500).send({ error: 'Internal server error' }); } }); /** * GET /api/public/card/:cardId * Returns public data for a shared card via its direct link. - * Used for standalone card sharing (minimal owner info). - */ - // ─── Shared Card View (Direct) ─── - + */ app.get('/card/:cardId', { config: { rateLimit: { max: 100, - timeWindow: '1 minute' - } - } as FastifyContextConfig + timeWindow: '1 minute', + }, + } as FastifyContextConfig, }, async (request: FastifyRequest<{ Params: { cardId: string } }>, reply: FastifyReply) => { const { cardId } = request.params; try { - const card = await publicService.getCardById(app, cardId) - if (!card) return reply.status(404).send({ error: 'Card not found' }) - const response = { id: card.id, title: card.title, owner: { username: card.user.username, displayName: card.user.displayName, bio: card.user.bio, avatarUrl: card.user.avatarUrl, accentColor: card.user.accentColor }, links: card.cardLinks.map((cl: any) => ({ id: cl.platformLink.id, platform: cl.platformLink.platform, username: cl.platformLink.username, url: cl.platformLink.url })) } - return response - } catch (err: any) { - app.log.error({ err }, 'Failed to fetch shared card') - return reply.status(500).send({ error: 'Internal server error' }) + const card = await publicService.getCardById(app, cardId); + if (!card) return reply.status(404).send({ error: 'Card not found' }); + const response: CardPublicProfileResponse = { + id: card.id, + title: card.title, + owner: { + username: card.user.username, + displayName: card.user.displayName, + bio: card.user.bio, + avatarUrl: card.user.avatarUrl, + accentColor: card.user.accentColor, + }, + links: card.cardLinks.map((cl: CardLinkWithPlatform) => ({ + id: cl.platformLink.id, + platform: cl.platformLink.platform, + username: cl.platformLink.username, + url: cl.platformLink.url, + })), + }; + return response; + } catch (err: unknown) { + app.log.error({ err }, 'Failed to fetch shared card'); + return reply.status(500).send({ error: 'Internal server error' }); } }); - // ─── Public Card View ───────────────────────────────────────────────────── - // ─── Public Card View ─── /** * GET /api/u/:username/card/:cardId * Returns full owner profile + specific card data. - * Used when viewing a card through username + cardId (e.g. QR code scans). */ app.get('/:username/card/:cardId', { config: { @@ -176,76 +170,71 @@ export async function publicRoutes(app: FastifyInstance) { }, async (request: FastifyRequest<{ Params: { username: string; cardId: string } }>, reply: FastifyReply) => { const { username, cardId } = request.params; - let viewerId: string | null = null + let viewerId: string | null = null; try { if (request.headers.authorization) { - const decoded = (await request.jwtVerify()) as { id?: string } - viewerId = decoded?.id ?? null + const decoded = (await request.jwtVerify()) as { id?: string }; + viewerId = decoded?.id ?? null; } } catch { // ignored } try { - const result = await publicService.getUserCard(app, username, cardId, viewerId, request) - if (result.notFound) return reply.status(404).send({ error: 'User or card not found' }) - return result.data - } catch (err: any) { - app.log.error({ err }, 'Failed to fetch user card') - return reply.status(500).send({ error: 'Internal server error' }) + const result = await publicService.getUserCard(app, username, cardId, viewerId, request); + if (result.notFound) return reply.status(404).send({ error: 'User or card not found' }); + return result.data as UsernameCardPublicProfileResponse; + } catch (err: unknown) { + app.log.error({ err }, 'Failed to fetch user card'); + return reply.status(500).send({ error: 'Internal server error' }); } }); - // ─── QR Session ────────────────────────────────────────────────────────── - // Returns a short-lived signed JWT encoding the public profile snapshot. - // Intended for native apps to generate QR codes that remain scannable when - // the device has no live network connectivity (offline QR mode, spec §5.9). + /** + * GET /api/u/:username/qr-session + * Returns a short-lived signed JWT encoding the public profile snapshot. + */ app.get('/:username/qr-session', { config: { rateLimit: { max: 30, - timeWindow: '1 minute' - } - } as FastifyContextConfig + timeWindow: '1 minute', + }, + } as FastifyContextConfig, }, async (request: FastifyRequest<{ Params: { username: string } }>, reply: FastifyReply) => { const { username } = request.params; - const cacheKey = `profile:${username}`; try { - const result = await publicService.getPublicProfile(app, username, null, request) - if (!result) return reply.status(404).send({ error: 'User not found' }) - const snapshot = result.data - const expiresIn = 600 - const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString() - const token = app.jwt.sign({ profile: snapshot, sub: username }, { expiresIn: '10m' }) - reply.header('Cache-Control', CACHE_CONTROL_HEADER) - return { token, tokenType: 'JWT', expiresIn, expiresAt } - } catch (err: any) { - app.log.error({ err }, 'Failed to create qr-session') - return reply.status(500).send({ error: 'Internal server error' }) + const result = await publicService.getPublicProfile(app, username, null, request); + if (!result) return reply.status(404).send({ error: 'User not found' }); + const snapshot = result.data as UsernamePublicProfileResponse; + const expiresIn = 600; + const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); + const token = app.jwt.sign({ profile: snapshot, sub: username }, { expiresIn: '10m' }); + reply.header('Cache-Control', CACHE_CONTROL_HEADER); + return { token, tokenType: 'JWT', expiresIn, expiresAt }; + } catch (err: unknown) { + app.log.error({ err }, 'Failed to create qr-session'); + return reply.status(500).send({ error: 'Internal server error' }); } }); - // ─── QR Code Generation ─────────────────────────────────────────────────── + // ─── QR Code Generation ─── app.get('/:username/qr', { config: { rateLimit: { - max: 50, // Lower limit for QR generation as it's more resource intensive - timeWindow: '1 minute' - } - } as FastifyContextConfig + max: 50, + timeWindow: '1 minute', + }, + } as FastifyContextConfig, }, async (request: FastifyRequest<{ Params: { username: string }; Querystring: { format?: string; size?: string }; }>, reply: FastifyReply) => { const { username } = request.params; - const format = (request.query as any).format || 'png'; - - // Parse and validate size before touching the DB or allocating any buffers. - // parseInt safely handles non-numeric strings (returns NaN) and ignores any - // trailing fractional part, so '400.9' → 400 which is within bounds. - const rawSize = (request.query as any).size; + const format = request.query.format || 'png'; + const rawSize = request.query.size; const size = rawSize !== undefined ? parseInt(rawSize, 10) : 400; if (!Number.isInteger(size) || size < MIN_QR_SIZE || size > MAX_QR_SIZE) { @@ -254,7 +243,6 @@ export async function publicRoutes(app: FastifyInstance) { }); } - // Verify user exists const user = await app.prisma.user.findUnique({ where: { username }, }); @@ -267,14 +255,21 @@ export async function publicRoutes(app: FastifyInstance) { try { if (format === 'svg') { - const svg = await generateQRSvg(profileUrl, { width: size }) - return reply.header('Content-Type', 'image/svg+xml').header('Content-Disposition', `inline; filename="devcard-${username}.svg"`).send(svg) + const svg = await generateQRSvg(profileUrl, { width: size }); + return reply + .header('Content-Type', 'image/svg+xml') + .header('Content-Disposition', `inline; filename="devcard-${username}.svg"`) + .send(svg); } - const png = await generateQRBuffer(profileUrl, { width: size }) - return reply.header('Content-Type', 'image/png').header('Content-Disposition', `inline; filename="devcard-${username}.png"`).send(png) + + const png = await generateQRBuffer(profileUrl, { width: size }); + return reply + .header('Content-Type', 'image/png') + .header('Content-Disposition', `inline; filename="devcard-${username}.png"`) + .send(png); } catch (error) { - app.log.error({ error, username, size, format }, 'QR generation failed') - return reply.status(500).send({ error: 'QR code generation failed' }) + app.log.error({ error, username, size, format }, 'QR generation failed'); + return reply.status(500).send({ error: 'QR code generation failed' }); } }); } diff --git a/apps/backend/src/services/analytics/trackEvent.ts b/apps/backend/src/services/analytics/trackEvent.ts new file mode 100644 index 00000000..22161ca7 --- /dev/null +++ b/apps/backend/src/services/analytics/trackEvent.ts @@ -0,0 +1,25 @@ +import type { PrismaClient } from '@prisma/client'; +import { hashIp } from '../../utils/hashIp.js'; + +export type TrackViewInput = { + ownerId: string; + cardId?: string | null; + viewerId?: string | null; + ip?: string; + userAgent?: string; + source?: string; +}; + +/** Records a profile or card view via the existing CardView model. */ +export async function trackEvent(prisma: PrismaClient, data: TrackViewInput) { + return prisma.cardView.create({ + data: { + ownerId: data.ownerId, + cardId: data.cardId ?? null, + viewerId: data.viewerId ?? null, + viewerIp: hashIp(data.ip), + viewerAgent: data.userAgent ?? null, + source: data.source ?? 'link', + }, + }); +} diff --git a/apps/backend/src/services/publicService.ts b/apps/backend/src/services/publicService.ts index 758ab78f..5fec2d02 100644 --- a/apps/backend/src/services/publicService.ts +++ b/apps/backend/src/services/publicService.ts @@ -1,67 +1,178 @@ -import type { FastifyInstance } from 'fastify' -import { getErrorMessage } from '../utils/error.util.js' +import type { FastifyInstance } from 'fastify'; +import { getErrorMessage } from '../utils/error.util.js'; +import { trackEvent } from './analytics/trackEvent.js'; -const PROFILE_CACHE_TTL = 300 -const CACHE_CONTROL_HEADER = 'public, max-age=300, stale-while-revalidate=60' +const PROFILE_CACHE_TTL = 300; -export async function getPublicProfile(app: FastifyInstance, username: string, viewerId: string | null, request: any) { - const cacheKey = `profile:${username}` +function logView( + app: FastifyInstance, + ownerId: string, + cardId: string | null, + viewerId: string | null, + request: { ip?: string; headers: Record; query?: { source?: string } }, + defaultSource: string, +) { + if (viewerId === ownerId) return; + + trackEvent(app.prisma, { + ownerId, + cardId, + viewerId, + ip: request.ip, + userAgent: + typeof request.headers['user-agent'] === 'string' + ? request.headers['user-agent'] + : undefined, + source: request.query?.source || defaultSource, + }).catch((err: unknown) => app.log.error(`Failed to log view: ${getErrorMessage(err)}`)); +} + +export async function getPublicProfile( + app: FastifyInstance, + username: string, + viewerId: string | null, + request: { ip?: string; headers: Record; query?: { source?: string } }, +) { + const cacheKey = `profile:${username}`; if (app.redis) { try { - const cached = await app.redis.get(cacheKey) + const cached = await app.redis.get(cacheKey); if (cached) { - const { _userId, ...profileData } = JSON.parse(cached) - if (viewerId && viewerId !== _userId) { - app.prisma.cardView.create({ data: { ownerId: _userId, cardId: null, viewerId, viewerIp: request.ip || null, viewerAgent: request.headers['user-agent'] || null, source: request.query?.source || 'link' } }).catch((err: unknown) => app.log.error(`Failed to log view: ${getErrorMessage(err)}`)) - } - return { cached: true, data: profileData, cacheKey } + const { _userId, ...profileData } = JSON.parse(cached) as { _userId: string }; + logView(app, _userId, null, viewerId, request, 'link'); + return { cached: true, data: profileData, cacheKey }; } } catch (err) { - app.log.warn(`Redis cache read failed for ${cacheKey}: ${getErrorMessage(err)}`) + app.log.warn(`Redis cache read failed for ${cacheKey}: ${getErrorMessage(err)}`); } } - const user = await app.prisma.user.findUnique({ where: { username }, include: { platformLinks: { orderBy: { displayOrder: 'asc' } } } }) - if (!user) return null + const user = await app.prisma.user.findUnique({ + where: { username }, + include: { platformLinks: { orderBy: { displayOrder: 'asc' } } }, + }); + if (!user) return null; - if (viewerId && viewerId !== user.id) { - app.prisma.cardView.create({ data: { ownerId: user.id, cardId: null, 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)}`)) - } + logView(app, user.id, null, viewerId, request, 'link'); - let followedLinkIds: string[] = [] + let followedLinkIds: string[] = []; if (viewerId && user.platformLinks.length > 0) { - const successfulFollows = await app.prisma.followLog.findMany({ where: { followerId: viewerId, status: 'success', OR: user.platformLinks.map((link: any) => ({ platform: link.platform, targetUsername: link.username })) }, select: { platform: true, targetUsername: true } }) - followedLinkIds = user.platformLinks.filter((link: any) => successfulFollows.some((f: any) => f.platform === link.platform && f.targetUsername.toLowerCase() === link.username.toLowerCase())).map((l: any) => l.id) + const successfulFollows = await app.prisma.followLog.findMany({ + where: { + followerId: viewerId, + status: 'success', + OR: user.platformLinks.map((link) => ({ + platform: link.platform, + targetUsername: link.username, + })), + }, + select: { platform: true, targetUsername: true }, + }); + followedLinkIds = user.platformLinks + .filter((link) => + successfulFollows.some( + (f) => + f.platform === link.platform && + f.targetUsername.toLowerCase() === link.username.toLowerCase(), + ), + ) + .map((link) => link.id); } - const baseLinks = user.platformLinks.map((link: any) => ({ id: link.id, platform: link.platform, username: link.username, url: link.url, displayOrder: link.displayOrder, followed: false })) + const baseLinks = user.platformLinks.map((link) => ({ + id: link.id, + platform: link.platform, + username: link.username, + url: link.url, + displayOrder: link.displayOrder, + followed: false, + })); if (app.redis) { - const entry = { _userId: user.id, 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: baseLinks } - app.redis.set(cacheKey, JSON.stringify(entry), 'EX', PROFILE_CACHE_TTL).catch((err: unknown) => app.log.warn(`Redis cache write failed for ${cacheKey}: ${getErrorMessage(err)}`)) + const entry = { + _userId: user.id, + 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: baseLinks, + }; + app.redis + .set(cacheKey, JSON.stringify(entry), 'EX', PROFILE_CACHE_TTL) + .catch((err: unknown) => app.log.warn(`Redis cache write failed for ${cacheKey}: ${getErrorMessage(err)}`)); } - const response = { 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: baseLinks.map((link) => ({ ...link, followed: followedLinkIds.includes(link.id) })) } + const response = { + 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: baseLinks.map((link) => ({ + ...link, + followed: followedLinkIds.includes(link.id), + })), + }; - return { cached: false, data: response, cacheKey } + return { cached: false, data: response, cacheKey }; } export async function getCardById(app: FastifyInstance, cardId: string) { - const card = await app.prisma.card.findUnique({ where: { id: cardId }, include: { user: true, cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } } }) - return card + return app.prisma.card.findUnique({ + where: { id: cardId }, + include: { + user: true, + cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } }, + }, + }); } -export async function getUserCard(app: FastifyInstance, username: string, cardId: string, viewerId: string | null, request: any) { - const user = await app.prisma.user.findUnique({ where: { username } }) - if (!user) return { notFound: true } - const card = await app.prisma.card.findFirst({ where: { id: cardId, userId: user.id }, include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } } }) - if (!card) return { notFound: true } +export async function getUserCard( + app: FastifyInstance, + username: string, + cardId: string, + viewerId: string | null, + request: { ip?: string; headers: Record; query?: { source?: string } }, +) { + const user = await app.prisma.user.findUnique({ where: { username } }); + if (!user) return { notFound: true }; - if (viewerId && viewerId !== user.id) { - app.prisma.cardView.create({ data: { ownerId: user.id, cardId: card.id, viewerId, viewerIp: request.ip || null, viewerAgent: request.headers['user-agent'] || null, source: request.query?.source || 'qr' } }).catch((error: unknown) => app.log.error(`Failed to log view: ${getErrorMessage(error)}`)) - } + const card = await app.prisma.card.findFirst({ + where: { id: cardId, userId: user.id }, + include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, + }); + if (!card) return { notFound: true }; + + logView(app, user.id, card.id, viewerId, request, 'qr'); + + const response = { + title: card.title, + owner: { + 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: card.cardLinks.map((cl) => ({ + id: cl.platformLink.id, + platform: cl.platformLink.platform, + username: cl.platformLink.username, + url: cl.platformLink.url, + displayOrder: cl.displayOrder, + })), + }; - const response = { title: card.title, owner: { 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: card.cardLinks.map((cl: any) => ({ id: cl.platformLink.id, platform: cl.platformLink.platform, username: cl.platformLink.username, url: cl.platformLink.url, displayOrder: cl.displayOrder })) } - return { notFound: false, data: response } + return { notFound: false, data: response }; } diff --git a/apps/backend/src/utils/hashIp.ts b/apps/backend/src/utils/hashIp.ts new file mode 100644 index 00000000..31fadbbd --- /dev/null +++ b/apps/backend/src/utils/hashIp.ts @@ -0,0 +1,7 @@ +import crypto from 'crypto'; + +/** Returns a SHA-256 hex digest of the IP, or null when absent. */ +export function hashIp(ip?: string | null): string | null { + if (!ip) return null; + return crypto.createHash('sha256').update(ip).digest('hex'); +}