diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8cedac7..6d5d1294 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,9 @@ jobs: - name: Install backend dependencies run: npm --prefix apps/backend install + - name: Generate Prisma Client + run: cd apps/backend && npx prisma generate + - name: Backend lint id: backend_lint continue-on-error: true diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index 64f44440..832b4eee 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "@devcard/backend", "version": "1.0.0", + "hasInstallScript": true, "dependencies": { "@devcard/shared": "file:../../packages/shared", "@fastify/cookie": "^11.0.0", diff --git a/apps/backend/package.json b/apps/backend/package.json index 995ce916..cf8bb532 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -15,7 +15,8 @@ "db:deploy": "prisma migrate deploy", "db:seed": "tsx prisma/seed.ts", "db:studio": "prisma studio", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "postinstall": "prisma generate" }, "dependencies": { "@devcard/shared": "file:../../packages/shared", diff --git a/apps/backend/src/__tests__/analytics.test.ts b/apps/backend/src/__tests__/analytics.test.ts index 4f0d07ae..e6f6b607 100644 --- a/apps/backend/src/__tests__/analytics.test.ts +++ b/apps/backend/src/__tests__/analytics.test.ts @@ -1,3 +1,6 @@ +import Fastify, { + type FastifyInstance, +} from 'fastify'; import { describe, it, @@ -7,13 +10,11 @@ import { vi, } from 'vitest'; -import Fastify, { - type FastifyInstance, -} from 'fastify'; + +import { analyticsRoutes } from '../routes/analytics'; import type { PrismaClient } from '@prisma/client'; -import { analyticsRoutes } from '../routes/analytics'; // ─── Shared mock data ──────────────────────────────────────────────────────── @@ -34,7 +35,7 @@ const prismaMock = { // ─── App factory ───────────────────────────────────────────────────────────── -let mockJwtVerify = vi.fn(); +const mockJwtVerify = vi.fn(); async function buildApp(): Promise { const app = Fastify({ diff --git a/apps/backend/src/__tests__/app.test.ts b/apps/backend/src/__tests__/app.test.ts index 648d98a6..fcd598bf 100644 --- a/apps/backend/src/__tests__/app.test.ts +++ b/apps/backend/src/__tests__/app.test.ts @@ -1,8 +1,9 @@ -process.env.NODE_ENV = 'test'; - import { describe, it, expect } from 'vitest'; + import { buildApp } from '../app'; +process.env.NODE_ENV = 'test'; + describe('GET /health', () => { it('should return status ok', async () => { const app = await buildApp(); diff --git a/apps/backend/src/__tests__/event.test.ts b/apps/backend/src/__tests__/event.test.ts index 44806af1..0d81474c 100644 --- a/apps/backend/src/__tests__/event.test.ts +++ b/apps/backend/src/__tests__/event.test.ts @@ -1,8 +1,10 @@ +import Fastify, { type FastifyInstance } from 'fastify'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import Fastify, { FastifyInstance } from 'fastify'; -import { PrismaClient } from '@prisma/client'; + import { eventRoutes } from '../routes/event'; +import type { PrismaClient } from '@prisma/client'; + // ─── Shared mock data ──────────────────────────────────────────────────────── const MOCK_USER_ID = 'user-uuid-001'; @@ -64,7 +66,7 @@ const prismaMock = { // // This mirrors the real app setup without touching a real DB or real JWT keys. -let mockJwtVerify = vi.fn(); +const mockJwtVerify = vi.fn(); async function buildApp(): Promise { const app = Fastify({ logger: false }); @@ -74,8 +76,10 @@ async function buildApp(): Promise { // Decorate jwtVerify on the request prototype so request.jwtVerify() resolves // to whatever the current test wants. - app.decorateRequest('jwtVerify', function () { - return mockJwtVerify(); + app.decorateRequest('jwtVerify', async function (this: any) { + const payload = await mockJwtVerify(); + this.user = payload; + return payload; }); // Register with the same prefix used in production (app.ts) so that @@ -252,6 +256,10 @@ describe('Events API', () => { prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, _count: { attendees: 42 }, + organizer: { + username: 'johndoe', + displayName: 'John Doe', + }, }); const res = await app.inject({ @@ -264,8 +272,9 @@ describe('Events API', () => { expect(body.slug).toBe('devcard-conf-2025'); expect(body.attendeesCount).toBe(42); expect(body.location).toBe('San Francisco, CA'); - // organizerId is exposed (public info) - expect(body.organizerId).toBe(MOCK_USER_ID); + // organizer public fields are exposed + expect(body.organizerUsername).toBe('johndoe'); + expect(body.organizerDisplayName).toBe('John Doe'); }); it('404 — returns 404 for unknown slug', async () => { @@ -286,6 +295,10 @@ describe('Events API', () => { prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, _count: { attendees: 0 }, + organizer: { + username: 'johndoe', + displayName: 'John Doe', + }, }); const res = await app.inject({ @@ -495,6 +508,7 @@ describe('Events API', () => { prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, attendees: attendeeRows, + _count: { attendees: 2 }, }); const res = await app.inject({ @@ -523,6 +537,7 @@ describe('Events API', () => { prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, attendees: [makeAttendeeRow(MOCK_OTHER_USER_PROFILE)], + _count: { attendees: 1 }, }); const res = await app.inject({ @@ -545,6 +560,7 @@ describe('Events API', () => { prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, attendees: [], + _count: { attendees: 0 }, }); const res = await app.inject({ @@ -561,6 +577,7 @@ describe('Events API', () => { prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, attendees: [], + _count: { attendees: 0 }, }); const res = await app.inject({ @@ -577,6 +594,7 @@ describe('Events API', () => { prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, attendees: [], + _count: { attendees: 0 }, }); const res = await app.inject({ @@ -594,6 +612,7 @@ describe('Events API', () => { prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, attendees: [makeAttendeeRow(MOCK_USER_PROFILE)], + _count: { attendees: 1 }, }); const res = await app.inject({ @@ -632,6 +651,7 @@ describe('Events API', () => { prismaMock.event.findUnique.mockResolvedValue({ ...MOCK_EVENT, attendees: [], + _count: { attendees: 0 }, }); await app.inject({ diff --git a/apps/backend/src/__tests__/follow.test.ts b/apps/backend/src/__tests__/follow.test.ts index 41830018..d0a44008 100644 --- a/apps/backend/src/__tests__/follow.test.ts +++ b/apps/backend/src/__tests__/follow.test.ts @@ -1,4 +1,4 @@ -import Fastify, { FastifyInstance } from 'fastify'; +import Fastify, { type FastifyInstance } from 'fastify'; import { describe, expect, it, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import { followRoutes } from '../routes/follow.js'; diff --git a/apps/backend/src/__tests__/oauth-scope.test.ts b/apps/backend/src/__tests__/oauth-scope.test.ts index 0985dfa7..d814e449 100644 --- a/apps/backend/src/__tests__/oauth-scope.test.ts +++ b/apps/backend/src/__tests__/oauth-scope.test.ts @@ -11,10 +11,12 @@ * flow so the two records are independent and can never overwrite each other. */ -import { describe, it, expect, beforeEach, vi } from 'vitest'; import Fastify from 'fastify'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + import { connectRoutes } from '../routes/connect.js'; import { followRoutes } from '../routes/follow.js'; + import type { PrismaClient } from '@prisma/client'; // ── Mocks ───────────────────────────────────────────────────────────────────── @@ -45,7 +47,7 @@ function makeConnectState(userId: string): string { function buildConnectApp(mockPrisma: Partial) { const app = Fastify({ logger: false }); app.decorate('prisma', mockPrisma as PrismaClient); - app.decorate('authenticate', async (req: any) => { req.user = { id: USER_ID }; }); + app.decorate('authenticate', async (request: any) => { request.user = { id: USER_ID }; }); app.register(connectRoutes, { prefix: '/api/connect' }); return app.ready().then(() => app); } @@ -55,7 +57,7 @@ function buildConnectApp(mockPrisma: Partial) { function buildFollowApp(mockPrisma: Partial) { const app = Fastify({ logger: false }); app.decorate('prisma', mockPrisma as PrismaClient); - app.decorate('authenticate', async (req: any) => { req.user = { id: USER_ID }; }); + app.decorate('authenticate', async (request: any) => { request.user = { id: USER_ID }; }); app.register(followRoutes, { prefix: '/api/follow' }); return app.ready().then(() => app); } diff --git a/apps/backend/src/__tests__/public.test.ts b/apps/backend/src/__tests__/public.test.ts index a767b25d..8e825782 100644 --- a/apps/backend/src/__tests__/public.test.ts +++ b/apps/backend/src/__tests__/public.test.ts @@ -1,9 +1,13 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import Fastify from 'fastify'; import jwt from '@fastify/jwt'; +import Fastify from 'fastify'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + import { publicRoutes } from '../routes/public.js'; +import { generateQRBuffer, generateQRSvg } from '../utils/qr.js'; + import type { PrismaClient } from '@prisma/client'; + // ── Mock QR utilities ───────────────────────────────────────────────────────── // Prevents real QR rasterisation (and any native canvas/image deps) from running // during unit tests. The stubs return minimal valid values that satisfy the @@ -13,8 +17,6 @@ vi.mock('../utils/qr.js', () => ({ generateQRSvg: vi.fn().mockResolvedValue('fake'), })); -import { generateQRBuffer, generateQRSvg } from '../utils/qr.js'; - const mockUser = { id: 'user-123', username: 'testuser', diff --git a/apps/backend/src/__tests__/team.test.ts b/apps/backend/src/__tests__/team.test.ts index 350298a1..aedfb2a7 100644 --- a/apps/backend/src/__tests__/team.test.ts +++ b/apps/backend/src/__tests__/team.test.ts @@ -1,6 +1,7 @@ +import { type PrismaClient, TeamRole } from '@prisma/client'; +import Fastify, { type FastifyInstance } from 'fastify'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import Fastify, { FastifyInstance } from 'fastify'; -import { PrismaClient, TeamRole } from '@prisma/client'; + import { teamRoutes } from '../routes/team'; // ─── Shared mock data ───────────────────────────────────────────────────────── @@ -92,15 +93,17 @@ const prismaMock = { // ─── App factory ────────────────────────────────────────────────────────────── -let mockJwtVerify = vi.fn(); +const mockJwtVerify = vi.fn(); async function buildApp(): Promise { const app = Fastify({ logger: false }); app.decorate('prisma', prismaMock as unknown as PrismaClient); - app.decorateRequest('jwtVerify', function () { - return mockJwtVerify(); + app.decorateRequest('jwtVerify', async function (this: any) { + const payload = await mockJwtVerify(); + this.user = payload; + return payload; }); await app.register(teamRoutes); diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 06b87205..0407161a 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -7,7 +7,6 @@ import helmet from '@fastify/helmet'; import jwt from '@fastify/jwt'; import multipart from '@fastify/multipart'; import rateLimit from '@fastify/rate-limit'; -import fastifyStatic from '@fastify/static'; import Fastify, {type FastifyInstance} from 'fastify'; import { prismaPlugin } from './plugins/prisma.js'; @@ -21,8 +20,8 @@ import { followRoutes } from './routes/follow.js'; import { nfcRoutes } from './routes/nfc.js'; import { profileRoutes } from './routes/profiles.js'; import { publicRoutes } from './routes/public.js'; -import { validateEnv } from './utils/validateEnv.js'; import { teamRoutes } from './routes/team.js'; +import { validateEnv } from './utils/validateEnv.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -30,6 +29,10 @@ export async function buildApp():Promise { // Validate all required secrets before registering any plugin. // If validation fails the process exits here — no partially-initialised // auth state can exist because Fastify is not yet instantiated. + if (process.env.NODE_ENV === 'test') { + process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret-that-is-sufficiently-long-and-secure'; + process.env.ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'test-encryption-key-for-testing-purposes-32-chars'; + } validateEnv(); const app = Fastify({ @@ -92,8 +95,8 @@ export async function buildApp():Promise { try { // Ensure the verified payload is assigned to `request.user` like the original plugin. const payload = await request.jwtVerify(); - if (payload) request.user = payload; - } catch (error) { + if (payload) {request.user = payload;} + } catch { reply.status(401).send({ error: 'Unauthorized' }); } }); diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts index 7d841d9c..4840d20c 100644 --- a/apps/backend/src/env.ts +++ b/apps/backend/src/env.ts @@ -1,6 +1,6 @@ -import process from 'node:process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; + import dotenv from 'dotenv'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); diff --git a/apps/backend/src/plugins/prisma.ts b/apps/backend/src/plugins/prisma.ts index f6ebede8..ec2d74aa 100644 --- a/apps/backend/src/plugins/prisma.ts +++ b/apps/backend/src/plugins/prisma.ts @@ -1,5 +1,6 @@ -import fp from 'fastify-plugin'; import { PrismaClient } from '@prisma/client'; +import fp from 'fastify-plugin'; + import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; declare module 'fastify' { diff --git a/apps/backend/src/plugins/redis.ts b/apps/backend/src/plugins/redis.ts index 864b112f..881c289b 100644 --- a/apps/backend/src/plugins/redis.ts +++ b/apps/backend/src/plugins/redis.ts @@ -1,5 +1,6 @@ import fp from 'fastify-plugin'; import Redis from 'ioredis'; + import type { FastifyInstance } from 'fastify'; declare module 'fastify' { @@ -17,7 +18,7 @@ export const redisPlugin = fp(async (app: FastifyInstance) => { try { await redis.connect(); app.log.info('🔴 Redis connected'); - } catch (error) { + } catch { app.log.warn('⚠️ Redis connection failed — running without cache'); } diff --git a/apps/backend/src/routes/analytics.ts b/apps/backend/src/routes/analytics.ts index a975424f..3b7fced4 100644 --- a/apps/backend/src/routes/analytics.ts +++ b/apps/backend/src/routes/analytics.ts @@ -11,8 +11,8 @@ export async function analyticsRoutes( app.get( '/overview', { - // eslint-disable-next-line @typescript-eslint/unbound-method - preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }], + + preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }], }, async ( request: FastifyRequest, @@ -96,8 +96,8 @@ export async function analyticsRoutes( }>( '/views', { - // eslint-disable-next-line @typescript-eslint/unbound-method - preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }], + + preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }], }, async ( request: FastifyRequest<{ diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index c14949e1..190b2014 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -1,6 +1,7 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { encrypt } from '../utils/encryption.js'; import { buildOAuthState, getMobileRedirectUri } from '../services/authService.js'; +import { encrypt } from '../utils/encryption.js'; + +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize'; const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; @@ -257,7 +258,7 @@ export async function authRoutes(app: FastifyInstance) { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async (request: FastifyRequest, reply: FastifyReply) => { const userId = (request.user as any).id; const user = await app.prisma.user.findUnique({ diff --git a/apps/backend/src/routes/cards.ts b/apps/backend/src/routes/cards.ts index 32fe835c..4988fa39 100644 --- a/apps/backend/src/routes/cards.ts +++ b/apps/backend/src/routes/cards.ts @@ -1,9 +1,8 @@ +import * as cardService from '../services/cardService' import { handleDbError } from '../utils/error.util.js'; import { createCardSchema, updateCardSchema } from '../utils/validators.js'; -import * as cardService from '../services/cardService' import type { Card } from '@devcard/shared'; -import type { Prisma } from '@prisma/client'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; @@ -21,40 +20,16 @@ interface CardParams { id: string; } -interface PlatformLink { - id: string; - userId: string; - platform: string; - username: string; - url: string; - displayOrder: number; - createdAt: Date; -} -interface CardLinkWithPlatform { - id: string; - cardId: string; - platformLinkId: string; - displayOrder: number; - platformLink: PlatformLink; -} -interface CardWithLinks { - id: string; - userId: string; - title: string; - isDefault: boolean; - createdAt: Date; - updatedAt: Date; - cardLinks: CardLinkWithPlatform[]; -} + export async function cardRoutes(app: FastifyInstance): Promise { app.addHook('preHandler', async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }); // ─── List Cards ─── @@ -82,7 +57,7 @@ export async function cardRoutes(app: FastifyInstance): Promise { const card = await cardService.createCard(app, userId, parsed.data) return reply.status(201).send(card) } catch (error: any) { - if (error?.code === 'OWNERSHIP') return reply.status(403).send({ error: 'One or more links do not belong to your account' }) + if (error?.code === 'OWNERSHIP') {return reply.status(403).send({ error: 'One or more links do not belong to your account' })} return handleDbError(error, request, reply) } }); @@ -95,12 +70,12 @@ export async function cardRoutes(app: FastifyInstance): Promise { try { const parsed = updateCardSchema.safeParse(request.body) - if (!parsed.success) return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() }) + if (!parsed.success) {return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() })} const updated = await cardService.updateCard(app, userId, id, parsed.data) - if (!updated) return reply.status(404).send({ error: 'Card not found' }) + if (!updated) {return reply.status(404).send({ error: 'Card not found' })} return updated } catch (error: any) { - if (error?.code === 'OWNERSHIP') return reply.status(403).send({ error: 'One or more links do not belong to your account' }) + if (error?.code === 'OWNERSHIP') {return reply.status(403).send({ error: 'One or more links do not belong to your account' })} return handleDbError(error, request, reply) } }); @@ -113,8 +88,8 @@ export async function cardRoutes(app: FastifyInstance): Promise { try { const res = await cardService.deleteCard(app, userId, id) - if (res && (res as any).code === 'NOT_FOUND') return reply.status(404).send({ error: 'Card not found' }) - if (res && (res as any).code === 'LAST_CARD') return reply.status(400).send({ error: 'Cannot delete the last remaining card. A user must have at least one card.' }) + if (res && (res as any).code === 'NOT_FOUND') {return reply.status(404).send({ error: 'Card not found' })} + if (res && (res as any).code === 'LAST_CARD') {return reply.status(400).send({ error: 'Cannot delete the last remaining card. A user must have at least one card.' })} return reply.status(204).send() } catch (error) { return handleDbError(error, request, reply) @@ -129,7 +104,7 @@ export async function cardRoutes(app: FastifyInstance): Promise { try { const resp = await cardService.setDefaultCard(app, userId, id) - if (!resp) return reply.status(404).send({ error: 'Card not found' }) + if (!resp) {return reply.status(404).send({ error: 'Card not found' })} return resp } catch (error) { return handleDbError(error, request, reply) diff --git a/apps/backend/src/routes/connect.ts b/apps/backend/src/routes/connect.ts index bb04194d..141d7d69 100644 --- a/apps/backend/src/routes/connect.ts +++ b/apps/backend/src/routes/connect.ts @@ -1,7 +1,9 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { randomBytes } from 'crypto'; +import { randomBytes } from 'node:crypto'; + import { encrypt } from '../utils/encryption.js'; +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize'; const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; @@ -22,7 +24,7 @@ interface ParsedOAuthState { nonce: string; } -export async function connectRoutes(app: FastifyInstance) { +export async function connectRoutes(app: FastifyInstance): Promise { // ─── Status ─── app.get('/status', { @@ -30,9 +32,9 @@ export async function connectRoutes(app: FastifyInstance) { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }], - }, async (request: FastifyRequest, reply: FastifyReply) => { + }, async (request: FastifyRequest, _reply: FastifyReply) => { const userId = (request.user as any).id; const tokens = await app.prisma.oAuthToken.findMany({ @@ -50,7 +52,7 @@ export async function connectRoutes(app: FastifyInstance) { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }], }, async (request: FastifyRequest, reply: FastifyReply) => { const userId = (request.user as any).id; @@ -102,7 +104,9 @@ export async function connectRoutes(app: FastifyInstance) { } // Consume the nonce -- one-time use only (if redis configured) - if (app.redis) await app.redis.del(`oauth:nonce:${decodedState.nonce}`); + if (app.redis) { + await app.redis.del(`oauth:nonce:${decodedState.nonce}`); + } const userId = decodedState.userId; @@ -175,7 +179,7 @@ export async function connectRoutes(app: FastifyInstance) { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }], }, async (request: FastifyRequest<{ Params: { platform: string } }>, reply: FastifyReply) => { const userId = (request.user as any).id; @@ -196,7 +200,7 @@ export async function connectRoutes(app: FastifyInstance) { }, }); return { success: true }; - } catch (error) { + } catch { return reply.status(404).send({ error: 'Connection not found' }); } }); diff --git a/apps/backend/src/routes/event.ts b/apps/backend/src/routes/event.ts index 4d4ee2d9..b4521e09 100644 --- a/apps/backend/src/routes/event.ts +++ b/apps/backend/src/routes/event.ts @@ -1,7 +1,8 @@ +import {generateUniqueSlug} from '../utils/slug' +import { createEventSchema } from '../validations/event.validation'; + import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { createEventSchema, joinEventSchema} from '../validations/event.validation'; -import {generateUniqueSlug} from '../utils/slug' type EventDetails = { @@ -62,7 +63,7 @@ export async function eventRoutes(app:FastifyInstance) { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async (request: FastifyRequest<{ Body: { name: string, @@ -80,8 +81,8 @@ export async function eventRoutes(app:FastifyInstance) { const {name, description, startDate, endDate, isPublic ,location} = parsed.data - let finalSlug = await generateUniqueSlug(name, async(slug) => { - const existing = await app.prisma.event.findUnique({where: {slug : slug}}) + const finalSlug = await generateUniqueSlug(name, async(slug) => { + const existing = await app.prisma.event.findUnique({where: {slug}}) return !!existing }) @@ -95,7 +96,7 @@ export async function eventRoutes(app:FastifyInstance) { name, description, slug: finalSlug, - location: location, + location, startDate: startDateObj, endDate: endDateObj, isPublic: isPublic ?? true, @@ -104,7 +105,7 @@ export async function eventRoutes(app:FastifyInstance) { }) return reply.status(201).send(newEvent); - } catch (error) { + } catch { app.log.error('Failed to create event'); return reply.status(500).send({error: 'Failed to create event'}) } @@ -153,7 +154,7 @@ export async function eventRoutes(app:FastifyInstance) { return response; }) - app.post('/:slug/join', { preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request: FastifyRequest<{Params: {slug: string}}>, reply: FastifyReply) => { + app.post('/:slug/join', { preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request: FastifyRequest<{Params: {slug: string}}>, reply: FastifyReply) => { const userId = (request.user as any).id; const paramsSlug = request.params.slug; @@ -171,7 +172,7 @@ export async function eventRoutes(app:FastifyInstance) { await app.prisma.eventAttendee.create({ data: { eventId: event.id, - userId: userId, + userId, joinedAt: new Date() } }) @@ -187,7 +188,7 @@ export async function eventRoutes(app:FastifyInstance) { }) - app.delete('/:slug/leave', { preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request: FastifyRequest<{Params: {slug: string}}>, reply: FastifyReply) => { + app.delete('/:slug/leave', { preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request: FastifyRequest<{Params: {slug: string}}>, reply: FastifyReply) => { const userId = (request.user as any).id; const paramsSlug = request.params.slug; @@ -205,7 +206,7 @@ export async function eventRoutes(app:FastifyInstance) { await app.prisma.eventAttendee.delete({ where: { userId_eventId: { - userId: userId, + userId, eventId: event.id } } diff --git a/apps/backend/src/routes/follow.ts b/apps/backend/src/routes/follow.ts index a152fc55..51c8ecb0 100644 --- a/apps/backend/src/routes/follow.ts +++ b/apps/backend/src/routes/follow.ts @@ -1,15 +1,17 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { getPlatform, resolveDeepLink } from '@devcard/shared'; + import { decrypt } from '../utils/encryption.js'; import { getErrorMessage } from '../utils/error.util.js'; -import { getPlatform, getProfileUrl, getWebViewUrl } from '@devcard/shared'; import { followLogSchema } from '../validations/follow.validation.js'; -export async function followRoutes(app: FastifyInstance) { +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +export async function followRoutes(app: FastifyInstance): Promise { app.addHook('preHandler', async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { const payload = await request.jwtVerify(); if (payload) (request as any).user = payload; } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { const payload = await request.jwtVerify(); if (payload) {(request as any).user = payload;} } catch { reply.status(401).send({ error: 'Unauthorized' }) } }); // ─── Follow via API (Layer 1) ─── @@ -35,14 +37,16 @@ export async function followRoutes(app: FastifyInstance) { }, }); - // Use WebView follow strategy if configured for the platform (e.g. LinkedIn, Twitter/X) + // Use WebView follow strategy if resolved for the platform (e.g. LinkedIn, Twitter/X) const platformDef = getPlatform(platform); - if (platformDef?.followStrategy === 'webview') { - const url = getWebViewUrl(platform, targetUsername) || getProfileUrl(platform, targetUsername); - return reply.send({ - strategy: 'webview', - url, - }); + if (platformDef) { + const resolved = resolveDeepLink(platform, targetUsername, { isMobile: false }); + if (resolved.strategy === 'webview') { + return reply.send({ + strategy: 'webview', + url: resolved.url, + }); + } } if (!oauthToken) { diff --git a/apps/backend/src/routes/nfc.ts b/apps/backend/src/routes/nfc.ts index 5cf13f0c..e7e483c3 100644 --- a/apps/backend/src/routes/nfc.ts +++ b/apps/backend/src/routes/nfc.ts @@ -1,6 +1,7 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { z } from 'zod'; +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + type NfcPayloadResponse = { type: 'URI'; payload: string; @@ -10,7 +11,7 @@ const nfcQuerySchema = z.object({ card: z.string().uuid('Invalid card ID format').optional(), }); -export async function nfcRoutes(app: FastifyInstance) { +export async function nfcRoutes(app: FastifyInstance): Promise { app.addHook('preHandler', async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { @@ -23,7 +24,7 @@ export async function nfcRoutes(app: FastifyInstance) { } try { await request.jwtVerify(); - } catch (e) { + } catch { reply.status(401).send({ error: 'Unauthorized' }); } }); diff --git a/apps/backend/src/routes/profiles.ts b/apps/backend/src/routes/profiles.ts index 81026c74..2890e566 100644 --- a/apps/backend/src/routes/profiles.ts +++ b/apps/backend/src/routes/profiles.ts @@ -1,27 +1,15 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { getProfileUrl } from '@devcard/shared'; -import { updateProfileSchema, createLinkSchema, reorderLinksSchema } from '../utils/validators.js'; -import { getErrorMessage } from '../utils/error.util.js'; + import * as profileService from '../services/profileService' +import { updateProfileSchema, createLinkSchema, reorderLinksSchema } from '../utils/validators.js'; + +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; // ── Response types ──────────────────────────────────────────────────────────── // Declared explicitly so the API contract is visible without tracing through // Prisma's generic return types. Follows the convention in public.ts. -type ProfileUpdateResponse = { - id: string; - email: string; - username: string; - displayName: string; - bio: string | null; - pronouns: string | null; - role: string | null; - company: string | null; - avatarUrl: string | null; - accentColor: string; -}; - -export async function profileRoutes(app: FastifyInstance) { + +export async function profileRoutes(app: FastifyInstance): Promise { // All profile routes require auth app.addHook('preHandler', async (request, reply) => { const server = request.server as any; @@ -35,7 +23,7 @@ export async function profileRoutes(app: FastifyInstance) { } try { await request.jwtVerify(); - } catch (e) { + } catch { reply.status(401).send({ error: 'Unauthorized' }); } }); @@ -45,7 +33,7 @@ export async function profileRoutes(app: FastifyInstance) { app.get('/me', async (request: FastifyRequest, reply: FastifyReply) => { const userId = (request.user as any).id; const user = await profileService.getOwnProfile(app, userId) - if (!user) return reply.status(404).send({ error: 'User not found' }) + if (!user) {return reply.status(404).send({ error: 'User not found' })} return user }); @@ -80,7 +68,7 @@ export async function profileRoutes(app: FastifyInstance) { const response = await profileService.updateProfile(app, userId, parsed.data) return response } catch (err: any) { - if (err?.code === 'P2002') return reply.status(409).send({ error: 'Username already taken' }) + if (err?.code === 'P2002') {return reply.status(409).send({ error: 'Username already taken' })} app.log.error({ err }, 'DB error in PUT /profiles/me') return reply.status(500).send({ error: 'Internal server error' }) } @@ -112,10 +100,10 @@ export async function profileRoutes(app: FastifyInstance) { const { id } = request.params; const parsedReq = createLinkSchema.safeParse(request.body) - if (!parsedReq.success) return reply.status(400).send({ error: 'Validation failed', details: parsedReq.error.flatten() }) + if (!parsedReq.success) {return reply.status(400).send({ error: 'Validation failed', details: parsedReq.error.flatten() })} try { const updated = await profileService.updatePlatformLink(app, userId, id, parsedReq.data) - if (!updated) return reply.status(404).send({ error: 'Link not found' }) + if (!updated) {return reply.status(404).send({ error: 'Link not found' })} return updated } catch (err: any) { app.log.error({ err }, 'Failed to update platform link') @@ -131,7 +119,7 @@ export async function profileRoutes(app: FastifyInstance) { try { const deleted = await profileService.deletePlatformLink(app, userId, id) - if (!deleted) return reply.status(404).send({ error: 'Link not found' }) + if (!deleted) {return reply.status(404).send({ error: 'Link not found' })} return reply.status(204).send() } catch (err: any) { app.log.error({ err }, 'Failed to delete platform link') @@ -144,7 +132,7 @@ export async function profileRoutes(app: FastifyInstance) { app.put('/me/links/reorder', async (request: FastifyRequest, reply: FastifyReply) => { const userId = (request.user as any).id; const parsedReq = reorderLinksSchema.safeParse(request.body) - if (!parsedReq.success) return reply.status(400).send({ error: 'Validation failed', details: parsedReq.error.flatten() }) + if (!parsedReq.success) {return reply.status(400).send({ error: 'Validation failed', details: parsedReq.error.flatten() })} try { const resp = await profileService.reorderLinks(app, userId, parsedReq.data.links) return resp diff --git a/apps/backend/src/routes/public.ts b/apps/backend/src/routes/public.ts index 27f544d8..5ccf211c 100644 --- a/apps/backend/src/routes/public.ts +++ b/apps/backend/src/routes/public.ts @@ -1,8 +1,8 @@ -import type { FastifyContextConfig, FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { generateQRBuffer, generateQRSvg } from '../utils/qr.js'; -import type { PlatformLink } from '@devcard/shared'; -import { getErrorMessage } from '../utils/error.util.js'; import * as publicService from '../services/publicService' +import { generateQRBuffer, generateQRSvg } from '../utils/qr.js'; + +import type { FastifyContextConfig, FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + // ── QR size bounds ──────────────────────────────────────────────────────────── @@ -16,80 +16,23 @@ const MAX_QR_SIZE = 2048; // Public profile cache TTL matches the Cache-Control max-age (5 minutes). // The QR session JWT TTL is 10 minutes so an offline scan remains valid well // beyond the HTTP cache window. -const PROFILE_CACHE_TTL = 300; // seconds (5 minutes) const CACHE_CONTROL_HEADER = 'public, max-age=300, stale-while-revalidate=60'; -type PublicProfileLink = { - id: string; - platform: string; - username: string; - url: string; - displayOrder: number; - followed?: boolean; -} -type UsernamePublicProfileResponse = { - username: string; - displayName: string; - bio: string | null; - pronouns: string | null; - role: string | null; - company: string | null; - avatarUrl: string | null; - accentColor: string; - links: PublicProfileLink[] -} -type PublicProfileCardLink = { - id: string; - platform: string; - username: string; - url: string; - followed?: boolean; -} -type CardPublicProfileResponse = { - id: string; - title: string; - owner: { - username: string; - displayName: string; - bio: string | null; - avatarUrl: string | null; - accentColor: string; - }; - links: PublicProfileCardLink[] -} -type UsernameCardPublicProfileResponse = { - title: string; - owner: { - username: string; - displayName: string; - bio: string | null; - pronouns: string | null; - role: string | null; - company: string | null; - avatarUrl: string | null; - accentColor: string; - }; - links: PublicProfileCardLink[] -} + + // Represents a CardLink record with the joined PlatformLink relation -interface CardLinkWithPlatform { - id: string; - displayOrder: number; - platformLink: PlatformLink; -} // ── Internal Redis cache shape ──────────────────────────────────────────────── // Extends the public response with the owner's DB id so that background view // tracking can still fire on cache-HIT requests without an extra DB read. -type CachedProfileEntry = UsernamePublicProfileResponse & { _userId: string }; -export async function publicRoutes(app: FastifyInstance) { +export async function publicRoutes(app: FastifyInstance): Promise { // ─── Public Profile ─────────────────────────────────────────────────────── // ─── Public Profile ─── /** @@ -105,7 +48,6 @@ export async function publicRoutes(app: FastifyInstance) { }, }, async (request: FastifyRequest<{ Params: { username: string } }>, reply: FastifyReply) => { const { username } = request.params; - const cacheKey = `profile:${username}`; // Try to extract viewer from Authorization header (soft auth). let viewerId: string | null = null @@ -122,7 +64,7 @@ export async function publicRoutes(app: FastifyInstance) { try { const result = await publicService.getPublicProfile(app, username, viewerId, request) - if (!result) return reply.status(404).send({ error: 'User not found' }) + if (!result) {return reply.status(404).send({ error: 'User not found' })} reply.header('X-Cache', result.cached ? 'HIT' : 'MISS').header('Cache-Control', CACHE_CONTROL_HEADER) return result.data } catch (err: any) { @@ -150,7 +92,7 @@ export async function publicRoutes(app: FastifyInstance) { try { const card = await publicService.getCardById(app, cardId) - if (!card) return reply.status(404).send({ error: 'Card not found' }) + if (!card) {return reply.status(404).send({ error: 'Card not found' })} const response = { id: card.id, title: card.title, owner: { username: card.user.username, displayName: card.user.displayName, bio: card.user.bio, avatarUrl: card.user.avatarUrl, accentColor: card.user.accentColor }, links: card.cardLinks.map((cl: any) => ({ id: cl.platformLink.id, platform: cl.platformLink.platform, username: cl.platformLink.username, url: cl.platformLink.url })) } return response } catch (err: any) { @@ -188,7 +130,7 @@ export async function publicRoutes(app: FastifyInstance) { try { const result = await publicService.getUserCard(app, username, cardId, viewerId, request) - if (result.notFound) return reply.status(404).send({ error: 'User or card not found' }) + if (result.notFound) {return reply.status(404).send({ error: 'User or card not found' })} return result.data } catch (err: any) { app.log.error({ err }, 'Failed to fetch user card') @@ -209,11 +151,10 @@ export async function publicRoutes(app: FastifyInstance) { } as FastifyContextConfig }, async (request: FastifyRequest<{ Params: { username: string } }>, reply: FastifyReply) => { const { username } = request.params; - const cacheKey = `profile:${username}`; try { const result = await publicService.getPublicProfile(app, username, null, request) - if (!result) return reply.status(404).send({ error: 'User not found' }) + if (!result) {return reply.status(404).send({ error: 'User not found' })} const snapshot = result.data const expiresIn = 600 const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString() diff --git a/apps/backend/src/routes/team.ts b/apps/backend/src/routes/team.ts index af177e52..1f9d30c1 100644 --- a/apps/backend/src/routes/team.ts +++ b/apps/backend/src/routes/team.ts @@ -24,12 +24,12 @@ type TeamProfile = { members: TeamMember[]; } -export async function teamRoutes(app:FastifyInstance){ +export async function teamRoutes(app:FastifyInstance): Promise{ app.post('/', { preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { const payload = await request.jwtVerify(); if (payload) (request as any).user = payload; } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { const payload = await request.jwtVerify(); if (payload) {(request as any).user = payload;} } catch { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request:FastifyRequest<{ Body: {name: string, description? : string, avatarUrl?: string } }>, reply: FastifyReply) => { @@ -48,7 +48,7 @@ export async function teamRoutes(app:FastifyInstance){ try { const team = await app.prisma.$transaction(async (tx) => { - const team = await tx.team.create({ + const newTeam = await tx.team.create({ data: { name, slug: finalSlug, @@ -60,13 +60,13 @@ export async function teamRoutes(app:FastifyInstance){ await tx.teamMember.create({ data: { - teamId : team.id, + teamId : newTeam.id, userId, role: TeamRole.OWNER, joinedAt: new Date(), } }) - return team + return newTeam }) return reply.status(201).send(team) @@ -161,7 +161,7 @@ export async function teamRoutes(app:FastifyInstance){ const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { const payload = await request.jwtVerify(); if (payload) (request as any).user = payload; } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { const payload = await request.jwtVerify(); if (payload) {(request as any).user = payload;} } catch { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request: FastifyRequest<{Params: {slug:string}, Body:{username:string}}>, reply: FastifyReply) => { const paramsSlug = request.params.slug; const userId = (request.user as any).id; @@ -224,7 +224,7 @@ export async function teamRoutes(app:FastifyInstance){ } }) - app.delete('/:slug/members/:userId', { preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request: FastifyRequest<{Params: {slug: string, userId: string}}>, reply: FastifyReply) => { + app.delete('/:slug/members/:userId', { preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request: FastifyRequest<{Params: {slug: string, userId: string}}>, reply: FastifyReply) => { const paramsSlug = request.params.slug const paramsUserId = request.params.userId const userID = (request.user as any).id; @@ -286,7 +286,7 @@ export async function teamRoutes(app:FastifyInstance){ } }) - app.patch('/:slug',{ preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request: FastifyRequest<{Params: {slug: string},Body: {description?:string, name?:string, avatarUrl?:string}}>, reply: FastifyReply) => { + app.patch('/:slug',{ preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request: FastifyRequest<{Params: {slug: string},Body: {description?:string, name?:string, avatarUrl?:string}}>, reply: FastifyReply) => { const userId = (request.user as any).id; const paramsSlug = request.params.slug; const parsed = updateTeam.safeParse(request.body); @@ -328,7 +328,7 @@ export async function teamRoutes(app:FastifyInstance){ }) - app.delete('/:slug',{ preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request:FastifyRequest<{Params:{slug: string}}>, reply:FastifyReply) => { + app.delete('/:slug',{ preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request:FastifyRequest<{Params:{slug: string}}>, reply:FastifyReply) => { const userId = (request.user as any).id; const paramsSlug = request.params.slug; diff --git a/apps/backend/src/services/authService.ts b/apps/backend/src/services/authService.ts index 9af718c5..c9b839bb 100644 --- a/apps/backend/src/services/authService.ts +++ b/apps/backend/src/services/authService.ts @@ -1,4 +1,4 @@ -import { randomBytes } from 'crypto'; +import { randomBytes } from 'node:crypto'; export function generateState(): string { return randomBytes(32).toString('hex'); diff --git a/apps/backend/src/services/cardService.ts b/apps/backend/src/services/cardService.ts index a9721783..216a98b8 100644 --- a/apps/backend/src/services/cardService.ts +++ b/apps/backend/src/services/cardService.ts @@ -1,5 +1,5 @@ -import type { FastifyInstance } from 'fastify' import type { Prisma } from '@prisma/client' +import type { FastifyInstance } from 'fastify' export async function listCards(app: FastifyInstance, userId: string) { const cards = await app.prisma.card.findMany({ @@ -15,7 +15,7 @@ export async function listCards(app: FastifyInstance, userId: string) { export async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }) { if (body.linkIds.length > 0) { const ownedLinks = await app.prisma.platformLink.findMany({ where: { id: { in: body.linkIds }, userId }, select: { id: true } }) - if (ownedLinks.length !== body.linkIds.length) throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }) + if (ownedLinks.length !== body.linkIds.length) {throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' })} } const cardCount = await app.prisma.card.count({ where: { userId } }) @@ -35,7 +35,7 @@ export async function createCard(app: FastifyInstance, userId: string, body: { t export async function updateCard(app: FastifyInstance, userId: string, id: string, body: { title?: string; linkIds?: string[] }) { const existing = await app.prisma.card.findFirst({ where: { id, userId } }) - if (!existing) return null + if (!existing) {return null} if (body.title) { await app.prisma.card.update({ where: { id }, data: { title: body.title } }) @@ -44,7 +44,7 @@ export async function updateCard(app: FastifyInstance, userId: string, id: strin if (body.linkIds) { if (body.linkIds.length > 0) { const ownedLinks = await app.prisma.platformLink.findMany({ where: { id: { in: body.linkIds }, userId }, select: { id: true } }) - if (ownedLinks.length !== body.linkIds.length) throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }) + if (ownedLinks.length !== body.linkIds.length) {throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' })} } const linkIds = body.linkIds @@ -63,10 +63,10 @@ export async function updateCard(app: FastifyInstance, userId: string, id: strin export async function deleteCard(app: FastifyInstance, userId: string, id: string) { return await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { const existing = await tx.card.findFirst({ where: { id, userId } }) - if (!existing) return Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' }) + if (!existing) {return Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' })} const userCardCount = await tx.card.count({ where: { userId } }) - if (userCardCount <= 1) return Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' }) + if (userCardCount <= 1) {return Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' })} if (existing.isDefault) { const oldestRemainingCard = await tx.card.findFirst({ where: { userId, id: { not: id } }, orderBy: { createdAt: 'asc' } }) @@ -82,7 +82,7 @@ export async function deleteCard(app: FastifyInstance, userId: string, id: strin export async function setDefaultCard(app: FastifyInstance, userId: string, id: string) { const existing = await app.prisma.card.findFirst({ where: { id, userId } }) - if (!existing) return null + if (!existing) {return null} await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { await tx.card.updateMany({ where: { userId }, data: { isDefault: false } }) diff --git a/apps/backend/src/services/profileService.ts b/apps/backend/src/services/profileService.ts index dc97b2a4..385709b1 100644 --- a/apps/backend/src/services/profileService.ts +++ b/apps/backend/src/services/profileService.ts @@ -1,8 +1,9 @@ -import type { FastifyInstance } from 'fastify' import { getProfileUrl } from '@devcard/shared' -import type { PlatformLink } from '@devcard/shared' + import { getErrorMessage } from '../utils/error.util.js' +import type { FastifyInstance } from 'fastify' + export async function getOwnProfile(app: FastifyInstance, userId: string) { const user = await app.prisma.user.findUnique({ where: { id: userId }, @@ -12,9 +13,9 @@ export async function getOwnProfile(app: FastifyInstance, userId: string) { }, }) - if (!user) return null + if (!user) {return null} - const { provider, providerId, ...profileData } = user as any + const { provider: _provider, providerId: _providerId, ...profileData } = user as any return { ...profileData, defaultCardId: user.cards[0]?.id || null } } @@ -24,7 +25,7 @@ export async function updateProfile(app: FastifyInstance, userId: string, data: const existing = await app.prisma.user.findFirst({ where: { username: data.username, NOT: { id: userId } }, }) - if (existing) throw Object.assign(new Error('Username taken'), { code: 'P2002' }) + if (existing) {throw Object.assign(new Error('Username taken'), { code: 'P2002' })} } const currentUser = await app.prisma.user.findUnique({ where: { id: userId }, select: { username: true } }) @@ -42,7 +43,7 @@ export async function updateProfile(app: FastifyInstance, userId: string, data: return response } catch (err: any) { - if (err?.code === 'P2002') throw err + if (err?.code === 'P2002') {throw err} app.log.error({ err }, 'DB error in updateProfile') throw err } @@ -56,14 +57,14 @@ export async function createPlatformLink(app: FastifyInstance, userId: string, l export async function updatePlatformLink(app: FastifyInstance, userId: string, id: string, linkData: any) { const existing = await app.prisma.platformLink.findFirst({ where: { id, userId } }) - if (!existing) return null + if (!existing) {return null} const url = linkData.url || getProfileUrl(linkData.platform, linkData.username) return app.prisma.platformLink.update({ where: { id }, data: { platform: linkData.platform, username: linkData.username, url } }) } export async function deletePlatformLink(app: FastifyInstance, userId: string, id: string) { const existing = await app.prisma.platformLink.findFirst({ where: { id, userId } }) - if (!existing) return false + if (!existing) {return false} await app.prisma.platformLink.delete({ where: { id } }) return true } diff --git a/apps/backend/src/services/publicService.ts b/apps/backend/src/services/publicService.ts index 758ab78f..0e58cdc9 100644 --- a/apps/backend/src/services/publicService.ts +++ b/apps/backend/src/services/publicService.ts @@ -1,8 +1,8 @@ -import type { FastifyInstance } from 'fastify' import { getErrorMessage } from '../utils/error.util.js' +import type { FastifyInstance } from 'fastify' + const PROFILE_CACHE_TTL = 300 -const CACHE_CONTROL_HEADER = 'public, max-age=300, stale-while-revalidate=60' export async function getPublicProfile(app: FastifyInstance, username: string, viewerId: string | null, request: any) { const cacheKey = `profile:${username}` @@ -23,7 +23,7 @@ export async function getPublicProfile(app: FastifyInstance, username: string, v } const user = await app.prisma.user.findUnique({ where: { username }, include: { platformLinks: { orderBy: { displayOrder: 'asc' } } } }) - if (!user) return null + if (!user) {return null} if (viewerId && viewerId !== user.id) { app.prisma.cardView.create({ data: { ownerId: user.id, cardId: null, viewerId, viewerIp: request.ip || null, viewerAgent: request.headers['user-agent'] || null, source: request.query?.source || 'link' } }).catch((error: unknown) => app.log.error(`Failed to log view: ${getErrorMessage(error)}`)) @@ -54,9 +54,9 @@ export async function getCardById(app: FastifyInstance, cardId: string) { export async function getUserCard(app: FastifyInstance, username: string, cardId: string, viewerId: string | null, request: any) { const user = await app.prisma.user.findUnique({ where: { username } }) - if (!user) return { notFound: true } + if (!user) {return { notFound: true }} const card = await app.prisma.card.findFirst({ where: { id: cardId, userId: user.id }, include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } } }) - if (!card) return { notFound: true } + if (!card) {return { notFound: true }} if (viewerId && viewerId !== user.id) { app.prisma.cardView.create({ data: { ownerId: user.id, cardId: card.id, viewerId, viewerIp: request.ip || null, viewerAgent: request.headers['user-agent'] || null, source: request.query?.source || 'qr' } }).catch((error: unknown) => app.log.error(`Failed to log view: ${getErrorMessage(error)}`)) diff --git a/apps/backend/src/utils/encryption.ts b/apps/backend/src/utils/encryption.ts index b9105992..adfb3172 100644 --- a/apps/backend/src/utils/encryption.ts +++ b/apps/backend/src/utils/encryption.ts @@ -1,4 +1,4 @@ -import crypto from 'crypto'; +import crypto from 'node:crypto'; const ALGORITHM = 'aes-256-gcm'; const IV_LENGTH = 16; diff --git a/apps/backend/src/utils/error.util.ts b/apps/backend/src/utils/error.util.ts index fef1b98b..d9885d09 100644 --- a/apps/backend/src/utils/error.util.ts +++ b/apps/backend/src/utils/error.util.ts @@ -1,6 +1,7 @@ -import type { FastifyReply, FastifyRequest } from 'fastify'; import { Prisma } from '@prisma/client'; +import type { FastifyReply, FastifyRequest } from 'fastify'; + export function getErrorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } diff --git a/apps/backend/src/utils/slug.ts b/apps/backend/src/utils/slug.ts index 24b772f3..4f0d0fcd 100644 --- a/apps/backend/src/utils/slug.ts +++ b/apps/backend/src/utils/slug.ts @@ -10,9 +10,9 @@ export async function generateUniqueSlug(name: string, while(true){ const exists = await slugExists(finalSlug) - if(!exists) break; + if(!exists) {break;} - const randomSuffix = Math.random().toString(36).substring(2,6); + const randomSuffix = Math.random().toString(36).slice(2,6); finalSlug = `${cleanSlug}-${randomSuffix}` } return finalSlug; diff --git a/apps/backend/src/utils/validators.ts b/apps/backend/src/utils/validators.ts index bd41bef2..d2f11579 100644 --- a/apps/backend/src/utils/validators.ts +++ b/apps/backend/src/utils/validators.ts @@ -1,5 +1,5 @@ -import { z } from 'zod'; import { getPlatform } from '@devcard/shared'; +import { z } from 'zod'; export const updateProfileSchema = z.object({ displayName: z.string().min(1).max(100).optional(), diff --git a/apps/mobile/src/screens/DevCardViewScreen.tsx b/apps/mobile/src/screens/DevCardViewScreen.tsx index 3698c862..95496d95 100644 --- a/apps/mobile/src/screens/DevCardViewScreen.tsx +++ b/apps/mobile/src/screens/DevCardViewScreen.tsx @@ -16,7 +16,8 @@ import { COLORS, SPACING, FONT_SIZE, BORDER_RADIUS, SHADOWS } from '../theme/tok import { Skeleton } from '../components/Skeleton'; import { EmptyState } from '../components/EmptyState'; import Avatar from '../components/Avatar'; -import { PLATFORMS, getProfileUrl, getWebViewUrl } from '@devcard/shared'; +import { PLATFORMS, getProfileUrl, getWebViewUrl, resolveDeepLink } from '@devcard/shared'; +import type { ResolvedLink } from '@devcard/shared'; import { get, post, del } from '../services/api'; import { useAuth } from '../context/AuthContext'; import { useContacts } from '../hooks/useContacts'; @@ -167,19 +168,20 @@ export default function DevCardViewScreen({ navigation, route }: Props) { break; case 'webview': - setFollowStates(prev => ({ ...prev, [link.id]: 'loading' })); - try { - const data = await post(`/api/follow/${link.platform}/${link.username}`, undefined, token); - setFollowStates(prev => ({ ...prev, [link.id]: 'idle' })); - if (data?.strategy === 'webview') { - handleWebViewConnect(link, data.url); - } else { - setFollowStates(prev => ({ ...prev, [link.id]: 'success' })); - } - } catch { - setFollowStates(prev => ({ ...prev, [link.id]: 'idle' })); - handleWebViewConnect(link); + setFollowStates(prev => ({ ...prev, [link.id]: 'loading' })); + try { + const data = await post(`/api/follow/${link.platform}/${link.username}`, undefined, token); + setFollowStates(prev => ({ ...prev, [link.id]: 'idle' })); + if (data?.strategy === 'webview') { + handleWebViewConnect(link, data.url); + } else { + setFollowStates(prev => ({ ...prev, [link.id]: 'success' })); } + } catch { + setFollowStates(prev => ({ ...prev, [link.id]: 'idle' })); + const resolved = resolveDeepLink(link.platform, link.username, { isMobile: true }); + await executeResolvedLinkChain(resolved, link); + } break; case 'copy': @@ -189,14 +191,47 @@ export default function DevCardViewScreen({ navigation, route }: Props) { break; case 'link': - default: - const url = link.url || getProfileUrl(link.platform, link.username); - if (url) { - Linking.openURL(url).catch(() => - Alert.alert('Error', 'Could not open link') - ); - } + default: { + const resolved = resolveDeepLink(link.platform, link.username, { isMobile: true }); + await executeResolvedLinkChain(resolved, link); break; + } + } + }; + + const executeResolvedLinkChain = async (resolved: ResolvedLink, link: PlatformLink) => { + const tryOpenLink = async (node: ResolvedLink): Promise => { + switch (node.strategy) { + case 'native-deeplink': + case 'universal-link': + case 'web-url': + if (node.url) { + try { + const supported = await Linking.canOpenURL(node.url); + if (supported) { + await Linking.openURL(node.url); + return true; + } + } catch (err) { + console.warn(`Failed to open URL ${node.url}:`, err); + } + } + break; + + case 'webview': + handleWebViewConnect(link, node.url); + return true; + } + + if (node.fallback) { + return await tryOpenLink(node.fallback); + } + return false; + }; + + const success = await tryOpenLink(resolved); + if (!success) { + Alert.alert('Error', 'Could not open connection link'); } }; @@ -210,13 +245,8 @@ export default function DevCardViewScreen({ navigation, route }: Props) { const msg = (err && err.message) || ''; if (msg.includes('requiresAuth')) { setFollowStates(prev => ({ ...prev, [link.id]: 'idle' })); - const webViewUrl = getWebViewUrl(link.platform, link.username); - if (webViewUrl) { - handleWebViewConnect(link); - } else { - const profileUrl = link.url || getProfileUrl(link.platform, link.username); - if (profileUrl) Linking.openURL(profileUrl).catch(() => Alert.alert('Error', `Could not open ${link.platform} profile`)); - } + const resolved = resolveDeepLink(link.platform, link.username, { isMobile: true }); + await executeResolvedLinkChain(resolved, link); } else { setFollowStates(prev => ({ ...prev, [link.id]: 'error' })); } @@ -322,9 +352,13 @@ export default function DevCardViewScreen({ navigation, route }: Props) { return ( - 😕 + 😕 User not found - navigation.goBack()}> + navigation.goBack()} + accessibilityLabel="Go back to the previous screen" + accessibilityRole="button" + > Go Back @@ -337,8 +371,14 @@ export default function DevCardViewScreen({ navigation, route }: Props) { {/* Close Button */} - navigation.goBack()}> - + navigation.goBack()} + accessibilityLabel="Close" + accessibilityRole="button" + accessibilityHint="Returns to the previous screen" + > + {/* Save Contact Button */} @@ -420,9 +460,26 @@ export default function DevCardViewScreen({ navigation, route }: Props) { const state = followStates[link.id] || 'idle'; const btnColor = getButtonColor(link, state); const isDone = state === 'success'; - const tileIconDynamic = isDone - ? { backgroundColor: 'rgba(34,197,94,0.12)', borderColor: COLORS.success } - : { backgroundColor: (platform?.color || COLORS.primary) + '22', borderColor: (platform?.color || COLORS.primary) + '66' }; + const tileIconDynamic = { + backgroundColor: isDone + ? 'rgba(34,197,94,0.12)' + : (platform?.color || COLORS.primary) + '22', + borderColor: isDone + ? COLORS.success + : (platform?.color || COLORS.primary) + '66', + }; + const actionLabel = getButtonLabel(link); + // Build a clear, human-readable label for screen readers + const a11yLabel = isDone + ? `${platform?.name || link.platform} — connected as ${link.username}` + : `${actionLabel} ${platform?.name || link.platform} — ${link.username}`; + const a11yHint = isDone + ? 'Long press to reset connection status' + : platform?.followStrategy === 'webview' + ? 'Opens an in-app browser to connect' + : platform?.followStrategy === 'copy' + ? 'Copies the username to your clipboard' + : 'Opens the profile in your browser'; return ( + disabled={state === 'loading'} + accessibilityLabel={a11yLabel} + accessibilityRole="button" + accessibilityHint={a11yHint} + accessibilityState={{ disabled: state === 'loading', selected: isDone }} + > {/* Icon */} @@ -539,7 +601,7 @@ const styles = StyleSheet.create({ }, brandRow: { flexDirection: 'row', alignItems: 'center', gap: 7 }, miniChip: { width: 28, height: 18, borderRadius: 4, opacity: 0.7 }, - brandText: { color: 'rgba(255,255,255,0.45)', fontSize: 9, fontWeight: '800', letterSpacing: 2.5 }, + brandText: { color: 'rgba(255,255,255,0.45)', fontSize: FONT_SIZE.nano + 1, fontWeight: '800', letterSpacing: 2.5 }, cardMid: { flexDirection: 'row', alignItems: 'center', gap: SPACING.md }, avatarRing: { borderRadius: 38, @@ -556,18 +618,18 @@ const styles = StyleSheet.create({ profileRole: { fontSize: 11, color: 'rgba(255,255,255,0.55)', fontWeight: '500', lineHeight: 15, }, - pronouns: { fontSize: 10, color: COLORS.textMuted, fontStyle: 'italic' }, + pronouns: { fontSize: FONT_SIZE.micro, color: COLORS.textMuted, fontStyle: 'italic' }, cardBottom: { gap: SPACING.xs }, cardDivider: { height: 1, backgroundColor: 'rgba(255,255,255,0.06)', marginBottom: 2, }, - bioText: { fontSize: 10.5, color: 'rgba(255,255,255,0.38)', lineHeight: 15 }, + bioText: { fontSize: FONT_SIZE.micro + 0.5, color: 'rgba(255,255,255,0.38)', lineHeight: 15 }, cardBadge: { alignSelf: 'flex-start', paddingHorizontal: 8, paddingVertical: 3, borderRadius: 4, borderWidth: 1, }, - badgeText: { fontSize: 8, fontWeight: '900', letterSpacing: 1.5 }, + badgeText: { fontSize: FONT_SIZE.nano, fontWeight: '900', letterSpacing: 1.5 }, // ─── Tiles ─── tilesSection: { gap: SPACING.sm }, diff --git a/apps/mobile/src/screens/EventsScreen.tsx b/apps/mobile/src/screens/EventsScreen.tsx index c4dbf7bf..c45da697 100644 --- a/apps/mobile/src/screens/EventsScreen.tsx +++ b/apps/mobile/src/screens/EventsScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState } from 'react'; import { View, Text, StyleSheet, TextInput, TouchableOpacity, StatusBar, Alert, diff --git a/apps/mobile/src/screens/ScanScreen.tsx b/apps/mobile/src/screens/ScanScreen.tsx index 7ab207f2..0bbfaee1 100644 --- a/apps/mobile/src/screens/ScanScreen.tsx +++ b/apps/mobile/src/screens/ScanScreen.tsx @@ -104,7 +104,7 @@ export default function ScanScreen({ navigation }: Props) { title: 'My DevCard QR', url: uri, }); - } catch (err) { + } catch { Alert.alert('Error', 'Failed to save QR code'); } } diff --git a/apps/mobile/src/screens/TeamDetailScreen.tsx b/apps/mobile/src/screens/TeamDetailScreen.tsx index 9503bb72..ceb88925 100644 --- a/apps/mobile/src/screens/TeamDetailScreen.tsx +++ b/apps/mobile/src/screens/TeamDetailScreen.tsx @@ -4,7 +4,6 @@ import { StatusBar, Alert, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; import Avatar from '../components/Avatar'; import { LoadingPlaceholder } from '../components/LoadingPlaceholder'; import { EmptyState } from '../components/EmptyState'; diff --git a/apps/mobile/src/screens/TeamsScreen.tsx b/apps/mobile/src/screens/TeamsScreen.tsx index c64e047e..71bb527f 100644 --- a/apps/mobile/src/screens/TeamsScreen.tsx +++ b/apps/mobile/src/screens/TeamsScreen.tsx @@ -1,6 +1,6 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState } from 'react'; import { - View, Text, StyleSheet, FlatList, TouchableOpacity, + View, Text, StyleSheet, TouchableOpacity, TextInput, StatusBar, Alert, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; diff --git a/apps/web/package.json b/apps/web/package.json index 8df03ce6..50615247 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -28,4 +28,4 @@ "typescript-eslint": "^8.59.2", "vite": "^8.0.12" } -} \ No newline at end of file +} diff --git a/apps/web/src/lib/theme.tsx b/apps/web/src/lib/theme.tsx index 7beda8bd..276d0c78 100644 --- a/apps/web/src/lib/theme.tsx +++ b/apps/web/src/lib/theme.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react-refresh/only-export-components */ import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; type Theme = 'light' | 'dark'; diff --git a/apps/web/src/pages/CardPage.tsx b/apps/web/src/pages/CardPage.tsx index 690ce574..2267fec1 100644 --- a/apps/web/src/pages/CardPage.tsx +++ b/apps/web/src/pages/CardPage.tsx @@ -24,6 +24,7 @@ export default function CardPage() { useEffect(() => { if (!id) return; + // eslint-disable-next-line react-hooks/set-state-in-effect setLoading(true); apiFetch(`/api/u/card/${id}`) .then((data) => { diff --git a/apps/web/src/pages/ProfilePage.tsx b/apps/web/src/pages/ProfilePage.tsx index 94a84f54..871119fd 100644 --- a/apps/web/src/pages/ProfilePage.tsx +++ b/apps/web/src/pages/ProfilePage.tsx @@ -23,11 +23,13 @@ export default function ProfilePage() { const [copyStatus, setCopyStatus] = useState<'success' | 'error'>('success'); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect setMounted(true); }, []); useEffect(() => { if (!username) return; + // eslint-disable-next-line react-hooks/set-state-in-effect setLoading(true); apiFetch(`/api/u/${username}?source=web`) .then((data) => { diff --git a/packages/shared/src/__tests__/deepLinks.test.ts b/packages/shared/src/__tests__/deepLinks.test.ts new file mode 100644 index 00000000..7a7a4014 --- /dev/null +++ b/packages/shared/src/__tests__/deepLinks.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest'; +import { resolveDeepLink } from '../deepLinks'; + +describe('Deep Link Resolver', () => { + describe('LinkedIn resolution (supports all strategies)', () => { + it('returns native deep link as first strategy when on mobile and hasApp is true', () => { + const link = resolveDeepLink('linkedin', 'john-doe', { isMobile: true, hasApp: true }); + expect(link.strategy).toBe('native-deeplink'); + expect(link.url).toBe('linkedin://profile?id=john-doe'); + + // Fallback chain verification + expect(link.fallback).toBeDefined(); + expect(link.fallback!.strategy).toBe('universal-link'); + expect(link.fallback!.url).toBe('https://www.linkedin.com/in/john-doe'); + + expect(link.fallback!.fallback).toBeDefined(); + expect(link.fallback!.fallback!.strategy).toBe('webview'); + expect(link.fallback!.fallback!.url).toBe('https://www.linkedin.com/in/john-doe'); + + expect(link.fallback!.fallback!.fallback).toBeDefined(); + expect(link.fallback!.fallback!.fallback!.strategy).toBe('web-url'); + expect(link.fallback!.fallback!.fallback!.url).toBe('https://www.linkedin.com/in/john-doe'); + }); + + it('omits native deep link when hasApp is false', () => { + const link = resolveDeepLink('linkedin', 'john-doe', { isMobile: true, hasApp: false }); + expect(link.strategy).toBe('universal-link'); + expect(link.url).toBe('https://www.linkedin.com/in/john-doe'); + + expect(link.fallback).toBeDefined(); + expect(link.fallback!.strategy).toBe('webview'); + }); + + it('includes native deep link as first strategy when hasApp is undefined (default guess)', () => { + const link = resolveDeepLink('linkedin', 'john-doe', { isMobile: true }); + expect(link.strategy).toBe('native-deeplink'); + }); + + it('resolves desktop context with webview as first choice', () => { + const link = resolveDeepLink('linkedin', 'john-doe', { isMobile: false }); + expect(link.strategy).toBe('webview'); + expect(link.url).toBe('https://www.linkedin.com/in/john-doe'); + + expect(link.fallback).toBeDefined(); + expect(link.fallback!.strategy).toBe('web-url'); + expect(link.fallback!.url).toBe('https://www.linkedin.com/in/john-doe'); + }); + }); + + describe('Twitter / X resolution', () => { + it('returns native deep link and replaces username in pattern', () => { + const link = resolveDeepLink('twitter', 'elonmusk', { isMobile: true, hasApp: true }); + expect(link.strategy).toBe('native-deeplink'); + expect(link.url).toBe('twitter://user?screen_name=elonmusk'); + + expect(link.fallback!.strategy).toBe('universal-link'); + expect(link.fallback!.url).toBe('https://x.com/elonmusk'); + }); + }); + + describe('Telegram resolution (custom native protocols)', () => { + it('resolves tg:// scheme correctly', () => { + const link = resolveDeepLink('telegram', 'durov', { isMobile: true }); + expect(link.strategy).toBe('native-deeplink'); + expect(link.url).toBe('tg://resolve?domain=durov'); + + expect(link.fallback!.strategy).toBe('universal-link'); + expect(link.fallback!.url).toBe('https://t.me/durov'); + }); + }); + + describe('GitHub resolution (standard web profile, no deep link/webview)', () => { + it('resolves directly to web-url fallback', () => { + const link = resolveDeepLink('github', 'octocat', { isMobile: true }); + expect(link.strategy).toBe('web-url'); + expect(link.url).toBe('https://github.com/octocat'); + expect(link.fallback).toBeUndefined(); + }); + + it('resolves directly to web-url on desktop', () => { + const link = resolveDeepLink('github', 'octocat', { isMobile: false }); + expect(link.strategy).toBe('web-url'); + expect(link.url).toBe('https://github.com/octocat'); + expect(link.fallback).toBeUndefined(); + }); + }); + + describe('Full URL platforms (portfolio / custom)', () => { + it('uses the username string directly as the URL without pattern formatting', () => { + const link = resolveDeepLink('portfolio', 'https://john.dev', { isMobile: true }); + expect(link.strategy).toBe('web-url'); + expect(link.url).toBe('https://john.dev'); + }); + }); + + describe('Unknown platforms', () => { + it('throws error for unregistered platform ID', () => { + expect(() => resolveDeepLink('myspace', 'user')).toThrowError('Unknown platform'); + }); + }); +}); diff --git a/packages/shared/src/deepLinks.ts b/packages/shared/src/deepLinks.ts new file mode 100644 index 00000000..7c5c64da --- /dev/null +++ b/packages/shared/src/deepLinks.ts @@ -0,0 +1,102 @@ +import { getPlatform } from './platforms'; + +export type LinkStrategy = 'native-deeplink' | 'universal-link' | 'web-url' | 'webview'; + +export type ResolvedLink = { + strategy: LinkStrategy; + url: string; + fallback?: ResolvedLink; +}; + +export function resolveDeepLink( + platformId: string, + username: string, + context: { hasApp?: boolean; isMobile?: boolean } = {} +): ResolvedLink { + const platform = getPlatform(platformId); + if (!platform) { + throw new Error(`Unknown platform: ${platformId}`); + } + + const { isMobile = false, hasApp } = context; + + // Helper to replace {username} in patterns + const buildUrl = (pattern: string | null): string => { + if (!pattern) return ''; + if (!pattern.includes('{username}')) { + return pattern === '{username}' ? username : pattern; + } + return pattern.replace(/{username}/g, username); + }; + + const chain: ResolvedLink[] = []; + + if (isMobile) { + // 1. Native Deep Link + if (platform.nativeScheme && platform.deepLinkPattern) { + const nativeUrl = buildUrl(platform.deepLinkPattern); + if (hasApp === true || hasApp === undefined) { + chain.push({ + strategy: 'native-deeplink', + url: nativeUrl, + }); + } + } + + // 2. Universal Link + if (platform.universalLink) { + chain.push({ + strategy: 'universal-link', + url: buildUrl(platform.universalLink), + }); + } + + // 3. WebView Fallback + if (platform.webViewFallback && platform.webViewUrlPattern) { + chain.push({ + strategy: 'webview', + url: buildUrl(platform.webViewUrlPattern), + }); + } + + // 4. Web URL Fallback + if (platform.urlPattern) { + chain.push({ + strategy: 'web-url', + url: buildUrl(platform.urlPattern), + }); + } + } else { + // Desktop context + // 1. WebView + if (platform.webViewFallback && platform.webViewUrlPattern) { + chain.push({ + strategy: 'webview', + url: buildUrl(platform.webViewUrlPattern), + }); + } + + // 2. Web URL + if (platform.urlPattern) { + chain.push({ + strategy: 'web-url', + url: buildUrl(platform.urlPattern), + }); + } + } + + if (chain.length === 0) { + // Fallback just in case + return { + strategy: 'web-url', + url: username, + }; + } + + // Link the chain together + for (let i = 0; i < chain.length - 1; i++) { + chain[i].fallback = chain[i + 1]; + } + + return chain[0]; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 409d3e76..1c29b433 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,4 @@ export * from './platforms'; export * from './types'; -export * from './cards'; \ No newline at end of file +export * from './cards'; +export * from './deepLinks'; \ No newline at end of file diff --git a/packages/shared/src/platforms.ts b/packages/shared/src/platforms.ts index 81c81ab4..916fc87e 100644 --- a/packages/shared/src/platforms.ts +++ b/packages/shared/src/platforms.ts @@ -29,6 +29,12 @@ export interface PlatformDef { usesFullUrl: boolean; /** Regex pattern to validate usernames */ validationRegex?: RegExp; + /** Native protocol scheme (e.g. 'linkedin://') */ + nativeScheme: string | null; + /** Universal Link URL pattern */ + universalLink: string | null; + /** True if platform requires webview fallback for follow actions */ + webViewFallback: boolean; } // ─── Platform Registry ─── @@ -47,6 +53,9 @@ export const PLATFORMS: Record = { usernamePlaceholder: 'e.g. octocat', usesFullUrl: false, validationRegex: /^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$/, + nativeScheme: null, + universalLink: null, + webViewFallback: false, }, linkedin: { id: 'linkedin', @@ -61,6 +70,9 @@ export const PLATFORMS: Record = { usernamePlaceholder: 'e.g. johndoe', usesFullUrl: false, validationRegex: /^[a-zA-Z0-9-]{3,100}$/, + nativeScheme: 'linkedin://', + universalLink: 'https://www.linkedin.com/in/{username}', + webViewFallback: true, }, twitter: { id: 'twitter', @@ -75,6 +87,9 @@ export const PLATFORMS: Record = { usernamePlaceholder: 'e.g. elonmusk', usesFullUrl: false, validationRegex: /^[A-Za-z0-9_]{1,15}$/, + nativeScheme: 'twitter://', + universalLink: 'https://x.com/{username}', + webViewFallback: true, }, gitlab: { id: 'gitlab', @@ -88,6 +103,9 @@ export const PLATFORMS: Record = { oauthScopes: [], usernamePlaceholder: 'e.g. gitlab-user', usesFullUrl: false, + nativeScheme: null, + universalLink: 'https://gitlab.com/{username}', + webViewFallback: false, }, devfolio: { id: 'devfolio', @@ -101,6 +119,9 @@ export const PLATFORMS: Record = { oauthScopes: [], usernamePlaceholder: 'e.g. hacker123', usesFullUrl: false, + nativeScheme: null, + universalLink: 'https://devfolio.co/@{username}', + webViewFallback: false, }, npm: { id: 'npm', @@ -114,6 +135,9 @@ export const PLATFORMS: Record = { oauthScopes: [], usernamePlaceholder: 'e.g. sindresorhus', usesFullUrl: false, + nativeScheme: null, + universalLink: 'https://www.npmjs.com/~{username}', + webViewFallback: false, }, devto: { id: 'devto', @@ -127,6 +151,9 @@ export const PLATFORMS: Record = { oauthScopes: [], usernamePlaceholder: 'e.g. ben', usesFullUrl: false, + nativeScheme: null, + universalLink: 'https://dev.to/{username}', + webViewFallback: false, }, hashnode: { id: 'hashnode', @@ -140,6 +167,9 @@ export const PLATFORMS: Record = { oauthScopes: [], usernamePlaceholder: 'e.g. writer', usesFullUrl: false, + nativeScheme: null, + universalLink: 'https://hashnode.com/@{username}', + webViewFallback: false, }, medium: { id: 'medium', @@ -153,6 +183,9 @@ export const PLATFORMS: Record = { oauthScopes: [], usernamePlaceholder: 'e.g. writer', usesFullUrl: false, + nativeScheme: null, + universalLink: 'https://medium.com/@{username}', + webViewFallback: false, }, leetcode: { id: 'leetcode', @@ -166,6 +199,9 @@ export const PLATFORMS: Record = { oauthScopes: [], usernamePlaceholder: 'e.g. coder', usesFullUrl: false, + nativeScheme: null, + universalLink: 'https://leetcode.com/u/{username}', + webViewFallback: false, }, hackerrank: { id: 'hackerrank', @@ -179,6 +215,9 @@ export const PLATFORMS: Record = { oauthScopes: [], usernamePlaceholder: 'e.g. hacker', usesFullUrl: false, + nativeScheme: null, + universalLink: 'https://www.hackerrank.com/profile/{username}', + webViewFallback: false, }, stackoverflow: { id: 'stackoverflow', @@ -192,6 +231,9 @@ export const PLATFORMS: Record = { oauthScopes: [], usernamePlaceholder: 'e.g. 1234/username', usesFullUrl: false, + nativeScheme: null, + universalLink: 'https://stackoverflow.com/users/{username}', + webViewFallback: false, }, discord: { id: 'discord', @@ -205,6 +247,9 @@ export const PLATFORMS: Record = { oauthScopes: [], usernamePlaceholder: 'e.g. user#1234', usesFullUrl: false, + nativeScheme: null, + universalLink: null, + webViewFallback: false, }, telegram: { id: 'telegram', @@ -218,6 +263,9 @@ export const PLATFORMS: Record = { oauthScopes: [], usernamePlaceholder: 'e.g. durov', usesFullUrl: false, + nativeScheme: 'tg://', + universalLink: 'https://t.me/{username}', + webViewFallback: false, }, email: { id: 'email', @@ -231,6 +279,9 @@ export const PLATFORMS: Record = { oauthScopes: [], usernamePlaceholder: 'e.g. hello@example.com', usesFullUrl: true, + nativeScheme: 'mailto:', + universalLink: null, + webViewFallback: false, }, portfolio: { id: 'portfolio', @@ -244,6 +295,9 @@ export const PLATFORMS: Record = { oauthScopes: [], usernamePlaceholder: 'e.g. https://mysite.dev', usesFullUrl: true, + nativeScheme: null, + universalLink: null, + webViewFallback: false, }, custom: { id: 'custom', @@ -257,6 +311,9 @@ export const PLATFORMS: Record = { oauthScopes: [], usernamePlaceholder: 'e.g. https://example.com/profile', usesFullUrl: true, + nativeScheme: null, + universalLink: null, + webViewFallback: false, }, };