diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 5f45001..6060410 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -17,9 +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'; +import { nfcRoutes } from './routes/nfc.js'; import { eventRoutes } from './routes/event.js'; - const __dirname = path.dirname(fileURLToPath(import.meta.url)); export async function buildApp() { @@ -91,8 +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' }); - await app.register(eventRoutes, {prefix: '/api/events'}) - +await app.register(nfcRoutes, { prefix: '/api/nfc' }); + await app.register(eventRoutes, { prefix: '/api/events' }); // ─── Health Check ─── app.get('/health', async () => ({ status: 'ok', diff --git a/apps/backend/src/routes/cards.ts b/apps/backend/src/routes/cards.ts index f1af7b0..7da682b 100644 --- a/apps/backend/src/routes/cards.ts +++ b/apps/backend/src/routes/cards.ts @@ -100,6 +100,8 @@ export async function cardRoutes(app: FastifyInstance) { if (parsed.data.linkIds) { // Remove existing links await app.prisma.cardLink.deleteMany({ where: { cardId: id } }); + + // Add new links await app.prisma.cardLink.createMany({ data: parsed.data.linkIds.map((linkId, index) => ({ diff --git a/apps/backend/src/routes/nfc.ts b/apps/backend/src/routes/nfc.ts new file mode 100644 index 0000000..03393ba --- /dev/null +++ b/apps/backend/src/routes/nfc.ts @@ -0,0 +1,99 @@ +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(), +}); + +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; + + // 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; + + // Fetch username + try { + 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; + } 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 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: payloadUrl, + }; + + return reply.send(response); + } + ); +} \ No newline at end of file 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