Skip to content
Open
6 changes: 3 additions & 3 deletions apps/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/routes/cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down
99 changes: 99 additions & 0 deletions apps/backend/src/routes/nfc.ts
Original file line number Diff line number Diff line change
@@ -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=<cardId> — 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 },
});
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catch position should be here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated this section as well to keep the try/catch scoped only around the database call. Thanks for catching that.


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);
}
);
}
72 changes: 72 additions & 0 deletions packages/shared/src/__tests__/cards.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
50 changes: 50 additions & 0 deletions packages/shared/src/cards.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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)),
};
}
1 change: 1 addition & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './platforms';
export * from './types';
export * from './cards';