From f9e94708bae30b027216dd4257953ee3bea9aab2 Mon Sep 17 00:00:00 2001 From: dinesh Date: Tue, 19 May 2026 11:27:35 +0530 Subject: [PATCH 1/7] feat: add context-card diffing utility and validation layer --- packages/shared/src/__tests__/cards.test.ts | 72 +++++++++++++++++++++ packages/shared/src/cards.ts | 50 ++++++++++++++ packages/shared/src/index.ts | 1 + 3 files changed, 123 insertions(+) create mode 100644 packages/shared/src/__tests__/cards.test.ts create mode 100644 packages/shared/src/cards.ts diff --git a/packages/shared/src/__tests__/cards.test.ts b/packages/shared/src/__tests__/cards.test.ts new file mode 100644 index 0000000..0c1a6d1 --- /dev/null +++ b/packages/shared/src/__tests__/cards.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { validateCardPlatforms, diffCardPlatforms } from '../cards'; + +describe('validateCardPlatforms', () => { + it('passes with valid platforms', () => { + const result = validateCardPlatforms(['github', 'linkedin']); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('fails with empty array', () => { + const result = validateCardPlatforms([]); + expect(result.valid).toBe(false); + expect(result.errors).toContain('At least one platform is required.'); + }); + + it('fails with unknown platform', () => { + const result = validateCardPlatforms(['github', 'myspace']); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('myspace'))).toBe(true); + }); + + it('fails with duplicate platforms', () => { + const result = validateCardPlatforms(['github', 'github']); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('Duplicate'))).toBe(true); + }); + + it('passes with exactly 10 platforms', () => { + const platforms = ['github','linkedin','twitter','youtube','twitch', + 'discord','devto','medium','dribbble','leetcode']; + const result = validateCardPlatforms(platforms); + expect(result.valid).toBe(true); + }); + + it('fails with more than 10 platforms', () => { + const platforms = ['github','linkedin','twitter','youtube','twitch', + 'discord','devto','medium','dribbble','leetcode','npm']; + const result = validateCardPlatforms(platforms); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('Maximum 10'))).toBe(true); + }); + + it('fails with all invalid platforms', () => { + const result = validateCardPlatforms(['myspace', 'bebo']); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThanOrEqual(2); + }); +}); + +describe('diffCardPlatforms', () => { + it('correctly identifies added, removed, unchanged', () => { + const diff = diffCardPlatforms(['github', 'linkedin'], ['github', 'twitter']); + expect(diff.added).toEqual(['twitter']); + expect(diff.removed).toEqual(['linkedin']); + expect(diff.unchanged).toEqual(['github']); + }); + + it('handles empty old card', () => { + const diff = diffCardPlatforms([], ['github']); + expect(diff.added).toEqual(['github']); + expect(diff.removed).toEqual([]); + expect(diff.unchanged).toEqual([]); + }); + + it('handles identical cards', () => { + const diff = diffCardPlatforms(['github'], ['github']); + expect(diff.added).toEqual([]); + expect(diff.removed).toEqual([]); + expect(diff.unchanged).toEqual(['github']); + }); +}); \ No newline at end of file diff --git a/packages/shared/src/cards.ts b/packages/shared/src/cards.ts new file mode 100644 index 0000000..d9fa513 --- /dev/null +++ b/packages/shared/src/cards.ts @@ -0,0 +1,50 @@ +export type CardValidationResult = { + valid: boolean; + errors: string[]; +}; + +const PLATFORMS = new Set([ + 'github', 'linkedin', 'twitter', 'instagram', 'youtube', + 'twitch', 'discord', 'devto', 'hashnode', 'medium', + 'dribbble', 'behance', 'figma', 'stackoverflow', 'leetcode', + 'codepen', 'replit', 'npm', 'producthunt', 'website', +]); + +export function validateCardPlatforms(platforms: string[]): CardValidationResult { + const errors: string[] = []; + + if (platforms.length === 0) { + errors.push('At least one platform is required.'); + } + + if (platforms.length > 10) { + errors.push(`Maximum 10 platforms allowed, got ${platforms.length}.`); + } + + const seen = new Set(); + for (const p of platforms) { + if (!PLATFORMS.has(p)) { + errors.push(`Unknown platform: "${p}".`); + } + if (seen.has(p)) { + errors.push(`Duplicate platform: "${p}".`); + } + seen.add(p); + } + + return { valid: errors.length === 0, errors }; +} + +export function diffCardPlatforms( + oldCard: string[], + newCard: string[] +): { added: string[]; removed: string[]; unchanged: string[] } { + const oldSet = new Set(oldCard); + const newSet = new Set(newCard); + + return { + added: newCard.filter(p => !oldSet.has(p)), + removed: oldCard.filter(p => !newSet.has(p)), + unchanged: oldCard.filter(p => newSet.has(p)), + }; +} \ No newline at end of file diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a57e7e7..409d3e7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,2 +1,3 @@ export * from './platforms'; export * from './types'; +export * from './cards'; \ No newline at end of file From b10c39fe593f3b4399e65ae2f1695145f7dc1231 Mon Sep 17 00:00:00 2001 From: dinesh Date: Wed, 20 May 2026 00:00:19 +0530 Subject: [PATCH 2/7] feat: add NFC tag payload generation endpoint with card ownership validation --- apps/backend/src/app.ts | 2 ++ apps/backend/src/routes/nfc.ts | 52 ++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 apps/backend/src/routes/nfc.ts diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 8e8cf38..eb87301 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -17,6 +17,7 @@ import { publicRoutes } from './routes/public.js'; import { followRoutes } from './routes/follow.js'; import { connectRoutes } from './routes/connect.js'; import { analyticsRoutes } from './routes/analytics.js'; +import { nfcRoutes } from './routes/nfc.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -89,6 +90,7 @@ export async function buildApp() { await app.register(followRoutes, { prefix: '/api/follow' }); await app.register(connectRoutes, { prefix: '/api/connect' }); await app.register(analyticsRoutes, { prefix: '/api/analytics' }); + await app.register(nfcRoutes, { prefix: '/api/nfc' }); // ─── Health Check ─── app.get('/health', async () => ({ diff --git a/apps/backend/src/routes/nfc.ts b/apps/backend/src/routes/nfc.ts new file mode 100644 index 0000000..8610664 --- /dev/null +++ b/apps/backend/src/routes/nfc.ts @@ -0,0 +1,52 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +export async function nfcRoutes(app: FastifyInstance) { + app.addHook('preHandler', app.authenticate); + + // GET /api/nfc/payload — returns NDEF URI payload for user's default DevCard URL + // GET /api/nfc/payload?card= — returns payload for a specific card + app.get('/payload', async (request: FastifyRequest<{ Querystring: { card?: string } }>, reply: FastifyReply) => { + const userId = (request.user as any).id; + const { card: cardId } = request.query; + + let username: string; + + // Get username from user profile + const user = await app.prisma.user.findUnique({ + where: { id: userId }, + select: { username: true }, + }); + + if (!user) { + return reply.status(404).send({ error: 'User not found' }); + } + + username = user.username; + + if (cardId) { + // Validate card belongs to authenticated user + const card = await app.prisma.card.findUnique({ + where: { id: cardId }, + select: { userId: true }, + }); + + if (!card) { + return reply.status(404).send({ error: 'Card not found' }); + } + + if (card.userId !== userId) { + return reply.status(403).send({ error: 'This card does not belong to you' }); + } + + return reply.send({ + type: 'URI', + payload: `https://devcard.dev/${username}?card=${cardId}`, + }); + } + + return reply.send({ + type: 'URI', + payload: `https://devcard.dev/${username}`, + }); + }); +} \ No newline at end of file From c416a3aa4524bd323fc41924689cd7ffd7580f02 Mon Sep 17 00:00:00 2001 From: dinesh Date: Wed, 20 May 2026 15:43:09 +0530 Subject: [PATCH 3/7] fix: add Zod query validation and improve error handling in NFC route --- apps/backend/src/routes/nfc.ts | 62 +++++++++++++++++++++++----------- 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/apps/backend/src/routes/nfc.ts b/apps/backend/src/routes/nfc.ts index 8610664..f0bf556 100644 --- a/apps/backend/src/routes/nfc.ts +++ b/apps/backend/src/routes/nfc.ts @@ -1,4 +1,9 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { z } from 'zod'; + +const nfcQuerySchema = z.object({ + card: z.string().uuid('Invalid card ID format').optional(), +}); export async function nfcRoutes(app: FastifyInstance) { app.addHook('preHandler', app.authenticate); @@ -7,35 +12,52 @@ export async function nfcRoutes(app: FastifyInstance) { // GET /api/nfc/payload?card= — returns payload for a specific card app.get('/payload', async (request: FastifyRequest<{ Querystring: { card?: string } }>, reply: FastifyReply) => { const userId = (request.user as any).id; - const { card: cardId } = request.query; + + // Validate query params with Zod + const parseResult = nfcQuerySchema.safeParse(request.query); + if (!parseResult.success) { + return reply.status(400).send({ error: 'Invalid query parameters', details: parseResult.error.flatten() }); + } + + const { card: cardId } = parseResult.data; let username: string; - // Get username from user profile - const user = await app.prisma.user.findUnique({ - where: { id: userId }, - select: { username: true }, - }); + try { + // Get username from user profile + const user = await app.prisma.user.findUnique({ + where: { id: userId }, + select: { username: true }, + }); - if (!user) { - return reply.status(404).send({ error: 'User not found' }); - } + if (!user) { + return reply.status(404).send({ error: 'User not found' }); + } - username = user.username; + username = user.username; + } catch (err) { + request.log.error({ err }, 'Failed to fetch user for NFC payload'); + return reply.status(500).send({ error: 'Failed to fetch user profile' }); + } if (cardId) { - // Validate card belongs to authenticated user - const card = await app.prisma.card.findUnique({ - where: { id: cardId }, - select: { userId: true }, - }); + try { + // Validate card belongs to authenticated user + const card = await app.prisma.card.findUnique({ + where: { id: cardId }, + select: { userId: true }, + }); - if (!card) { - return reply.status(404).send({ error: 'Card not found' }); - } + if (!card) { + return reply.status(404).send({ error: 'Card not found' }); + } - if (card.userId !== userId) { - return reply.status(403).send({ error: 'This card does not belong to you' }); + if (card.userId !== userId) { + return reply.status(403).send({ error: 'This card does not belong to you' }); + } + } catch (err) { + request.log.error({ err }, 'Failed to fetch card for NFC payload'); + return reply.status(500).send({ error: 'Failed to fetch card details' }); } return reply.send({ From f24bd452474a5379f0648fe33c08bb46cc966f9d Mon Sep 17 00:00:00 2001 From: dinesh Date: Wed, 20 May 2026 16:11:33 +0530 Subject: [PATCH 4/7] fix: resolve merge conflicts in app.ts --- apps/backend/src/app.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index b3d4ceb..6060410 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -17,13 +17,9 @@ import { publicRoutes } from './routes/public.js'; import { followRoutes } from './routes/follow.js'; import { connectRoutes } from './routes/connect.js'; import { analyticsRoutes } from './routes/analytics.js'; -<<<<<<< HEAD import { nfcRoutes } from './routes/nfc.js'; -======= import { eventRoutes } from './routes/event.js'; ->>>>>>> upstream/main - const __dirname = path.dirname(fileURLToPath(import.meta.url)); export async function buildApp() { @@ -95,12 +91,8 @@ export async function buildApp() { await app.register(followRoutes, { prefix: '/api/follow' }); await app.register(connectRoutes, { prefix: '/api/connect' }); await app.register(analyticsRoutes, { prefix: '/api/analytics' }); -<<<<<<< HEAD - await app.register(nfcRoutes, { prefix: '/api/nfc' }); -======= - await app.register(eventRoutes, {prefix: '/api/events'}) ->>>>>>> upstream/main - +await app.register(nfcRoutes, { prefix: '/api/nfc' }); + await app.register(eventRoutes, { prefix: '/api/events' }); // ─── Health Check ─── app.get('/health', async () => ({ status: 'ok', From 5c84994d1ec22be28b91d561b9ca849898c6d2cc Mon Sep 17 00:00:00 2001 From: dinesh Date: Wed, 20 May 2026 16:17:01 +0530 Subject: [PATCH 5/7] fix: add typed response schema NfcPayloadResponse --- apps/backend/src/routes/nfc.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/routes/nfc.ts b/apps/backend/src/routes/nfc.ts index f0bf556..e17e8f9 100644 --- a/apps/backend/src/routes/nfc.ts +++ b/apps/backend/src/routes/nfc.ts @@ -1,5 +1,9 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { z } from 'zod'; +type NfcPayloadResponse = { + type: 'URI'; + payload: string; +}; const nfcQuerySchema = z.object({ card: z.string().uuid('Invalid card ID format').optional(), @@ -60,13 +64,13 @@ export async function nfcRoutes(app: FastifyInstance) { return reply.status(500).send({ error: 'Failed to fetch card details' }); } - return reply.send({ + return reply.send({ type: 'URI', payload: `https://devcard.dev/${username}?card=${cardId}`, }); } - return reply.send({ + return reply.send({ type: 'URI', payload: `https://devcard.dev/${username}`, }); From 1fc961c99eed645fd76d443f59f8dc9be2cb61cb Mon Sep 17 00:00:00 2001 From: dinesh Date: Wed, 20 May 2026 17:24:10 +0530 Subject: [PATCH 6/7] fix: remove typo in import statement in cards.ts --- apps/backend/src/routes/cards.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/routes/cards.ts b/apps/backend/src/routes/cards.ts index efe0074..7da682b 100644 --- a/apps/backend/src/routes/cards.ts +++ b/apps/backend/src/routes/cards.ts @@ -1,4 +1,4 @@ -Dimport type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { createCardSchema, updateCardSchema } from '../utils/validators.js'; export async function cardRoutes(app: FastifyInstance) { From 41140d6ed5a0d7ad5269b002da5c227fac08b26c Mon Sep 17 00:00:00 2001 From: dinesh Date: Wed, 20 May 2026 21:12:09 +0530 Subject: [PATCH 7/7] refactor: narrow try catch scope in NFC payload route --- apps/backend/src/routes/nfc.ts | 115 +++++++++++++++++++-------------- 1 file changed, 68 insertions(+), 47 deletions(-) diff --git a/apps/backend/src/routes/nfc.ts b/apps/backend/src/routes/nfc.ts index e17e8f9..03393ba 100644 --- a/apps/backend/src/routes/nfc.ts +++ b/apps/backend/src/routes/nfc.ts @@ -1,5 +1,6 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { z } from 'zod'; + type NfcPayloadResponse = { type: 'URI'; payload: string; @@ -14,65 +15,85 @@ export async function nfcRoutes(app: FastifyInstance) { // GET /api/nfc/payload — returns NDEF URI payload for user's default DevCard URL // GET /api/nfc/payload?card= — returns payload for a specific card - app.get('/payload', async (request: FastifyRequest<{ Querystring: { card?: string } }>, reply: FastifyReply) => { - const userId = (request.user as any).id; - - // Validate query params with Zod - const parseResult = nfcQuerySchema.safeParse(request.query); - if (!parseResult.success) { - return reply.status(400).send({ error: 'Invalid query parameters', details: parseResult.error.flatten() }); - } - - const { card: cardId } = parseResult.data; - - let username: string; + app.get( + '/payload', + async ( + request: FastifyRequest<{ Querystring: { card?: string } }>, + reply: FastifyReply + ) => { + const userId = (request.user as any).id; - try { - // Get username from user profile - const user = await app.prisma.user.findUnique({ - where: { id: userId }, - select: { username: true }, - }); - - if (!user) { - return reply.status(404).send({ error: 'User not found' }); + // Validate query params with Zod + const parseResult = nfcQuerySchema.safeParse(request.query); + if (!parseResult.success) { + return reply.status(400).send({ + error: 'Invalid query parameters', + details: parseResult.error.flatten(), + }); } - username = user.username; - } catch (err) { - request.log.error({ err }, 'Failed to fetch user for NFC payload'); - return reply.status(500).send({ error: 'Failed to fetch user profile' }); - } + const { card: cardId } = parseResult.data; + + let username: string; - if (cardId) { + // Fetch username try { - // Validate card belongs to authenticated user - const card = await app.prisma.card.findUnique({ - where: { id: cardId }, - select: { userId: true }, + const user = await app.prisma.user.findUnique({ + where: { id: userId }, + select: { username: true }, }); - if (!card) { - return reply.status(404).send({ error: 'Card not found' }); + if (!user) { + return reply.status(404).send({ + error: 'User not found', + }); } - if (card.userId !== userId) { - return reply.status(403).send({ error: 'This card does not belong to you' }); - } + username = user.username; } catch (err) { - request.log.error({ err }, 'Failed to fetch card for NFC payload'); - return reply.status(500).send({ error: 'Failed to fetch card details' }); + request.log.error( + { err }, + 'Failed to fetch user for NFC payload' + ); + return reply.status(500).send({ + error: 'Failed to fetch user profile', + }); } - return reply.send({ + // If a specific card is requested, verify ownership + if (cardId) { + try { + const card = await app.prisma.card.findUnique({ + where: { id: cardId }, + select: { userId: true }, + }); + + if (!card || card.userId !== userId) { + return reply.status(404).send({ + error: 'Card not found', + }); + } + } catch (err) { + request.log.error( + { err }, + 'Failed to fetch card for NFC payload' + ); + return reply.status(500).send({ + error: 'Failed to fetch card', + }); + } + } + + const payloadUrl = `https://dev-card.vercel.app/${username}${ + cardId ? `?card=${cardId}` : '' + }`; + + const response: NfcPayloadResponse = { type: 'URI', - payload: `https://devcard.dev/${username}?card=${cardId}`, - }); - } + payload: payloadUrl, + }; - return reply.send({ - type: 'URI', - payload: `https://devcard.dev/${username}`, - }); - }); + return reply.send(response); + } + ); } \ No newline at end of file