1- import type { FastifyContextConfig , FastifyInstance , FastifyRequest , FastifyReply } from 'fastify ' ;
1+ import * as publicService from '../services/publicService ' ;
22import { generateQRBuffer , generateQRSvg } from '../utils/qr.js' ;
3- import type { PlatformLink } from '@devcard/shared' ;
4- import { getErrorMessage } from '../utils/error.util.js' ;
5- import * as publicService from '../services/publicService'
63
4+ import type { FastifyContextConfig , FastifyInstance , FastifyRequest , FastifyReply } from 'fastify' ;
75
86// ── QR size bounds ────────────────────────────────────────────────────────────
97// Enforced before any DB query or image allocation. Values outside this range
@@ -16,83 +14,11 @@ const MAX_QR_SIZE = 2048;
1614// Public profile cache TTL matches the Cache-Control max-age (5 minutes).
1715// The QR session JWT TTL is 10 minutes so an offline scan remains valid well
1816// beyond the HTTP cache window.
19- const PROFILE_CACHE_TTL = 300 ; // seconds (5 minutes)
2017const CACHE_CONTROL_HEADER = 'public, max-age=300, stale-while-revalidate=60' ;
2118
22- type PublicProfileLink = {
23- id : string ;
24- platform : string ;
25- username : string ;
26- url : string ;
27- displayOrder : number ;
28- followed ?: boolean ;
29- }
30-
31- type UsernamePublicProfileResponse = {
32- username : string ;
33- displayName : string ;
34- bio : string | null ;
35- pronouns : string | null ;
36- role : string | null ;
37- company : string | null ;
38- avatarUrl : string | null ;
39- accentColor : string ;
40- links : PublicProfileLink [ ]
41- }
42-
43- type PublicProfileCardLink = {
44- id : string ;
45- platform : string ;
46- username : string ;
47- url : string ;
48- followed ?: boolean ;
49- }
50-
51- type CardPublicProfileResponse = {
52- id : string ;
53- title : string ;
54- owner : {
55- username : string ;
56- displayName : string ;
57- bio : string | null ;
58- avatarUrl : string | null ;
59- accentColor : string ;
60- } ;
61- links : PublicProfileCardLink [ ]
62- }
63-
64- type UsernameCardPublicProfileResponse = {
65- title : string ;
66- owner : {
67- username : string ;
68- displayName : string ;
69- bio : string | null ;
70- pronouns : string | null ;
71- role : string | null ;
72- company : string | null ;
73- avatarUrl : string | null ;
74- accentColor : string ;
75- } ;
76- links : PublicProfileCardLink [ ]
77- }
78-
79- // Represents a CardLink record with the joined PlatformLink relation
80- interface CardLinkWithPlatform {
81- id : string ;
82- displayOrder : number ;
83- platformLink : PlatformLink ;
84- }
85-
86- // ── Internal Redis cache shape ────────────────────────────────────────────────
87- // Extends the public response with the owner's DB id so that background view
88- // tracking can still fire on cache-HIT requests without an extra DB read.
89- type CachedProfileEntry = UsernamePublicProfileResponse & { _userId : string } ;
90-
91-
92- export async function publicRoutes ( app : FastifyInstance ) {
19+ export async function publicRoutes ( app : FastifyInstance ) : Promise < void > {
9320 // ─── Public Profile ───────────────────────────────────────────────────────
94- // ─── Public Profile ───
95- /**
21+ /**
9622 * GET /api/u/:username
9723 * Returns the public profile information for a user.
9824 */
@@ -105,29 +31,30 @@ export async function publicRoutes(app: FastifyInstance) {
10531 } ,
10632 } , async ( request : FastifyRequest < { Params : { username : string } } > , reply : FastifyReply ) => {
10733 const { username } = request . params ;
108- const cacheKey = `profile:${ username } ` ;
10934
11035 // Try to extract viewer from Authorization header (soft auth).
111- let viewerId : string | null = null
36+ let viewerId : string | null = null ;
11237 try {
11338 if ( request . headers . authorization ) {
114- const decoded = ( await request . jwtVerify ( ) ) as { id ?: string }
115- viewerId = decoded ?. id ?? null
39+ const decoded = ( await request . jwtVerify ( ) ) as { id ?: string } ;
40+ viewerId = decoded ?. id ?? null ;
11641 } else {
117- viewerId = null
42+ viewerId = null ;
11843 }
11944 } catch {
12045 // ignored
12146 }
12247
12348 try {
124- const result = await publicService . getPublicProfile ( app , username , viewerId , request )
125- if ( ! result ) return reply . status ( 404 ) . send ( { error : 'User not found' } )
126- reply . header ( 'X-Cache' , result . cached ? 'HIT' : 'MISS' ) . header ( 'Cache-Control' , CACHE_CONTROL_HEADER )
127- return result . data
128- } catch ( err : any ) {
129- app . log . error ( { err } , 'Failed to fetch public profile' )
130- return reply . status ( 500 ) . send ( { error : 'Internal server error' } )
49+ const result = await publicService . getPublicProfile ( app , username , viewerId , request ) ;
50+ if ( ! result ) {
51+ return reply . status ( 404 ) . send ( { error : 'User not found' } ) ;
52+ }
53+ reply . header ( 'X-Cache' , result . cached ? 'HIT' : 'MISS' ) . header ( 'Cache-Control' , CACHE_CONTROL_HEADER ) ;
54+ return result . data ;
55+ } catch ( err : unknown ) {
56+ app . log . error ( { err } , 'Failed to fetch public profile' ) ;
57+ return reply . status ( 500 ) . send ( { error : 'Internal server error' } ) ;
13158 }
13259 } ) ;
13360
@@ -149,18 +76,35 @@ export async function publicRoutes(app: FastifyInstance) {
14976 const { cardId } = request . params ;
15077
15178 try {
152- const card = await publicService . getCardById ( app , cardId )
153- if ( ! card ) return reply . status ( 404 ) . send ( { error : 'Card not found' } )
154- 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 } ) ) }
155- return response
156- } catch ( err : any ) {
157- app . log . error ( { err } , 'Failed to fetch shared card' )
158- return reply . status ( 500 ) . send ( { error : 'Internal server error' } )
79+ const card = await publicService . getCardById ( app , cardId ) ;
80+ if ( ! card ) {
81+ return reply . status ( 404 ) . send ( { error : 'Card not found' } ) ;
82+ }
83+ const response = {
84+ id : card . id ,
85+ title : card . title ,
86+ owner : {
87+ username : card . user . username ,
88+ displayName : card . user . displayName ,
89+ bio : card . user . bio ,
90+ avatarUrl : card . user . avatarUrl ,
91+ accentColor : card . user . accentColor ,
92+ } ,
93+ links : card . cardLinks . map ( ( cl : any ) => ( {
94+ id : cl . platformLink . id ,
95+ platform : cl . platformLink . platform ,
96+ username : cl . platformLink . username ,
97+ url : cl . platformLink . url ,
98+ } ) ) ,
99+ } ;
100+ return response ;
101+ } catch ( err : unknown ) {
102+ app . log . error ( { err } , 'Failed to fetch shared card' ) ;
103+ return reply . status ( 500 ) . send ( { error : 'Internal server error' } ) ;
159104 }
160105 } ) ;
161106
162107 // ─── Public Card View ─────────────────────────────────────────────────────
163- // ─── Public Card View ───
164108 /**
165109 * GET /api/u/:username/card/:cardId
166110 * Returns full owner profile + specific card data.
@@ -176,23 +120,25 @@ export async function publicRoutes(app: FastifyInstance) {
176120 } , async ( request : FastifyRequest < { Params : { username : string ; cardId : string } } > , reply : FastifyReply ) => {
177121 const { username, cardId } = request . params ;
178122
179- let viewerId : string | null = null
123+ let viewerId : string | null = null ;
180124 try {
181125 if ( request . headers . authorization ) {
182- const decoded = ( await request . jwtVerify ( ) ) as { id ?: string }
183- viewerId = decoded ?. id ?? null
126+ const decoded = ( await request . jwtVerify ( ) ) as { id ?: string } ;
127+ viewerId = decoded ?. id ?? null ;
184128 }
185129 } catch {
186130 // ignored
187131 }
188132
189133 try {
190- const result = await publicService . getUserCard ( app , username , cardId , viewerId , request )
191- if ( result . notFound ) return reply . status ( 404 ) . send ( { error : 'User or card not found' } )
192- return result . data
193- } catch ( err : any ) {
194- app . log . error ( { err } , 'Failed to fetch user card' )
195- return reply . status ( 500 ) . send ( { error : 'Internal server error' } )
134+ const result = await publicService . getUserCard ( app , username , cardId , viewerId , request ) ;
135+ if ( result . notFound ) {
136+ return reply . status ( 404 ) . send ( { error : 'User or card not found' } ) ;
137+ }
138+ return result . data ;
139+ } catch ( err : unknown ) {
140+ app . log . error ( { err } , 'Failed to fetch user card' ) ;
141+ return reply . status ( 500 ) . send ( { error : 'Internal server error' } ) ;
196142 }
197143 } ) ;
198144
@@ -209,20 +155,21 @@ export async function publicRoutes(app: FastifyInstance) {
209155 } as FastifyContextConfig
210156 } , async ( request : FastifyRequest < { Params : { username : string } } > , reply : FastifyReply ) => {
211157 const { username } = request . params ;
212- const cacheKey = `profile:${ username } ` ;
213158
214159 try {
215- const result = await publicService . getPublicProfile ( app , username , null , request )
216- if ( ! result ) return reply . status ( 404 ) . send ( { error : 'User not found' } )
217- const snapshot = result . data
218- const expiresIn = 600
219- const expiresAt = new Date ( Date . now ( ) + expiresIn * 1000 ) . toISOString ( )
220- const token = app . jwt . sign ( { profile : snapshot , sub : username } , { expiresIn : '10m' } )
221- reply . header ( 'Cache-Control' , CACHE_CONTROL_HEADER )
222- return { token, tokenType : 'JWT' , expiresIn, expiresAt }
223- } catch ( err : any ) {
224- app . log . error ( { err } , 'Failed to create qr-session' )
225- return reply . status ( 500 ) . send ( { error : 'Internal server error' } )
160+ const result = await publicService . getPublicProfile ( app , username , null , request ) ;
161+ if ( ! result ) {
162+ return reply . status ( 404 ) . send ( { error : 'User not found' } ) ;
163+ }
164+ const snapshot = result . data ;
165+ const expiresIn = 600 ;
166+ const expiresAt = new Date ( Date . now ( ) + expiresIn * 1000 ) . toISOString ( ) ;
167+ const token = app . jwt . sign ( { profile : snapshot , sub : username } , { expiresIn : '10m' } ) ;
168+ reply . header ( 'Cache-Control' , CACHE_CONTROL_HEADER ) ;
169+ return { token, tokenType : 'JWT' , expiresIn, expiresAt } ;
170+ } catch ( err : unknown ) {
171+ app . log . error ( { err } , 'Failed to create qr-session' ) ;
172+ return reply . status ( 500 ) . send ( { error : 'Internal server error' } ) ;
226173 }
227174 } ) ;
228175
@@ -267,14 +214,21 @@ export async function publicRoutes(app: FastifyInstance) {
267214
268215 try {
269216 if ( format === 'svg' ) {
270- const svg = await generateQRSvg ( profileUrl , { width : size } )
271- return reply . header ( 'Content-Type' , 'image/svg+xml' ) . header ( 'Content-Disposition' , `inline; filename="devcard-${ username } .svg"` ) . send ( svg )
217+ const svg = await generateQRSvg ( profileUrl , { width : size } ) ;
218+ return reply
219+ . header ( 'Content-Type' , 'image/svg+xml' )
220+ . header ( 'Content-Disposition' , `inline; filename="devcard-${ username } .svg"` )
221+ . send ( svg ) ;
272222 }
273- const png = await generateQRBuffer ( profileUrl , { width : size } )
274- return reply . header ( 'Content-Type' , 'image/png' ) . header ( 'Content-Disposition' , `inline; filename="devcard-${ username } .png"` ) . send ( png )
223+
224+ const png = await generateQRBuffer ( profileUrl , { width : size } ) ;
225+ return reply
226+ . header ( 'Content-Type' , 'image/png' )
227+ . header ( 'Content-Disposition' , `inline; filename="devcard-${ username } .png"` )
228+ . send ( png ) ;
275229 } catch ( error ) {
276- app . log . error ( { error, username, size, format } , 'QR generation failed' )
277- return reply . status ( 500 ) . send ( { error : 'QR code generation failed' } )
230+ app . log . error ( { error, username, size, format } , 'QR generation failed' ) ;
231+ return reply . status ( 500 ) . send ( { error : 'QR code generation failed' } ) ;
278232 }
279233 } ) ;
280234}
0 commit comments