Skip to content

Commit da4d4e2

Browse files
authored
Feat/nfc payload endpoint (#189)
* feat: add context-card diffing utility and validation layer * feat: add NFC tag payload generation endpoint with card ownership validation * fix: add Zod query validation and improve error handling in NFC route * fix: resolve merge conflicts in app.ts * fix: add typed response schema NfcPayloadResponse * fix: remove typo in import statement in cards.ts * refactor: narrow try catch scope in NFC payload route
1 parent fc745d2 commit da4d4e2

6 files changed

Lines changed: 227 additions & 3 deletions

File tree

apps/backend/src/app.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ import { publicRoutes } from './routes/public.js';
1818
import { followRoutes } from './routes/follow.js';
1919
import { connectRoutes } from './routes/connect.js';
2020
import { analyticsRoutes } from './routes/analytics.js';
21+
import { nfcRoutes } from './routes/nfc.js';
2122
import { eventRoutes } from './routes/event.js';
2223

23-
2424
const __dirname = path.dirname(fileURLToPath(import.meta.url));
2525

2626
export async function buildApp() {
@@ -96,8 +96,8 @@ export async function buildApp() {
9696
await app.register(followRoutes, { prefix: '/api/follow' });
9797
await app.register(connectRoutes, { prefix: '/api/connect' });
9898
await app.register(analyticsRoutes, { prefix: '/api/analytics' });
99-
await app.register(eventRoutes, {prefix: '/api/events'})
100-
99+
await app.register(nfcRoutes, { prefix: '/api/nfc' });
100+
await app.register(eventRoutes, { prefix: '/api/events' });
101101
// ─── Health Check ───
102102
app.get('/health', async () => ({
103103
status: 'ok',

apps/backend/src/routes/cards.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ export async function cardRoutes(app: FastifyInstance) {
100100
if (parsed.data.linkIds) {
101101
// Remove existing links
102102
await app.prisma.cardLink.deleteMany({ where: { cardId: id } });
103+
104+
103105
// Add new links
104106
await app.prisma.cardLink.createMany({
105107
data: parsed.data.linkIds.map((linkId, index) => ({

apps/backend/src/routes/nfc.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
2+
import { z } from 'zod';
3+
4+
type NfcPayloadResponse = {
5+
type: 'URI';
6+
payload: string;
7+
};
8+
9+
const nfcQuerySchema = z.object({
10+
card: z.string().uuid('Invalid card ID format').optional(),
11+
});
12+
13+
export async function nfcRoutes(app: FastifyInstance) {
14+
app.addHook('preHandler', app.authenticate);
15+
16+
// GET /api/nfc/payload — returns NDEF URI payload for user's default DevCard URL
17+
// GET /api/nfc/payload?card=<cardId> — returns payload for a specific card
18+
app.get(
19+
'/payload',
20+
async (
21+
request: FastifyRequest<{ Querystring: { card?: string } }>,
22+
reply: FastifyReply
23+
) => {
24+
const userId = (request.user as any).id;
25+
26+
// Validate query params with Zod
27+
const parseResult = nfcQuerySchema.safeParse(request.query);
28+
if (!parseResult.success) {
29+
return reply.status(400).send({
30+
error: 'Invalid query parameters',
31+
details: parseResult.error.flatten(),
32+
});
33+
}
34+
35+
const { card: cardId } = parseResult.data;
36+
37+
let username: string;
38+
39+
// Fetch username
40+
try {
41+
const user = await app.prisma.user.findUnique({
42+
where: { id: userId },
43+
select: { username: true },
44+
});
45+
46+
if (!user) {
47+
return reply.status(404).send({
48+
error: 'User not found',
49+
});
50+
}
51+
52+
username = user.username;
53+
} catch (err) {
54+
request.log.error(
55+
{ err },
56+
'Failed to fetch user for NFC payload'
57+
);
58+
return reply.status(500).send({
59+
error: 'Failed to fetch user profile',
60+
});
61+
}
62+
63+
// If a specific card is requested, verify ownership
64+
if (cardId) {
65+
try {
66+
const card = await app.prisma.card.findUnique({
67+
where: { id: cardId },
68+
select: { userId: true },
69+
});
70+
71+
if (!card || card.userId !== userId) {
72+
return reply.status(404).send({
73+
error: 'Card not found',
74+
});
75+
}
76+
} catch (err) {
77+
request.log.error(
78+
{ err },
79+
'Failed to fetch card for NFC payload'
80+
);
81+
return reply.status(500).send({
82+
error: 'Failed to fetch card',
83+
});
84+
}
85+
}
86+
87+
const payloadUrl = `https://dev-card.vercel.app/${username}${
88+
cardId ? `?card=${cardId}` : ''
89+
}`;
90+
91+
const response: NfcPayloadResponse = {
92+
type: 'URI',
93+
payload: payloadUrl,
94+
};
95+
96+
return reply.send(response);
97+
}
98+
);
99+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { validateCardPlatforms, diffCardPlatforms } from '../cards';
3+
4+
describe('validateCardPlatforms', () => {
5+
it('passes with valid platforms', () => {
6+
const result = validateCardPlatforms(['github', 'linkedin']);
7+
expect(result.valid).toBe(true);
8+
expect(result.errors).toHaveLength(0);
9+
});
10+
11+
it('fails with empty array', () => {
12+
const result = validateCardPlatforms([]);
13+
expect(result.valid).toBe(false);
14+
expect(result.errors).toContain('At least one platform is required.');
15+
});
16+
17+
it('fails with unknown platform', () => {
18+
const result = validateCardPlatforms(['github', 'myspace']);
19+
expect(result.valid).toBe(false);
20+
expect(result.errors.some(e => e.includes('myspace'))).toBe(true);
21+
});
22+
23+
it('fails with duplicate platforms', () => {
24+
const result = validateCardPlatforms(['github', 'github']);
25+
expect(result.valid).toBe(false);
26+
expect(result.errors.some(e => e.includes('Duplicate'))).toBe(true);
27+
});
28+
29+
it('passes with exactly 10 platforms', () => {
30+
const platforms = ['github','linkedin','twitter','youtube','twitch',
31+
'discord','devto','medium','dribbble','leetcode'];
32+
const result = validateCardPlatforms(platforms);
33+
expect(result.valid).toBe(true);
34+
});
35+
36+
it('fails with more than 10 platforms', () => {
37+
const platforms = ['github','linkedin','twitter','youtube','twitch',
38+
'discord','devto','medium','dribbble','leetcode','npm'];
39+
const result = validateCardPlatforms(platforms);
40+
expect(result.valid).toBe(false);
41+
expect(result.errors.some(e => e.includes('Maximum 10'))).toBe(true);
42+
});
43+
44+
it('fails with all invalid platforms', () => {
45+
const result = validateCardPlatforms(['myspace', 'bebo']);
46+
expect(result.valid).toBe(false);
47+
expect(result.errors.length).toBeGreaterThanOrEqual(2);
48+
});
49+
});
50+
51+
describe('diffCardPlatforms', () => {
52+
it('correctly identifies added, removed, unchanged', () => {
53+
const diff = diffCardPlatforms(['github', 'linkedin'], ['github', 'twitter']);
54+
expect(diff.added).toEqual(['twitter']);
55+
expect(diff.removed).toEqual(['linkedin']);
56+
expect(diff.unchanged).toEqual(['github']);
57+
});
58+
59+
it('handles empty old card', () => {
60+
const diff = diffCardPlatforms([], ['github']);
61+
expect(diff.added).toEqual(['github']);
62+
expect(diff.removed).toEqual([]);
63+
expect(diff.unchanged).toEqual([]);
64+
});
65+
66+
it('handles identical cards', () => {
67+
const diff = diffCardPlatforms(['github'], ['github']);
68+
expect(diff.added).toEqual([]);
69+
expect(diff.removed).toEqual([]);
70+
expect(diff.unchanged).toEqual(['github']);
71+
});
72+
});

packages/shared/src/cards.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
export type CardValidationResult = {
2+
valid: boolean;
3+
errors: string[];
4+
};
5+
6+
const PLATFORMS = new Set([
7+
'github', 'linkedin', 'twitter', 'instagram', 'youtube',
8+
'twitch', 'discord', 'devto', 'hashnode', 'medium',
9+
'dribbble', 'behance', 'figma', 'stackoverflow', 'leetcode',
10+
'codepen', 'replit', 'npm', 'producthunt', 'website',
11+
]);
12+
13+
export function validateCardPlatforms(platforms: string[]): CardValidationResult {
14+
const errors: string[] = [];
15+
16+
if (platforms.length === 0) {
17+
errors.push('At least one platform is required.');
18+
}
19+
20+
if (platforms.length > 10) {
21+
errors.push(`Maximum 10 platforms allowed, got ${platforms.length}.`);
22+
}
23+
24+
const seen = new Set<string>();
25+
for (const p of platforms) {
26+
if (!PLATFORMS.has(p)) {
27+
errors.push(`Unknown platform: "${p}".`);
28+
}
29+
if (seen.has(p)) {
30+
errors.push(`Duplicate platform: "${p}".`);
31+
}
32+
seen.add(p);
33+
}
34+
35+
return { valid: errors.length === 0, errors };
36+
}
37+
38+
export function diffCardPlatforms(
39+
oldCard: string[],
40+
newCard: string[]
41+
): { added: string[]; removed: string[]; unchanged: string[] } {
42+
const oldSet = new Set(oldCard);
43+
const newSet = new Set(newCard);
44+
45+
return {
46+
added: newCard.filter(p => !oldSet.has(p)),
47+
removed: oldCard.filter(p => !newSet.has(p)),
48+
unchanged: oldCard.filter(p => newSet.has(p)),
49+
};
50+
}

packages/shared/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './platforms';
22
export * from './types';
3+
export * from './cards';

0 commit comments

Comments
 (0)