1- import { FastifyInstance } from 'fastify' ;
1+ import { FastifyInstance , FastifyReply } from 'fastify' ;
2+ import { retryFetch } from '../integrations/retry' ;
3+ import { WEBAPP_MAGIC_IMAGE_PREFIX } from '../config' ;
4+ import createOrGetConnection from '../db' ;
5+ import { User } from '../entity' ;
26
37// Record types matching the webapp's RecordType enum
48const RecordType = {
@@ -25,7 +29,7 @@ const MOCK_LOG_DATA = {
2529
2630 // Card 2: When You Read
2731 peakDay : 'Thursday' ,
28- readingPattern : 'night' ,
32+ readingPattern : 'night' as const ,
2933 patternPercentile : 8 ,
3034 activityHeatmap : Array ( 7 )
3135 . fill ( null )
@@ -113,7 +117,7 @@ const MOCK_LOG_DATA = {
113117 ] ,
114118
115119 // Card 8: Archetype
116- archetype : 'COLLECTOR' ,
120+ archetype : 'COLLECTOR' as const ,
117121 archetypeStat : 'Only 12% of developers read as late as you' ,
118122 archetypePercentile : 12 ,
119123
@@ -123,7 +127,100 @@ const MOCK_LOG_DATA = {
123127 shareCount : 24853 ,
124128} ;
125129
130+ // Valid card types for log share images (welcome is not shareable)
131+ const VALID_CARD_TYPES = [
132+ 'total-impact' ,
133+ 'when-you-read' ,
134+ 'topic-evolution' ,
135+ 'favorite-sources' ,
136+ 'community' ,
137+ 'contributions' ,
138+ 'records' ,
139+ 'archetype' ,
140+ 'share' ,
141+ ] as const ;
142+
143+ type CardType = ( typeof VALID_CARD_TYPES ) [ number ] ;
144+
145+ /**
146+ * Extract only the data needed for a specific card type.
147+ * This keeps the base64 URL payload small.
148+ */
149+ function extractCardData ( card : CardType , logData : typeof MOCK_LOG_DATA ) {
150+ switch ( card ) {
151+ case 'total-impact' :
152+ return {
153+ totalPosts : logData . totalPosts ,
154+ totalReadingTime : logData . totalReadingTime ,
155+ daysActive : logData . daysActive ,
156+ totalImpactPercentile : logData . totalImpactPercentile ,
157+ } ;
158+ case 'when-you-read' :
159+ return {
160+ peakDay : logData . peakDay ,
161+ readingPattern : logData . readingPattern ,
162+ patternPercentile : logData . patternPercentile ,
163+ activityHeatmap : logData . activityHeatmap ,
164+ } ;
165+ case 'topic-evolution' :
166+ return {
167+ topicJourney : logData . topicJourney ,
168+ uniqueTopics : logData . uniqueTopics ,
169+ evolutionPercentile : logData . evolutionPercentile ,
170+ } ;
171+ case 'favorite-sources' :
172+ return {
173+ topSources : logData . topSources ,
174+ uniqueSources : logData . uniqueSources ,
175+ sourcePercentile : logData . sourcePercentile ,
176+ sourceLoyaltyName : logData . sourceLoyaltyName ,
177+ } ;
178+ case 'community' :
179+ return {
180+ upvotesGiven : logData . upvotesGiven ,
181+ commentsWritten : logData . commentsWritten ,
182+ postsBookmarked : logData . postsBookmarked ,
183+ upvotePercentile : logData . upvotePercentile ,
184+ commentPercentile : logData . commentPercentile ,
185+ bookmarkPercentile : logData . bookmarkPercentile ,
186+ } ;
187+ case 'contributions' :
188+ return {
189+ postsCreated : logData . postsCreated ,
190+ totalViews : logData . totalViews ,
191+ commentsReceived : logData . commentsReceived ,
192+ upvotesReceived : logData . upvotesReceived ,
193+ reputationEarned : logData . reputationEarned ,
194+ creatorPercentile : logData . creatorPercentile ,
195+ } ;
196+ case 'records' :
197+ return {
198+ records : logData . records ,
199+ } ;
200+ case 'archetype' :
201+ return {
202+ archetype : logData . archetype ,
203+ archetypeStat : logData . archetypeStat ,
204+ archetypePercentile : logData . archetypePercentile ,
205+ } ;
206+ case 'share' :
207+ return {
208+ archetype : logData . archetype ,
209+ archetypeStat : logData . archetypeStat ,
210+ totalPosts : logData . totalPosts ,
211+ daysActive : logData . daysActive ,
212+ records : logData . records ,
213+ } ;
214+ default :
215+ return logData ;
216+ }
217+ }
218+
126219export default async function ( fastify : FastifyInstance ) : Promise < void > {
220+ /**
221+ * GET /log
222+ * Returns the user's log data for the year
223+ */
127224 fastify . get ( '/' , async ( req , res ) => {
128225 if ( ! req . userId ) {
129226 return res . status ( 401 ) . send ( { error : 'Unauthorized' } ) ;
@@ -132,4 +229,112 @@ export default async function (fastify: FastifyInstance): Promise<void> {
132229 // TODO: Replace mock data with actual user data based on req.userId
133230 return res . send ( MOCK_LOG_DATA ) ;
134231 } ) ;
232+
233+ /**
234+ * GET /log/images?card=xxx&userId=xxx
235+ *
236+ * Generates a share image for a specific Log card.
237+ * Requires authentication. The userId query param must match the authenticated user
238+ * for cache key uniqueness.
239+ */
240+ fastify . get < {
241+ Querystring : { card ?: string ; userId ?: string } ;
242+ } > ( '/images' , async ( req , res ) : Promise < FastifyReply > => {
243+ // Require authentication
244+ if ( ! req . userId ) {
245+ return res . status ( 401 ) . send ( { error : 'Unauthorized' } ) ;
246+ }
247+
248+ const { card, userId } = req . query ;
249+
250+ // Validate card type
251+ if ( ! card || ! VALID_CARD_TYPES . includes ( card as CardType ) ) {
252+ return res . status ( 400 ) . send ( {
253+ error : 'Invalid card type' ,
254+ validTypes : VALID_CARD_TYPES ,
255+ } ) ;
256+ }
257+
258+ // Validate userId matches authenticated user (for cache key integrity)
259+ if ( userId && userId !== req . userId ) {
260+ return res . status ( 403 ) . send ( { error : 'User ID mismatch' } ) ;
261+ }
262+
263+ try {
264+ // Fetch user profile for personalization
265+ const con = await createOrGetConnection ( ) ;
266+ const user = await con
267+ . getRepository ( User )
268+ . findOne ( { where : { id : req . userId } , select : [ 'image' , 'username' ] } ) ;
269+
270+ if ( ! user ) {
271+ return res . status ( 404 ) . send ( { error : 'User not found' } ) ;
272+ }
273+
274+ // Fetch user's log data
275+ // TODO: Replace with actual data fetching based on req.userId
276+ const logData = MOCK_LOG_DATA ;
277+
278+ // Extract only the data needed for this card type
279+ const cardData = extractCardData ( card as CardType , logData ) ;
280+
281+ // Combine card data with user profile for personalization
282+ const payloadData = {
283+ ...cardData ,
284+ userImage : user . image ,
285+ username : user . username ,
286+ } ;
287+
288+ // Encode data as base64url for URL-safe transmission
289+ const encoded = Buffer . from ( JSON . stringify ( payloadData ) ) . toString (
290+ 'base64url' ,
291+ ) ;
292+
293+ // Build image-generator URL
294+ const imageUrl = new URL (
295+ `${ WEBAPP_MAGIC_IMAGE_PREFIX } /log` ,
296+ process . env . COMMENTS_PREFIX ,
297+ ) ;
298+ imageUrl . searchParams . set ( 'card' , card ) ;
299+ imageUrl . searchParams . set ( 'data' , encoded ) ;
300+
301+ req . log . info (
302+ { url : imageUrl . toString ( ) , card } ,
303+ 'Generating log share image' ,
304+ ) ;
305+
306+ // Call scraper service to screenshot the page
307+ const response = await retryFetch (
308+ `${ process . env . SCRAPER_URL } /screenshot` ,
309+ {
310+ method : 'POST' ,
311+ body : JSON . stringify ( {
312+ url : imageUrl . toString ( ) ,
313+ selector : '#screenshot_wrapper' ,
314+ } ) ,
315+ headers : { 'content-type' : 'application/json' } ,
316+ } ,
317+ ) ;
318+
319+ if ( ! response . ok ) {
320+ req . log . error (
321+ { status : response . status , card } ,
322+ 'Scraper failed to generate image' ,
323+ ) ;
324+ return res . status ( 500 ) . send ( { error : 'Failed to generate image' } ) ;
325+ }
326+
327+ // Return the image with cache headers
328+ // Cache key includes userId in the URL for per-user uniqueness
329+ return res
330+ . type ( 'image/png' )
331+ . header ( 'cross-origin-opener-policy' , 'cross-origin' )
332+ . header ( 'cross-origin-resource-policy' , 'cross-origin' )
333+ . header ( 'cache-control' , 'public, max-age=3600, s-maxage=3600' )
334+ . send ( await response . buffer ( ) ) ;
335+ } catch ( err ) {
336+ req . log . error ( { err, card } , 'Error generating log share image' ) ;
337+ return res . status ( 500 ) . send ( { error : 'Internal server error' } ) ;
338+ }
339+ } ) ;
135340}
0 commit comments