Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions apps/backend/src/routes/cards.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -38,6 +39,23 @@ 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: { 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) {
return reply.status(400).send({ error: 'Invalid card platforms', details: validation.errors });
}
}
Comment on lines +42 to +57

// Check if user's first card → make it default
const cardCount = await app.prisma.card.count({ where: { userId } });

Expand Down Expand Up @@ -98,6 +116,23 @@ 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: { 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) {
return reply.status(400).send({ error: 'Invalid card platforms', details: validation.errors });
}
}
Comment on lines +119 to +134

// Remove existing links
await app.prisma.cardLink.deleteMany({ where: { cardId: id } });
// Add new links
Expand Down
6 changes: 4 additions & 2 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
119 changes: 119 additions & 0 deletions packages/shared/src/__tests__/cards.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Comment on lines +53 to +57
});

// ─── 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([]);
});
});
69 changes: 69 additions & 0 deletions packages/shared/src/cards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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<string>();
for (const id of platforms) {
if (!Object.prototype.hasOwnProperty.call(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.
* 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 = [...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 };
}
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';