diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bdc73b20..88c860ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,116 +1,192 @@ # Contributing to DevCard -

- - Discord Server - -

+Thank you for your interest in contributing to **DevCard**! DevCard is an open-source developer profile exchange platform that aggregates your developer profiles into a single shareable QR code. -**Join the community** — ask questions, get help, discuss ideas, and meet other contributors on our [Discord server](https://discord.gg/QueQN83wn). +By contributing, you help make networking easier and more accessible for developers around the world. Please take a moment to review this guide before getting started. -## Development Setup +--- -### Prerequisites +## Table of Contents +1. [Project Overview](#project-overview) +2. [Prerequisites](#prerequisites) +3. [Local Setup](#local-setup) +4. [Branch Naming Conventions](#branch-naming-conventions) +5. [Pull Request Process & Checklist](#pull-request-process--checklist) +6. [Issue Labels Guide](#issue-labels-guide) +7. [Coding Standards](#coding-standards) -- **Node.js** >= 20 -- **npm** >= 10 (bundled with Node.js) -- **Docker** & Docker Compose -- **React Native** dev environment — follow the [official setup guide](https://reactnative.dev/docs/environment-setup) +--- -### Getting Started +## Project Overview -```bash -# 1. Fork and clone the repo -git clone https://github.com/Dev-Card/DevCard.git -cd devcard - -# 2. Install dependencies -npm install # root (orchestrator) -npm --prefix packages/shared install # shared types/utils -npm --prefix apps/backend install # backend API -npm --prefix apps/web install # web app -npm --prefix apps/mobile install # mobile app (if working on mobile) - -# 3. Start PostgreSQL + Redis -docker compose up -d +DevCard is structured as a monorepo containing the web frontend, mobile frontend, backend API, and a shared packages library. -# 4. Configure environment -cp .env.example .env -# Edit .env with your OAuth credentials +```text +devcard/ +├── apps/ +│ ├── backend/ # Fastify API (TypeScript, Prisma ORM, Vitest) +│ ├── mobile/ # React Native mobile app (Bare Workflow, Jest) +│ └── web/ # React + Vite web app (TypeScript, ESLint) +├── packages/ +│ └── shared/ # Shared types, platform registry, and utility functions +├── docker/ # Docker files and configurations +├── docker-compose.yml # Runs PostgreSQL and Redis services +└── package.json # Root orchestrator (npm scripts to run workspace tasks) +``` -# 5. Run database migrations and seed -npm run db:migrate -npm run db:seed +--- -# 6. Start development -npm run dev:backend # Backend API on :3000 -npm run dev:mobile # React Native app -``` +## Prerequisites + +To run DevCard locally, you will need the following installed: + +* **Node.js**: `v20.x` or `v22.x` (Long Term Support recommended) +* **npm**: `v10.x` or higher (usually bundled with Node.js) +* **Docker & Docker Compose**: Used to run PostgreSQL 16 and Redis 7 databases locally. +* **React Native / Mobile Environment**: + * **React Native CLI** (Bare workflow environment setup) — follow the [official React Native setup guide](https://reactnative.dev/docs/environment-setup) for your OS (Android Studio / Xcode). + * **Expo CLI** (if doing secondary Expo testing or building). Note that the primary mobile app in `apps/mobile` is a bare React Native project. +* **Backend Runtime & Tools**: + * **PostgreSQL**: (Provided via Docker) + * **Redis**: (Provided via Docker) + +--- -### Running Tests +## Local Setup + +Follow these step-by-step instructions to get the project running on your local machine: + +### 1. Clone the Repository +Fork the repository on GitHub, then clone your fork: +```bash +git clone https://github.com/YOUR-USERNAME/DevCard.git +cd DevCard +``` -This project uses `npm` to run tests across different parts of the codebase. +### 2. Install Dependencies +Install all package dependencies from the root directory. This will install dependencies for all monorepo workspaces: +```bash +npm install +``` -#### Run all tests -To execute backend tests: +### 3. Start Database and Cache Services +Run Docker Compose to start PostgreSQL and Redis in the background: ```bash -npm run test +docker compose up -d ``` -#### apps/backend -The backend uses Vitest: +### 4. Configure Environment Variables +Copy the template `.env.example` in the root (or `apps/backend`) to `.env` inside `apps/backend/`: ```bash -npm --prefix apps/backend run test -npm --prefix apps/backend run test:watch +cp .env.example apps/backend/.env ``` -#### apps/mobile -The mobile app uses Jest: +Open `apps/backend/.env` and generate the required secure secrets: +* **JWT_SECRET**: Generate using: + ```bash + node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" + ``` +* **ENCRYPTION_KEY**: Generate using: + ```bash + node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" + ``` +Paste these values into `apps/backend/.env`. + +### 5. Run Database Migrations and Seed +Initialize your PostgreSQL database schemas and seed it with dummy developer profiles: ```bash -npm --prefix apps/mobile run test +# Run migrations +npm run db:migrate + +# Seed sample database data +npm run db:seed ``` -#### apps/web -Currently, the web app does not define a test script. -#### packages/shared -The shared package does not include test scripts. It only provides linting and type checking. +### 6. Run the Applications + +You can run individual parts of the project from the root directory using the following orchestrator scripts: + +* **Run Backend API**: + ```bash + npm run dev:backend + ``` + This starts the Fastify server (usually listening on `http://localhost:3000`). +* **Run Web App**: + ```bash + npm run dev:web + ``` + This starts the Vite-powered React web dashboard. +* **Run Mobile App**: + Make sure your Android Emulator or iOS Simulator is running, then execute: + ```bash + npm run dev:mobile + ``` + And in another terminal window to launch on Android: + ```bash + npm run android + ``` +--- -## Project Structure +## Branch Naming Conventions -``` -devcard/ -├── apps/backend/ # Fastify API (TypeScript) -├── apps/mobile/ # React Native mobile app -├── apps/web/ # SvelteKit web backup -└── packages/shared/ # Shared types, utils, platform registry -``` +We enforce prefix-based branch naming to keep the repository history organized. When creating a branch, use one of the following prefix structures: -## Coding Standards +* `feat/` — For new features or additions (e.g., `feat/add-github-oauth`) +* `fix/` — For bug fixes and patches (e.g., `fix/event-organizer-id`) +* `docs/` — For updates to documentation or guides (e.g., `docs/contributing-guide`) +* `chore/` — For build processes, dependency updates, or tool configurations (e.g., `chore/upgrade-prisma`) -- **TypeScript** for all new code -- **ESLint + Prettier** for formatting (run `npm run lint` before committing) -- **Conventional Commits** for commit messages (`feat:`, `fix:`, `docs:`, `chore:`) -- Write tests for new features and bug fixes +Use hyphens to separate words (kebab-case) and keep names concise. -## Pull Request Process +--- -1. Create a feature branch from `main`: `git checkout -b feat/your-feature` -2. Make your changes with clear, descriptive commits -3. Ensure all tests pass: `npm run test` -4. Ensure linting passes: `npm run lint` -5. Open a PR against `main` with a clear description of the change -6. Wait for review — maintainers will respond within 48 hours +## Pull Request Process & Checklist + +When you are ready to submit your changes, follow this process: + +### 1. PR Checklist +Before opening a Pull Request, please ensure you satisfy the following checklist: +- [ ] Code compiles and builds without errors. +- [ ] Linting passes: Run `npm run lint` from the root. +- [ ] Tests pass: Run `npm run test` (Vitest backend tests) and ensure zero failures. +- [ ] Your branch name matches our [Branch Naming Conventions](#branch-naming-conventions). +- [ ] Your commits use clear descriptions and follow [Conventional Commits](https://www.conventionalcommits.org/) format (e.g., `feat(auth): add GitHub login flow`). +- [ ] You have updated/added tests for any new features or bug fixes. +- [ ] Documentation has been updated if applicable. + +### 2. Submitting the PR +1. Push your branch to your GitHub fork: + ```bash + git push origin branch-name + ``` +2. Navigate to the main [DevCard Repository](https://github.com/Dev-Card/DevCard) and click **New Pull Request**. +3. Choose your fork and branch, write a clear title and description outlining: + * What problem does this PR solve? + * How was it resolved? + * Any testing steps or verification done. +4. Submit the PR and wait for a review from the maintainers. Reviews are usually conducted within 24–48 hours. + +--- -## Reporting Issues +## Issue Labels Guide -- Use GitHub Issues for bug reports and feature requests -- Include reproduction steps for bugs -- Search existing issues before creating a new one +We use specific labels to categorize and track issues. Here is a guide to what they mean: -## Code of Conduct +* `good-first-issue` — Welcoming issues for newcomers or first-time contributors. Usually has clear instructions. +* `bug` — A reproducible issue or error in the codebase. +* `enhancement` — A request for new features, optimizations, or enhancements. +* `documentation` — Work related to writing or updating READMEs, guides, or code docstrings. +* `help wanted` — Extra attention or specific expertise is requested to solve the issue. +* `gssoc24` / `hacktoberfest` — Labels indicating participation in open-source programs like GirlScript Summer of Code or Hacktoberfest. + +--- + +## Coding Standards -Be kind, inclusive, and constructive. We follow the [Contributor Covenant](https://www.contributor-covenant.org/). +* **TypeScript**: Use static typing wherever possible. Avoid using `any` and define proper interfaces/types. +* **Formatting**: We use ESLint and Prettier for code style consistency. Run `npm run lint` or format files directly in your IDE before committing. +* **Migrations**: Do not modify existing Prisma migrations. Create new migrations via `prisma migrate dev` if you modify `schema.prisma`. --- -Thank you for helping make DevCard better! 🎉 +Thank you for contributing to DevCard! 🚀 diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index 832b4eee..73d0cd7c 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -65,29 +65,6 @@ "resolved": "../../packages/shared", "link": true }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1651,6 +1628,7 @@ "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.60.1", "@typescript-eslint/types": "8.60.1", @@ -1800,6 +1778,7 @@ "integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.60.1", @@ -2288,6 +2267,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2496,6 +2476,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3089,6 +3070,7 @@ "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -3243,6 +3225,7 @@ "integrity": "sha512-rM9K8UBHcWKpzQzStn1YRN2T5NvdeIfSVoKu/lKF41znQXHAUcBbYXe5wd6GNjZjTrP7viQ49n1D83x/2gYgIw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@package-json/types": "^0.0.12", "@typescript-eslint/types": "^8.56.0", @@ -4666,6 +4649,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4812,6 +4796,7 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "6.19.3", "@prisma/engines": "6.19.3" @@ -5548,6 +5533,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5672,6 +5658,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/apps/backend/prisma/migrations/20260621223000_add_username_redirects/migration.sql b/apps/backend/prisma/migrations/20260621223000_add_username_redirects/migration.sql new file mode 100644 index 00000000..fa874d84 --- /dev/null +++ b/apps/backend/prisma/migrations/20260621223000_add_username_redirects/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "username_redirects" ( + "id" TEXT NOT NULL, + "old_username" TEXT NOT NULL, + "new_username" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "username_redirects_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "username_redirects_old_username_key" ON "username_redirects"("old_username"); + +-- CreateIndex +CREATE INDEX "username_redirects_old_username_idx" ON "username_redirects"("old_username"); + +-- AddForeignKey +ALTER TABLE "username_redirects" ADD CONSTRAINT "username_redirects_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 64f35016..dc11319c 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -14,37 +14,38 @@ enum Role { } model User { - id String @id @default(uuid()) - email String @unique - username String @unique - displayName String @map("display_name") - bio String? - pronouns String? - role String? - authRole Role @default(USER) - company String? - avatarUrl String? @map("avatar_url") - accentColor String @default("#6366f1") @map("accent_color") - emailVerified Boolean @default(false) @map("email_verified") - phoneNumber String? @unique @map("phone_number") - lastSignInAt DateTime? @map("last_sign_in_at") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - isActive Boolean @default(false) - - identities UserIdentity[] - refreshTokens RefreshToken[] - platformLinks PlatformLink[] - cards Card[] - oauthTokens OAuthToken[] - ownedViews CardView[] @relation("cardOwner") - viewedCards CardView[] @relation("cardViewer") - followLogs FollowLog[] - organizer Event[] - attendedEvents EventAttendee[] - ownedTeams Team[] @relation("TeamOwner") - teamMemberships TeamMember[] @relation("TeamMember") - webhookEndpoints WebhookEndpoint[] + id String @id @default(uuid()) + email String @unique + username String @unique + displayName String @map("display_name") + bio String? + pronouns String? + role String? + authRole Role @default(USER) + company String? + avatarUrl String? @map("avatar_url") + accentColor String @default("#6366f1") @map("accent_color") + emailVerified Boolean @default(false) @map("email_verified") + phoneNumber String? @unique @map("phone_number") + lastSignInAt DateTime? @map("last_sign_in_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + isActive Boolean @default(false) + + identities UserIdentity[] + refreshTokens RefreshToken[] + platformLinks PlatformLink[] + cards Card[] + oauthTokens OAuthToken[] + ownedViews CardView[] @relation("cardOwner") + viewedCards CardView[] @relation("cardViewer") + followLogs FollowLog[] + organizer Event[] + attendedEvents EventAttendee[] + ownedTeams Team[] @relation("TeamOwner") + teamMemberships TeamMember[] @relation("TeamMember") + usernameRedirects UsernameRedirect[] + webhookEndpoints WebhookEndpoint[] @@map("users") } @@ -301,4 +302,17 @@ model TeamMember { @@unique([userId, teamId]) @@index([userId]) @@map("team_members") +} + +model UsernameRedirect { + id String @id @default(uuid()) + oldUsername String @unique @map("old_username") + newUsername String @map("new_username") + userId String @map("user_id") + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([oldUsername]) + @@map("username_redirects") } \ No newline at end of file diff --git a/apps/backend/src/__tests__/analytics.test.ts b/apps/backend/src/__tests__/analytics.test.ts index 4f0d07ae..ff9525c7 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 ──────────────────────────────────────────────────────── @@ -30,11 +31,12 @@ const prismaMock = { followLog: { count: vi.fn(), }, + $queryRaw: vi.fn(), }; // ─── App factory ───────────────────────────────────────────────────────────── -let mockJwtVerify = vi.fn(); +const mockJwtVerify = vi.fn(); async function buildApp(): Promise { const app = Fastify({ @@ -157,22 +159,9 @@ describe( ] ); - prismaMock.cardView.groupBy.mockResolvedValue( - [ - { - viewerId: - 'u1', - viewerIp: - null, - }, - { - viewerId: - 'u2', - viewerIp: - null, - }, - ] - ); + prismaMock.$queryRaw.mockResolvedValue([ + { count: 2n } + ]); const res = await app.inject( diff --git a/apps/backend/src/__tests__/cards.test.ts b/apps/backend/src/__tests__/cards.test.ts index a8d78e9c..ad4f5012 100644 --- a/apps/backend/src/__tests__/cards.test.ts +++ b/apps/backend/src/__tests__/cards.test.ts @@ -262,7 +262,7 @@ describe('PUT /api/cards/:id/update — card metadata', () => { const app = await buildApp(); const res = await app.inject({ method: 'PUT', - url: `/api/cards/${CARD_ID}/update`, + url: `/api/cards/${CARD_ID}`, payload: { title: 'Renamed', visibility: 'UNLISTED', qrEnabled: false }, }); @@ -280,7 +280,7 @@ describe('PUT /api/cards/:id/update — card metadata', () => { const app = await buildApp(); const res = await app.inject({ method: 'PUT', - url: `/api/cards/${CARD_ID}/update`, + url: `/api/cards/${CARD_ID}`, payload: { title: 'Renamed' }, }); @@ -292,7 +292,7 @@ describe('PUT /api/cards/:id/update — card metadata', () => { const app = await buildApp(); const res = await app.inject({ method: 'PUT', - url: `/api/cards/${CARD_ID}/update`, + url: `/api/cards/${CARD_ID}`, payload: {}, }); @@ -308,7 +308,7 @@ describe('PUT /api/cards/:id/update — card metadata', () => { const app = await buildApp(); const res = await app.inject({ method: 'PUT', - url: `/api/cards/${CARD_ID}/update`, + url: `/api/cards/${CARD_ID}`, payload: { title: 'Renamed' }, }); @@ -420,7 +420,7 @@ describe('DELETE /api/cards/:id/delete', () => { mockPrisma.card.delete.mockResolvedValue(mockCard); const app = await buildApp(); - const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}/delete` }); + const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}` }); expect(res.statusCode).toBe(204); expect(mockPrisma.card.delete).toHaveBeenCalledWith({ where: { id: CARD_ID } }); @@ -439,7 +439,7 @@ describe('DELETE /api/cards/:id/delete', () => { mockPrisma.card.delete.mockResolvedValue(mockCard); const app = await buildApp(); - const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}/delete` }); + const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}` }); expect(res.statusCode).toBe(204); expect(mockPrisma.card.update).toHaveBeenCalledWith({ @@ -453,7 +453,7 @@ describe('DELETE /api/cards/:id/delete', () => { mockPrisma.card.findFirst.mockResolvedValue(null); const app = await buildApp(); - const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}/delete` }); + const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}` }); expect(res.statusCode).toBe(404); expect(mockPrisma.card.delete).not.toHaveBeenCalled(); @@ -464,7 +464,7 @@ describe('DELETE /api/cards/:id/delete', () => { mockPrisma.card.count.mockResolvedValue(1); const app = await buildApp(); - const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}/delete` }); + const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}` }); expect(res.statusCode).toBe(400); expect(res.json().error).toBe('Cannot delete the last remaining card. A user must have at least one card.'); @@ -477,7 +477,7 @@ describe('DELETE /api/cards/:id/delete', () => { mockPrisma.card.delete.mockRejectedValue(new Error('Deadlock detected')); const app = await buildApp(); - const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}/delete` }); + const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}` }); expect(res.statusCode).toBe(500); }); 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..150e779e 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,6 +47,7 @@ function makeConnectState(userId: string): string { function buildConnectApp(mockPrisma: Partial) { const app = Fastify({ logger: false }); app.decorate('prisma', mockPrisma as PrismaClient); + // eslint-disable-next-line no-param-reassign app.decorate('authenticate', async (req: any) => { req.user = { id: USER_ID }; }); app.register(connectRoutes, { prefix: '/api/connect' }); return app.ready().then(() => app); @@ -55,6 +58,7 @@ function buildConnectApp(mockPrisma: Partial) { function buildFollowApp(mockPrisma: Partial) { const app = Fastify({ logger: false }); app.decorate('prisma', mockPrisma as PrismaClient); + // eslint-disable-next-line no-param-reassign app.decorate('authenticate', async (req: any) => { req.user = { id: USER_ID }; }); app.register(followRoutes, { prefix: '/api/follow' }); return app.ready().then(() => app); diff --git a/apps/backend/src/__tests__/profiles.test.ts b/apps/backend/src/__tests__/profiles.test.ts index 0633b841..9f3af348 100644 --- a/apps/backend/src/__tests__/profiles.test.ts +++ b/apps/backend/src/__tests__/profiles.test.ts @@ -28,6 +28,16 @@ const mockPrisma = { findFirst: vi.fn(), update: vi.fn(), }, + usernameRedirect: { + create: vi.fn(), + deleteMany: vi.fn(), + }, + $transaction: vi.fn(async (cb: any) => { + if (typeof cb === 'function') { + return cb(mockPrisma); + } + return cb; + }), }; async function buildApp():Promise { 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__/redirects.test.ts b/apps/backend/src/__tests__/redirects.test.ts new file mode 100644 index 00000000..75eaffb2 --- /dev/null +++ b/apps/backend/src/__tests__/redirects.test.ts @@ -0,0 +1,154 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { publicRoutes } from '../routes/public.js'; + +import type { PrismaClient } from '@prisma/client'; + +const mockPrisma = { + usernameRedirect: { + findUnique: vi.fn(), + }, + user: { + findUnique: vi.fn(), + }, + cardView: { + create: vi.fn().mockReturnValue({ catch: vi.fn() }), + }, + followLog: { + findMany: vi.fn().mockResolvedValue([]), + }, +}; + +async function buildApp(): Promise { + const app = Fastify(); + app.decorate('prisma', mockPrisma as unknown as PrismaClient); + app.register(publicRoutes, { prefix: '/api/public' }); + await app.ready(); + return app; +} + +describe('Username Redirects Routing', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('performs a 301 redirect to the new username for recently changed usernames', async () => { + const app = buildApp(); + mockPrisma.usernameRedirect.findUnique.mockImplementation(({ where }: any) => { + if (where.oldUsername === 'olduser') { + return Promise.resolve({ + oldUsername: 'olduser', + newUsername: 'newuser', + createdAt: new Date(), + }); + } + return Promise.resolve(null); + }); + + const appInstance = await app; + const res = await appInstance.inject({ + method: 'GET', + url: '/api/public/olduser', + }); + + expect(res.statusCode).toBe(301); + expect(res.headers.location).toBe('/api/public/newuser'); + }); + + it('does not redirect and returns 404/200 if username is not in redirects', async () => { + const app = buildApp(); + mockPrisma.usernameRedirect.findUnique.mockResolvedValue(null); + mockPrisma.user.findUnique.mockResolvedValue(null); + + const appInstance = await app; + const res = await appInstance.inject({ + method: 'GET', + url: '/api/public/nonexistent', + }); + + expect(res.statusCode).toBe(404); + }); + + it('does not redirect if the redirect is older than 90 days', async () => { + const app = buildApp(); + const ninetyOneDaysAgo = new Date(); + ninetyOneDaysAgo.setDate(ninetyOneDaysAgo.getDate() - 91); + + mockPrisma.usernameRedirect.findUnique.mockResolvedValue({ + oldUsername: 'olduser', + newUsername: 'newuser', + createdAt: ninetyOneDaysAgo, + }); + mockPrisma.user.findUnique.mockResolvedValue(null); + + const appInstance = await app; + const res = await appInstance.inject({ + method: 'GET', + url: '/api/public/olduser', + }); + + expect(res.statusCode).toBe(404); + }); + + it('resolves multi-step redirect chains recursively', async () => { + const app = buildApp(); + mockPrisma.usernameRedirect.findUnique.mockImplementation(({ where }: any) => { + if (where.oldUsername === 'userA') { + return Promise.resolve({ + oldUsername: 'userA', + newUsername: 'userB', + createdAt: new Date(), + }); + } + if (where.oldUsername === 'userB') { + return Promise.resolve({ + oldUsername: 'userB', + newUsername: 'userC', + createdAt: new Date(), + }); + } + return Promise.resolve(null); + }); + + const appInstance = await app; + const res = await appInstance.inject({ + method: 'GET', + url: '/api/public/userA/qr?size=300', + }); + + expect(res.statusCode).toBe(301); + expect(res.headers.location).toBe('/api/public/userC/qr?size=300'); + }); + + it('guards against infinite loops in redirect chains', async () => { + const app = buildApp(); + mockPrisma.usernameRedirect.findUnique.mockImplementation(({ where }: any) => { + if (where.oldUsername === 'userA') { + return Promise.resolve({ + oldUsername: 'userA', + newUsername: 'userB', + createdAt: new Date(), + }); + } + if (where.oldUsername === 'userB') { + return Promise.resolve({ + oldUsername: 'userB', + newUsername: 'userA', + createdAt: new Date(), + }); + } + return Promise.resolve(null); + }); + mockPrisma.user.findUnique.mockResolvedValue(null); + + const appInstance = await app; + const res = await appInstance.inject({ + method: 'GET', + url: '/api/public/userA', + }); + + expect(res.statusCode).toBe(301); + expect(res.headers.location).toBe('/api/public/userB'); + }); +}); diff --git a/apps/backend/src/__tests__/team.test.ts b/apps/backend/src/__tests__/team.test.ts index 0e97bb11..fae23cc3 100644 --- a/apps/backend/src/__tests__/team.test.ts +++ b/apps/backend/src/__tests__/team.test.ts @@ -103,14 +103,16 @@ async function buildApp(): Promise { app.decorateRequest('jwtVerify', function () { return mockJwtVerify(); }); - app.decorate('authenticate', async function (request, reply) { - try { - const payload = await request.jwtVerify(); - if (payload) {request.user = payload as typeof request.user;} - } catch { - return reply.status(401).send({ error: 'Unauthorized' }); - } + + app.decorate('authenticate', async function (request: any, reply: any) { + try { + const user = await request.jwtVerify(); + request.user = user; + } catch (err: any) { + return reply.status(401).send({ error: err.message || 'Unauthorized' }); + } }); + await app.register(teamRoutes); await app.ready(); return app; diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts index ceb9222d..de5ee982 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/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/cards.ts b/apps/backend/src/routes/cards.ts index 96125215..860c9232 100644 --- a/apps/backend/src/routes/cards.ts +++ b/apps/backend/src/routes/cards.ts @@ -107,6 +107,9 @@ export async function cardRoutes(app: FastifyInstance): Promise { return updated } catch (error) { + if (hasErrorCode(error, 'NOT_FOUND')) { + return reply.status(404).send({ error: 'Card not found' }); + } if (hasErrorCode(error, 'OWNERSHIP')) {return reply.status(403).send({ error: 'One or more links do not belong to your account' })} return handleDbError(error, request, reply) } @@ -160,7 +163,7 @@ export async function cardRoutes(app: FastifyInstance): Promise { }); //Add platform-link - app.put('/:id/platform-link', async(request: FastifyRequest<{Params:{id: string}, Body: {platformLinkId: string}}>, reply: FastifyReply) => { + app.put('/:id/platform-link', { preHandler: [(req, reply) => app.authenticate(req, reply)] }, async(request: FastifyRequest<{Params:{id: string}, Body: {platformLinkId: string}}>, reply: FastifyReply) => { const cardId = request.params.id; const userId = request.user.id; const parsed = addPlatformLinkSchema.safeParse(request.body); @@ -200,7 +203,7 @@ export async function cardRoutes(app: FastifyInstance): Promise { }) //Share card - app.post('/:id/share',async(request: FastifyRequest<{Params: {id: string}}>, reply:FastifyReply) => { + app.post('/:id/share', { preHandler: [(req, reply) => app.authenticate(req, reply)] }, async(request: FastifyRequest<{Params: {id: string}}>, reply:FastifyReply) => { const cardId = request.params.id; const userId = request.user.id; @@ -230,8 +233,11 @@ export async function cardRoutes(app: FastifyInstance): Promise { // so source should not be hardcoded to "link". //Get shared card app.get('/share/:slug', async(request: FastifyRequest<{Params: {slug: string}}>, reply: FastifyReply) => { + try { + await request.jwtVerify(); + } catch (_e) {} const paramsSlug = request.params.slug; - const userId = request.user.id + const userId = request.user?.id const ip = hashIp(request.ip); const userAgent = request.headers['user-agent'] ?? 'unknown'; @@ -276,7 +282,7 @@ export async function cardRoutes(app: FastifyInstance): Promise { }) //Generates qr - app.get('/:id/qr', async(request: FastifyRequest<{Params: {id: string}}>, reply:FastifyReply) => { + app.get('/:id/qr', { preHandler: [(req, reply) => app.authenticate(req, reply)] }, async(request: FastifyRequest<{Params: {id: string}}>, reply:FastifyReply) => { const cardId = request.params.id const userId = request.user.id @@ -310,7 +316,7 @@ export async function cardRoutes(app: FastifyInstance): Promise { }) //Get analytics - app.get('/:id/analytics', async(request:FastifyRequest<{Params: {id:string}}>, reply: FastifyReply) => { + app.get('/:id/analytics', { preHandler: [(req, reply) => app.authenticate(req, reply)] }, async(request:FastifyRequest<{Params: {id:string}}>, reply: FastifyReply) => { const cardId = request.params.id const userId = request.user.id diff --git a/apps/backend/src/routes/event.ts b/apps/backend/src/routes/event.ts index e63fe83f..090031d0 100644 --- a/apps/backend/src/routes/event.ts +++ b/apps/backend/src/routes/event.ts @@ -1,23 +1,23 @@ -import {generateUniqueSlug} from '../utils/slug.js' -import { createEventSchema} from '../validations/event.validation.js'; +import { generateUniqueSlug } from '../utils/slug.js'; +import { createEventSchema } from '../validations/event.validation.js'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; type EventDetails = { - id: string; - name: string; - slug: string; - location: string; - description: string | null; - organizerId: string; - organizerUsername: string; - organizerDisplayName: string; - startDate: Date; - endDate: Date; - createdAt: Date; - attendeesCount: number -} + id: string; + name: string; + slug: string; + location: string; + description: string | null; + organizerId: string; + organizerUsername: string; + organizerDisplayName: string; + startDate: Date; + endDate: Date; + createdAt: Date; + attendeesCount: number; +}; type AttendeePublicProfile = { id: string; @@ -28,17 +28,16 @@ type AttendeePublicProfile = { company: string | null; avatarUrl: string | null; accentColor: string; -} - +}; type PaginatedAttendeesResponse = { attendees: AttendeePublicProfile[]; pagination: { page: number; limit: number; - total: number; + total: number; }; -} +}; type EventWithAttendees = { _count: { @@ -56,219 +55,275 @@ type EventWithAttendees = { accentColor: string; }; }[]; -} +}; -export async function eventRoutes(app:FastifyInstance): Promise { - app.post<{Body: { name: string; description?: string; startDate: string; location: string; endDate: string; isPublic?: boolean; }}>('/', { preHandler: [(req, reply) => app.authenticate(req, reply)] }, async (request, reply) => { - const userId = request.user.id; - const parsed = createEventSchema.safeParse(request.body); - if(!parsed.success){ - return reply.status(400).send({error: 'Bad request'}) - } - - const {name, description, startDate, endDate, isPublic ,location} = parsed.data +export async function eventRoutes(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 { + await request.jwtVerify(); + } catch { + reply.status(401).send({ error: 'Unauthorized' }); + } + }], + }, async (request: FastifyRequest<{ + Body: { + name: string; + description?: string; + startDate: string; + location: string; + endDate: string; + isPublic?: boolean; + }; + }>, reply: FastifyReply) => { + const userId = (request.user as any).id; + const parsed = createEventSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ error: 'Bad request' }); + } - const finalSlug = await generateUniqueSlug(name, async(slug) => { - const existing = await app.prisma.event.findUnique({where: {slug}}) - - return !!existing - }) + const { name, description, startDate, endDate, isPublic, location } = parsed.data; - const startDateObj = new Date(startDate); - const endDateObj = new Date(endDate); + const finalSlug = await generateUniqueSlug(name, async (slug) => { + const existing = await app.prisma.event.findUnique({ where: { slug } }); + return !!existing; + }); - try { - const newEvent = await app.prisma.event.create({ - data: { - name, - description, - slug: finalSlug, - location, - startDate: startDateObj, - endDate: endDateObj, - isPublic: isPublic ?? true, - organizerId: userId - } - }) + const startDateObj = new Date(startDate); + const endDateObj = new Date(endDate); - return reply.status(201).send(newEvent); - } catch (_error) { - app.log.error('Failed to create event'); - return reply.status(500).send({error: 'Failed to create event'}) - } - - }) + try { + const newEvent = await app.prisma.event.create({ + data: { + name, + description, + slug: finalSlug, + location, + startDate: startDateObj, + endDate: endDateObj, + isPublic: isPublic ?? true, + organizerId: userId, + }, + }); - //Returns event details and attendees count - app.get('/:slug', async(request: FastifyRequest<{Params: {slug: string}}>, reply: FastifyReply) => { - const paramsSlug = request.params.slug; - const details = await app.prisma.event.findUnique({ - where: { - slug: paramsSlug, - }, - include: { - _count: { - select: { - attendees: true - } - }, - organizer: { - select: { - username: true, - displayName: true - } - } - } - }) - if(!details){ - return reply.status(404).send({error: 'Event not found'}) - } + return reply.status(201).send(newEvent); + } catch { + app.log.error('Failed to create event'); + return reply.status(500).send({ error: 'Failed to create event' }); + } + }); - const response: EventDetails = { - id: details.id, - name: details.name, - slug: details.slug, - description: details.description, - location: details.location, - organizerId: details.organizerId, - organizerUsername: details.organizer.username, - organizerDisplayName: details.organizer.displayName, - startDate: details.startDate, - endDate: details.endDate, - createdAt: details.createdAt, - attendeesCount: details._count.attendees - } - - return response; - }) + // Returns event details and attendees count + app.get('/:slug', async (request: FastifyRequest<{ Params: { slug: string } }>, reply: FastifyReply) => { + const paramsSlug = request.params.slug; + const details = await app.prisma.event.findUnique({ + where: { + slug: paramsSlug, + }, + include: { + _count: { + select: { + attendees: true, + }, + }, + organizer: { + select: { + username: true, + displayName: true, + }, + }, + }, + }); + if (!details) { + return reply.status(404).send({ error: 'Event not found' }); + } - app.post<{ Params: { slug: string } }>('/:slug/join', {preHandler: [(req, reply) => app.authenticate(req, reply)]}, async(request, reply) => { - const userId = request.user.id; - const paramsSlug = request.params.slug; + const response: EventDetails = { + id: details.id, + name: details.name, + slug: details.slug, + description: details.description, + location: details.location, + organizerId: details.organizerId, + organizerUsername: details.organizer.username, + organizerDisplayName: details.organizer.displayName, + startDate: details.startDate, + endDate: details.endDate, + createdAt: details.createdAt, + attendeesCount: details._count.attendees, + }; + + return response; + }); - const event = await app.prisma.event.findUnique({ - where: { - slug: paramsSlug - } - }) + 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; - if(!event){ - return reply.status(404).send({error: 'Event not found'}) - } + const event = await app.prisma.event.findUnique({ + where: { + slug: paramsSlug, + }, + }); - try { - await app.prisma.eventAttendee.create({ - data: { - eventId: event.id, - userId, - joinedAt: new Date() - } - }) + if (!event) { + return reply.status(404).send({ error: 'Event not found' }); + } - return reply.status(201).send({message: 'User joined successfully'}) - } catch (error:any) { - if(error.code === "P2002" ){ - return reply.status(409).send({error: 'Already joined'}) - } - app.log.error((error as Error).message); - return reply.status(500).send({error: 'Failed to join'}) - } + try { + await app.prisma.eventAttendee.create({ + data: { + eventId: event.id, + userId, + joinedAt: new Date(), + }, + }); - }) - app.delete<{Params: {slug: string}}>('/:slug/leave',{preHandler: [(req, reply) => app.authenticate(req, reply)]}, async(request, reply) => { + return reply.status(201).send({ message: 'User joined successfully' }); + } catch (error: any) { + if (error.code === 'P2002') { + return reply.status(409).send({ error: 'Already joined' }); + } + app.log.error((error as Error).message); + return reply.status(500).send({ error: 'Failed to join' }); + } + }); - const userId = request.user.id; - const paramsSlug = request.params.slug; + 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; - const event = await app.prisma.event.findUnique({ - where: { - slug: paramsSlug - } - }) + const event = await app.prisma.event.findUnique({ + where: { + slug: paramsSlug, + }, + }); - if(!event){ - return reply.status(404).send({error: 'Event not found'}) - } + if (!event) { + return reply.status(404).send({ error: 'Event not found' }); + } - try { - await app.prisma.eventAttendee.delete({ - where: { - userId_eventId: { - userId, - eventId: event.id - } - } - }) - return reply.status(204).send() - } catch (error:any) { - if(error.code === 'P2025'){ - return reply.status(404).send({error: 'User not found'}) - } - app.log.error((error as Error).message) - return reply.status(500).send({error: 'Failed to leave'}) - } - }) + try { + await app.prisma.eventAttendee.delete({ + where: { + userId_eventId: { + userId, + eventId: event.id, + }, + }, + }); + return reply.status(204).send({ message: 'User left' }); + } catch (error: any) { + if (error.code === 'P2025') { + return reply.status(404).send({ error: 'User not found' }); + } + app.log.error((error as Error).message); + return reply.status(500).send({ error: 'Failed to leave' }); + } + }); - app.get('/:slug/attendees', async(request: FastifyRequest<{Params: {slug: string}, Querystring: {page?:string; limit?: string}}>, reply: FastifyReply) => { - const paramsSlug = request.params.slug; - const page = Math.max(1, Number(request.query.page) || 1); - const limit = Math.min(50, Number(request.query.limit) || 10); - const skip = (page - 1) * limit - const event = await app.prisma.event.findUnique({ - where: { - slug: paramsSlug - }, - include: { - _count: { - select: { attendees: true } - }, - attendees : { - include: { - user: { - select: { - id: true, - username: true, - displayName:true, - bio: true, - pronouns: true, - company: true, - avatarUrl: true, - accentColor: true - } - } - }, - skip, - take: limit, - orderBy: {joinedAt: 'desc'} - } - }, - })as EventWithAttendees | null; + app.get('/:slug/attendees', async (request: FastifyRequest<{ Params: { slug: string }; Querystring: { page?: string; limit?: string } }>, reply: FastifyReply) => { + const paramsSlug = request.params.slug; + const page = Math.max(1, Number(request.query.page) || 1); + const limit = Math.min(50, Number(request.query.limit) || 10); + const skip = (page - 1) * limit; + const event = await app.prisma.event.findUnique({ + where: { + slug: paramsSlug, + }, + include: { + _count: { + select: { attendees: true }, + }, + attendees: { + include: { + user: { + select: { + id: true, + username: true, + displayName: true, + bio: true, + pronouns: true, + company: true, + avatarUrl: true, + accentColor: true, + }, + }, + }, + skip, + take: limit, + orderBy: { joinedAt: 'desc' }, + }, + }, + }) as EventWithAttendees | null; - if(!event){ - return reply.status(404).send({error: 'Event not found'}) - } + if (!event) { + return reply.status(404).send({ error: 'Event not found' }); + } - - const attendees = event.attendees.map((attendee: EventWithAttendees['attendees'][number]) => ({ - id: attendee.user.id, - username: attendee.user.username, - displayName: attendee.user.displayName, - bio: attendee.user.bio, - pronouns: attendee.user.pronouns, - company: attendee.user.company, - avatarUrl: attendee.user.avatarUrl, - accentColor: attendee.user.accentColor, - })); + const attendees = event.attendees.map((attendee: EventWithAttendees['attendees'][number]) => ({ + id: attendee.user.id, + username: attendee.user.username, + displayName: attendee.user.displayName, + bio: attendee.user.bio, + pronouns: attendee.user.pronouns, + company: attendee.user.company, + avatarUrl: attendee.user.avatarUrl, + accentColor: attendee.user.accentColor, + })); - const response: PaginatedAttendeesResponse = { - attendees, - pagination: { - page, - limit, - total : event._count.attendees, - } - } + const response: PaginatedAttendeesResponse = { + attendees, + pagination: { + page, + limit, + total: event._count.attendees, + }, + }; - return response; - }) + return response; + }); } \ No newline at end of file diff --git a/apps/backend/src/routes/nfc.ts b/apps/backend/src/routes/nfc.ts index 9dcb8088..5cf48f66 100644 --- a/apps/backend/src/routes/nfc.ts +++ b/apps/backend/src/routes/nfc.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import type { FastifyInstance} from 'fastify'; +import type { FastifyInstance } from 'fastify'; type NfcPayloadResponse = { type: 'URI'; @@ -12,7 +12,22 @@ const nfcQuerySchema = z.object({ }); export async function nfcRoutes(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 { + reply.status(401).send({ error: 'Unauthorized' }); + } + }); // GET /api/nfc/payload — returns NDEF URI payload for user's default DevCard URL // GET /api/nfc/payload?card= — returns payload for a specific card @@ -83,10 +98,10 @@ export async function nfcRoutes(app: FastifyInstance): Promise { } } -const safeUsername = encodeURIComponent(username); -const payloadUrl = `${process.env.PUBLIC_APP_URL}/${safeUsername}${ - cardId ? `?card=${encodeURIComponent(cardId)}` : '' -}`; + const safeUsername = encodeURIComponent(username); + const payloadUrl = `${process.env.PUBLIC_APP_URL}/${safeUsername}${ + cardId ? `?card=${encodeURIComponent(cardId)}` : '' + }`; const response: NfcPayloadResponse = { type: 'URI', payload: payloadUrl, diff --git a/apps/backend/src/routes/profiles.ts b/apps/backend/src/routes/profiles.ts index 06b1c2c4..3e3d9b89 100644 --- a/apps/backend/src/routes/profiles.ts +++ b/apps/backend/src/routes/profiles.ts @@ -1,6 +1,4 @@ -import { Prisma } from '@prisma/client'; - -import * as profileService from '../services/profileService.js'; +import * as profileService from '../services/profileService'; import { updateProfileSchema, createLinkSchema, reorderLinksSchema } from '../utils/validators.js'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; @@ -79,7 +77,7 @@ export async function profileRoutes(app: FastifyInstance): Promise { const response = await profileService.updateProfile(app, userId, parsed.data) return response } catch (err: unknown) { - if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + if (err && typeof err === 'object' && 'code' in err && (err as any).code === 'P2002') { return reply.status(409).send({ error: 'Username already taken' }); } app.log.error({ err }, 'DB error in PUT /profiles/me') diff --git a/apps/backend/src/routes/public.ts b/apps/backend/src/routes/public.ts index 568910f3..e48c9fe3 100644 --- a/apps/backend/src/routes/public.ts +++ b/apps/backend/src/routes/public.ts @@ -28,6 +28,52 @@ type CardLinkWithPlatform = Prisma.CardLinkGetPayload<{ }>; export async function publicRoutes(app: FastifyInstance): Promise { + // ─── Username Redirect Hook ─── + app.addHook('preHandler', async (request, reply) => { + const params = request.params as Record | undefined; + if (!params || !params.username) { + return; + } + + const { username } = params; + + if (!app.prisma.usernameRedirect) { + return; + } + + const ninetyDaysAgo = new Date(); + ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90); + + let current = username; + let redirect = await app.prisma.usernameRedirect.findUnique({ + where: { oldUsername: current }, + }); + + const visited = new Set(); + + while (redirect && redirect.createdAt >= ninetyDaysAgo && !visited.has(redirect.newUsername)) { + visited.add(current); + current = redirect.newUsername; + redirect = await app.prisma.usernameRedirect.findUnique({ + where: { oldUsername: current }, + }); + } + + if (current !== username) { + const urlParts = request.url.split('?'); + const path = urlParts[0]; + const query = urlParts[1] ? `?${urlParts[1]}` : ''; + + const pathSegments = path.split('/'); + const index = pathSegments.indexOf(username); + if (index !== -1) { + pathSegments[index] = current; + const newPath = pathSegments.join('/') + query; + return reply.status(301).redirect(newPath); + } + } + }); + // ─── Public Profile ─────────────────────────────────────────────────────── /** * GET /api/u/:username 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 cc197e4e..032ac498 100644 --- a/apps/backend/src/services/cardService.ts +++ b/apps/backend/src/services/cardService.ts @@ -72,7 +72,7 @@ export async function createCard(app: FastifyInstance, userId: string, body: Cre for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const card = (await app.prisma.$transaction( - async (tx: Prisma.TransactionClient) => { + async (tx: any) => { const cardCount = await tx.card.count({ where: { userId } }); return tx.card.create({ @@ -145,7 +145,7 @@ export async function updateCard( //Delete card service export async function deleteCard(app: FastifyInstance, userId: string, id: string): Promise { - return await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { + return await app.prisma.$transaction(async (tx: any) => { const existing = await tx.card.findFirst({ where: { id, userId } }); if (!existing) { throw Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' }); @@ -180,7 +180,7 @@ export async function setDefaultCard(app: FastifyInstance, userId: string, id: s throw Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' }); } - await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { + await app.prisma.$transaction(async (tx: any) => { await tx.card.updateMany({ where: { userId }, data: { isDefault: false } }); await tx.card.update({ where: { id }, data: { isDefault: true } }); }); diff --git a/apps/backend/src/services/profileService.ts b/apps/backend/src/services/profileService.ts index 385709b1..86745045 100644 --- a/apps/backend/src/services/profileService.ts +++ b/apps/backend/src/services/profileService.ts @@ -1,4 +1,4 @@ -import { getProfileUrl } from '@devcard/shared' +import { getProfileUrl } from '@devcard/shared/src/platforms.js' import { getErrorMessage } from '../utils/error.util.js' @@ -31,9 +31,33 @@ export async function updateProfile(app: FastifyInstance, userId: string, data: const currentUser = await app.prisma.user.findUnique({ where: { id: userId }, select: { username: true } }) try { - const response = await app.prisma.user.update({ where: { id: userId }, data, select: { - id: true, email: true, username: true, displayName: true, bio: true, pronouns: true, role: true, company: true, avatarUrl: true, accentColor: true - } }) + const isUsernameChanging = data.username && currentUser && data.username !== currentUser.username; + + const response = await app.prisma.$transaction(async (tx: any) => { + if (isUsernameChanging) { + // Delete any existing redirects where the oldUsername is the new username + await tx.usernameRedirect.deleteMany({ + where: { oldUsername: data.username }, + }); + + // Record the redirect from the old username to the new username + await tx.usernameRedirect.create({ + data: { + oldUsername: currentUser.username, + newUsername: data.username, + userId, + }, + }); + } + + return tx.user.update({ + where: { id: userId }, + data, + select: { + id: true, email: true, username: true, displayName: true, bio: true, pronouns: true, role: true, company: true, avatarUrl: true, accentColor: true + } + }); + }); if (app.redis && currentUser) { app.redis.del(`profile:${currentUser.username}`).catch((err: unknown) => diff --git a/apps/backend/src/services/publicService.ts b/apps/backend/src/services/publicService.ts index 768da373..ee2f4219 100644 --- a/apps/backend/src/services/publicService.ts +++ b/apps/backend/src/services/publicService.ts @@ -55,7 +55,7 @@ export async function getPublicProfile( app.redis.set(cacheKey, JSON.stringify(entry), 'EX', PROFILE_CACHE_TTL).catch((err: unknown) => app.log.warn(`Redis cache write failed for ${cacheKey}: ${getErrorMessage(err)}`)) } - const response = { username: user.username, displayName: user.displayName, bio: user.bio, pronouns: user.pronouns, role: user.role, company: user.company, avatarUrl: user.avatarUrl, accentColor: user.accentColor, links: baseLinks.map((link) => ({ ...link, followed: followedLinkIds.includes(link.id) })) } + const response = { username: user.username, displayName: user.displayName, bio: user.bio, pronouns: user.pronouns, role: user.role, company: user.company, avatarUrl: user.avatarUrl, accentColor: user.accentColor, links: baseLinks.map((link: any) => ({ ...link, followed: followedLinkIds.includes(link.id) })) } return { cached: false, data: response, cacheKey } } diff --git a/apps/backend/src/utils/error.util.ts b/apps/backend/src/utils/error.util.ts index d429f1fb..48a0670e 100644 --- a/apps/backend/src/utils/error.util.ts +++ b/apps/backend/src/utils/error.util.ts @@ -36,19 +36,20 @@ export function handleDbError(error: unknown, request: FastifyRequest, reply: Fa request.log.error(error); if (error instanceof Prisma.PrismaClientKnownRequestError) { + const dbErr = error as Prisma.PrismaClientKnownRequestError; // P2002: Unique constraint failed - if (error.code === 'P2002') { + if (dbErr.code === 'P2002') { return reply.status(409).send({ error: 'Conflict: Record already exists or violates unique constraint' }); } // P2025: Record to update not found - if (error.code === 'P2025') { + if (dbErr.code === 'P2025') { return reply.status(404).send({ error: 'Not Found: Record does not exist' }); } // P2003: Foreign key constraint failed - if (error.code === 'P2003') { + if (dbErr.code === 'P2003') { return reply.status(400).send({ error: 'Constraint failed: Related record not found or invalid' }); } - return reply.status(400).send({ error: `Database error: ${error.message}` }); + return reply.status(400).send({ error: `Database error: ${dbErr.message}` }); } if (error instanceof Prisma.PrismaClientValidationError) { 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/web/src/pages/ProfilePage.tsx b/apps/web/src/pages/ProfilePage.tsx index 94a84f54..7a0b3db9 100644 --- a/apps/web/src/pages/ProfilePage.tsx +++ b/apps/web/src/pages/ProfilePage.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { useParams, Link } from 'react-router-dom'; +import { useParams, Link, useNavigate } from 'react-router-dom'; import { PLATFORMS, getProfileUrl } from '../shared'; import type { PublicProfile } from '../shared'; import { apiFetch } from '../lib/api'; @@ -15,6 +15,7 @@ const platformColors: Record = { export default function ProfilePage() { const { username } = useParams<{ username: string }>(); + const navigate = useNavigate(); const [profile, setProfile] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); @@ -33,6 +34,9 @@ export default function ProfilePage() { .then((data) => { setProfile(data); setError(null); + if (data.username && data.username !== username) { + navigate(`/u/${data.username}`, { replace: true }); + } }) .catch(() => { setProfile(null);