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
-
-
-
-
-
+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(''),
}));
-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);