From 910e761ca1c6968424f6e8821bb9709b2e1bcd24 Mon Sep 17 00:00:00 2001 From: Sarthak Doshi Date: Sun, 10 May 2026 23:45:02 +0530 Subject: [PATCH 1/2] feat: add shared card validation and diff utilities (issue #31) - Add packages/shared/src/cards.ts with: - validateCardPlatforms(platforms): checks all IDs exist in PLATFORMS, no duplicates, max 10 per card; returns { valid, errors } - diffCardPlatforms(oldCard, newCard): pure diff returning { added, removed, unchanged } - CardValidationResult and CardDiffResult types - Export from packages/shared/src/index.ts - Add comprehensive vitest unit tests in packages/shared/src/__tests__/cards.test.ts covering: empty arrays, unknown platforms, duplicates, max exceeded, boundary (exactly 10), multiple errors, and all diff cases - Add vitest as devDependency and "test" script to shared package.json - Update apps/backend/src/routes/cards.ts to import validateCardPlatforms and call it on both POST / (create) and PUT /:id (update) after resolving PlatformLink records to their platform IDs, returning 400 with structured errors on failure Closes #31 --- apps/backend/src/routes/cards.ts | 27 +++++ packages/shared/package.json | 6 +- packages/shared/src/__tests__/cards.test.ts | 119 ++++++++++++++++++++ packages/shared/src/cards.ts | 68 +++++++++++ packages/shared/src/index.ts | 1 + 5 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 packages/shared/src/__tests__/cards.test.ts create mode 100644 packages/shared/src/cards.ts diff --git a/apps/backend/src/routes/cards.ts b/apps/backend/src/routes/cards.ts index f1af7b0..81742fb 100644 --- a/apps/backend/src/routes/cards.ts +++ b/apps/backend/src/routes/cards.ts @@ -1,5 +1,6 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { createCardSchema, updateCardSchema } from '../utils/validators.js'; +import { validateCardPlatforms } from '@devcard/shared'; export async function cardRoutes(app: FastifyInstance) { app.addHook('preHandler', app.authenticate); @@ -38,6 +39,19 @@ export async function cardRoutes(app: FastifyInstance) { return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() }); } + // Validate platform IDs via shared utility + if (parsed.data.linkIds.length > 0) { + const links = await app.prisma.platformLink.findMany({ + where: { id: { in: parsed.data.linkIds }, userId }, + select: { platform: true }, + }); + const platformIds = links.map((l) => l.platform); + const validation = validateCardPlatforms(platformIds); + if (!validation.valid) { + return reply.status(400).send({ error: 'Invalid card platforms', details: validation.errors }); + } + } + // Check if user's first card → make it default const cardCount = await app.prisma.card.count({ where: { userId } }); @@ -98,6 +112,19 @@ export async function cardRoutes(app: FastifyInstance) { // Update card links if provided if (parsed.data.linkIds) { + // Validate updated platform set via shared utility + if (parsed.data.linkIds.length > 0) { + const links = await app.prisma.platformLink.findMany({ + where: { id: { in: parsed.data.linkIds }, userId }, + select: { platform: true }, + }); + const platformIds = links.map((l) => l.platform); + const validation = validateCardPlatforms(platformIds); + if (!validation.valid) { + return reply.status(400).send({ error: 'Invalid card platforms', details: validation.errors }); + } + } + // Remove existing links await app.prisma.cardLink.deleteMany({ where: { cardId: id } }); // Add new links diff --git a/packages/shared/package.json b/packages/shared/package.json index a15ef6d..b3b3ac7 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -7,9 +7,11 @@ "types": "./src/index.ts", "scripts": { "lint": "eslint src/", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run" }, "devDependencies": { - "typescript": "^5.4.0" + "typescript": "^5.4.0", + "vitest": "^2.0.0" } } diff --git a/packages/shared/src/__tests__/cards.test.ts b/packages/shared/src/__tests__/cards.test.ts new file mode 100644 index 0000000..372f1dd --- /dev/null +++ b/packages/shared/src/__tests__/cards.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest'; +import { validateCardPlatforms, diffCardPlatforms } from '../cards.js'; + +// ─── validateCardPlatforms ─── + +describe('validateCardPlatforms', () => { + it('accepts a valid list of known platforms', () => { + const result = validateCardPlatforms(['github', 'linkedin', 'twitter']); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('accepts an empty array', () => { + const result = validateCardPlatforms([]); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('rejects unknown platform IDs', () => { + const result = validateCardPlatforms(['github', 'myspace', 'xmpp']); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('"myspace"'))).toBe(true); + expect(result.errors.some((e) => e.includes('"xmpp"'))).toBe(true); + }); + + it('rejects duplicate platform IDs', () => { + const result = validateCardPlatforms(['github', 'github', 'linkedin']); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('Duplicate') && e.includes('"github"'))).toBe(true); + }); + + it('rejects lists exceeding 10 platforms', () => { + const platforms = ['github', 'linkedin', 'twitter', 'gitlab', 'devfolio', 'npm', 'devto', 'hashnode', 'medium', 'leetcode', 'hackerrank']; + expect(platforms.length).toBeGreaterThan(10); + const result = validateCardPlatforms(platforms); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('at most 10'))).toBe(true); + }); + + it('accepts exactly 10 platforms', () => { + const platforms = ['github', 'linkedin', 'twitter', 'gitlab', 'devfolio', 'npm', 'devto', 'hashnode', 'medium', 'leetcode']; + expect(platforms.length).toBe(10); + const result = validateCardPlatforms(platforms); + expect(result.valid).toBe(true); + }); + + it('accumulates multiple errors in a single pass', () => { + const result = validateCardPlatforms(['github', 'github', 'unknown1', 'unknown2']); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThanOrEqual(3); // dup + 2 unknown + }); + + it('returns all platform IDs listed in PLATFORMS as valid', () => { + const known = ['github', 'linkedin', 'twitter', 'gitlab', 'devfolio', 'npm']; + const result = validateCardPlatforms(known); + expect(result.valid).toBe(true); + }); +}); + +// ─── diffCardPlatforms ─── + +describe('diffCardPlatforms', () => { + it('detects added platforms', () => { + const result = diffCardPlatforms(['github'], ['github', 'linkedin']); + expect(result.added).toEqual(['linkedin']); + expect(result.removed).toEqual([]); + expect(result.unchanged).toEqual(['github']); + }); + + it('detects removed platforms', () => { + const result = diffCardPlatforms(['github', 'linkedin'], ['github']); + expect(result.added).toEqual([]); + expect(result.removed).toEqual(['linkedin']); + expect(result.unchanged).toEqual(['github']); + }); + + it('detects unchanged platforms', () => { + const result = diffCardPlatforms(['github', 'twitter'], ['github', 'twitter']); + expect(result.added).toEqual([]); + expect(result.removed).toEqual([]); + expect(result.unchanged).toContain('github'); + expect(result.unchanged).toContain('twitter'); + }); + + it('handles both added and removed in the same diff', () => { + const result = diffCardPlatforms(['github', 'twitter'], ['github', 'linkedin']); + expect(result.added).toEqual(['linkedin']); + expect(result.removed).toEqual(['twitter']); + expect(result.unchanged).toEqual(['github']); + }); + + it('handles empty old card', () => { + const result = diffCardPlatforms([], ['github', 'linkedin']); + expect(result.added).toEqual(['github', 'linkedin']); + expect(result.removed).toEqual([]); + expect(result.unchanged).toEqual([]); + }); + + it('handles empty new card', () => { + const result = diffCardPlatforms(['github', 'linkedin'], []); + expect(result.added).toEqual([]); + expect(result.removed).toContain('github'); + expect(result.removed).toContain('linkedin'); + expect(result.unchanged).toEqual([]); + }); + + it('handles both cards empty', () => { + const result = diffCardPlatforms([], []); + expect(result.added).toEqual([]); + expect(result.removed).toEqual([]); + expect(result.unchanged).toEqual([]); + }); + + it('is order-insensitive for membership checks', () => { + const result = diffCardPlatforms(['linkedin', 'github'], ['github', 'linkedin']); + expect(result.added).toEqual([]); + expect(result.removed).toEqual([]); + }); +}); diff --git a/packages/shared/src/cards.ts b/packages/shared/src/cards.ts new file mode 100644 index 0000000..e0fa690 --- /dev/null +++ b/packages/shared/src/cards.ts @@ -0,0 +1,68 @@ +import { PLATFORMS } from './platforms.js'; + +// ─── Types ─── + +export type CardValidationResult = { + valid: boolean; + errors: string[]; +}; + +export type CardDiffResult = { + added: string[]; + removed: string[]; + unchanged: string[]; +}; + +// ─── Constants ─── + +const MAX_PLATFORMS_PER_CARD = 10; + +// ─── Validation ─── + +/** + * Validate the list of platform IDs on a card. + * + * Rules: + * - All IDs must exist in PLATFORMS + * - No duplicates + * - At most MAX_PLATFORMS_PER_CARD entries + */ +export function validateCardPlatforms(platforms: string[]): CardValidationResult { + const errors: string[] = []; + + if (platforms.length > MAX_PLATFORMS_PER_CARD) { + errors.push(`A card can have at most ${MAX_PLATFORMS_PER_CARD} platforms (got ${platforms.length}).`); + } + + const seen = new Set(); + for (const id of platforms) { + if (!PLATFORMS[id]) { + errors.push(`Unknown platform: "${id}".`); + } + if (seen.has(id)) { + errors.push(`Duplicate platform: "${id}".`); + } + seen.add(id); + } + + return { valid: errors.length === 0, errors }; +} + +// ─── Diffing ─── + +/** + * Compute the diff between two sets of platform IDs. + * + * Returns which IDs were added, removed, or unchanged going from + * oldCard → newCard. Order is not considered — only membership. + */ +export function diffCardPlatforms(oldCard: string[], newCard: string[]): CardDiffResult { + const oldSet = new Set(oldCard); + const newSet = new Set(newCard); + + const added = newCard.filter((id) => !oldSet.has(id)); + const removed = oldCard.filter((id) => !newSet.has(id)); + const unchanged = oldCard.filter((id) => newSet.has(id)); + + return { added, removed, unchanged }; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a57e7e7..074e7dc 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'; From 7c3f19d9e3b822f2dc7cda4c4c812c670da0e2e4 Mon Sep 17 00:00:00 2001 From: Sarthak Doshi Date: Mon, 11 May 2026 00:09:52 +0530 Subject: [PATCH 2/2] fix: use hasOwn for platform lookup, set-based diff deduplication, validate linkId ownership --- apps/backend/src/routes/cards.ts | 12 ++++++++++-- packages/shared/src/cards.ts | 9 +++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/routes/cards.ts b/apps/backend/src/routes/cards.ts index 81742fb..c412f1b 100644 --- a/apps/backend/src/routes/cards.ts +++ b/apps/backend/src/routes/cards.ts @@ -43,8 +43,12 @@ export async function cardRoutes(app: FastifyInstance) { if (parsed.data.linkIds.length > 0) { const links = await app.prisma.platformLink.findMany({ where: { id: { in: parsed.data.linkIds }, userId }, - select: { platform: true }, + select: { id: true, platform: true }, }); + // Reject if any requested linkId doesn't belong to this user or doesn't exist. + if (links.length !== new Set(parsed.data.linkIds).size) { + return reply.status(403).send({ error: 'One or more link IDs are invalid or do not belong to you.' }); + } const platformIds = links.map((l) => l.platform); const validation = validateCardPlatforms(platformIds); if (!validation.valid) { @@ -116,8 +120,12 @@ export async function cardRoutes(app: FastifyInstance) { if (parsed.data.linkIds.length > 0) { const links = await app.prisma.platformLink.findMany({ where: { id: { in: parsed.data.linkIds }, userId }, - select: { platform: true }, + select: { id: true, platform: true }, }); + // Reject if any requested linkId doesn't belong to this user or doesn't exist. + if (links.length !== new Set(parsed.data.linkIds).size) { + return reply.status(403).send({ error: 'One or more link IDs are invalid or do not belong to you.' }); + } const platformIds = links.map((l) => l.platform); const validation = validateCardPlatforms(platformIds); if (!validation.valid) { diff --git a/packages/shared/src/cards.ts b/packages/shared/src/cards.ts index e0fa690..aa5f1f1 100644 --- a/packages/shared/src/cards.ts +++ b/packages/shared/src/cards.ts @@ -36,7 +36,7 @@ export function validateCardPlatforms(platforms: string[]): CardValidationResult const seen = new Set(); for (const id of platforms) { - if (!PLATFORMS[id]) { + if (!Object.prototype.hasOwnProperty.call(PLATFORMS, id)) { errors.push(`Unknown platform: "${id}".`); } if (seen.has(id)) { @@ -55,14 +55,15 @@ export function validateCardPlatforms(platforms: string[]): CardValidationResult * * Returns which IDs were added, removed, or unchanged going from * oldCard → newCard. Order is not considered — only membership. + * Duplicate entries in either input are ignored (set semantics). */ export function diffCardPlatforms(oldCard: string[], newCard: string[]): CardDiffResult { const oldSet = new Set(oldCard); const newSet = new Set(newCard); - const added = newCard.filter((id) => !oldSet.has(id)); - const removed = oldCard.filter((id) => !newSet.has(id)); - const unchanged = oldCard.filter((id) => newSet.has(id)); + const added = [...newSet].filter((id) => !oldSet.has(id)); + const removed = [...oldSet].filter((id) => !newSet.has(id)); + const unchanged = [...oldSet].filter((id) => newSet.has(id)); return { added, removed, unchanged }; }