Skip to content

Commit ff326c8

Browse files
committed
feat: add route for log image generation
1 parent acd44eb commit ff326c8

1 file changed

Lines changed: 208 additions & 3 deletions

File tree

src/routes/log.ts

Lines changed: 208 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
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
48
const 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+
126219
export 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

Comments
 (0)