From 7cc2cb9673df26475259fd9a9873532c0b1f1259 Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 21 Jun 2026 21:59:28 +0530 Subject: [PATCH 1/4] fix(mobile): check camera permission on ios and alert to open settings on denial --- apps/mobile/package-lock.json | 25 ++++++++ apps/mobile/src/screens/ScanScreen.tsx | 79 ++++++++++++++++++++++++-- 2 files changed, 99 insertions(+), 5 deletions(-) diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json index dc953469..b420b81e 100644 --- a/apps/mobile/package-lock.json +++ b/apps/mobile/package-lock.json @@ -103,6 +103,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -2909,6 +2910,7 @@ "integrity": "sha512-441WsVtRe4nGJ9OzA+QMU1+22lA6Q2hRWqqIMKD0wjEMLqcSfOZyu2UL9a/yRpL/dRpyUsU4n7AxqKfTKO/Csg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@react-native-community/cli-clean": "20.1.0", "@react-native-community/cli-config": "20.1.0", @@ -3365,6 +3367,7 @@ "integrity": "sha512-ALPSrM0q2fU+5AXcOXzDKx7rxVKPMvygAZfsTWLdrGRVWIqf/HEfM0R8euQqIKUqmEuQ1TxMWN+px3h6gc4vow==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/js-polyfills": "0.85.3", @@ -3411,6 +3414,7 @@ "resolved": "https://registry.npmjs.org/@react-native/metro-config/-/metro-config-0.85.3.tgz", "integrity": "sha512-sVo6HepUmCcpdfozEf91lA0FjpLNNZYu/Zi9FiYiAQTK8pzATXDVTqhvdxpFrQn435p5eUTSbllvbH/KN+bnyA==", "license": "MIT", + "peer": true, "dependencies": { "@react-native/js-polyfills": "0.85.3", "@react-native/metro-babel-transformer": "0.85.3", @@ -3522,6 +3526,7 @@ "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.2.5.tgz", "integrity": "sha512-01AAUQiiHQAfTabq+ZyU1/ZWq+AbB/J3v0CB0UTJSON6M6cuadWNsbChzrZUdqQvHrXvg96U5i2PQLJzK3+zpg==", "license": "MIT", + "peer": true, "dependencies": { "@react-navigation/core": "^7.17.5", "escape-string-regexp": "^4.0.0", @@ -3722,6 +3727,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz", "integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3732,6 +3738,7 @@ "integrity": "sha512-c6WbyCgWTBgKKMESj/8b4w+zWcZSsCforson7UdXtXMecG3MxCinYi6ihhrHVPyUrVzORsvEzK8zg32z4pK6Sg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/react": "*" } @@ -3784,6 +3791,7 @@ "integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.60.1", @@ -3813,6 +3821,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", @@ -4066,6 +4075,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4755,6 +4765,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -6035,6 +6046,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -8203,6 +8215,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -10318,6 +10331,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10439,6 +10453,7 @@ "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin-prettier.js" }, @@ -10777,6 +10792,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10835,6 +10851,7 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.85.3.tgz", "integrity": "sha512-HN/fGC+3nZVcDNcw7gfbM/DuqZAvI9Mz+/SxuhODaua4JY0BPzhfTzWXRyTR4mRgMHmShTPpH2PYMTxvZrsdZA==", "license": "MIT", + "peer": true, "dependencies": { "@react-native/assets-registry": "0.85.3", "@react-native/codegen": "0.85.3", @@ -10921,6 +10938,7 @@ "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.31.2.tgz", "integrity": "sha512-rw5q74i2AfS7YGYdbxQDhOU7xqgY6WRM1132/CCm3erqjblhECZDZFHIm0tteHoC9ih24wogVBVVzcTBQtZ+5A==", "license": "MIT", + "peer": true, "dependencies": { "@egjs/hammerjs": "^2.0.17", "@types/react-test-renderer": "^19.1.0", @@ -10963,6 +10981,7 @@ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.4.0.tgz", "integrity": "sha512-0XbC1SpF3JZOz5QfmTEx3vt8VkmkTlS05CBIOKEg5q5ZSNlGtlacntlhj5CrfZlN1ciHAeoliJouTC2cLGKbDA==", "license": "MIT", + "peer": true, "dependencies": { "react-native-is-edge-to-edge": "^1.3.1", "semver": "^7.7.3" @@ -10978,6 +10997,7 @@ "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.8.0.tgz", "integrity": "sha512-t+ZsAVzY/wWzzx34vqGbo3/as9EEESJdbyZNL7Yg5EYX+toYMtMqFoDDCvqZUi35eeGVsXc6pAaEk4edMwbuCQ==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -10988,6 +11008,7 @@ "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.25.2.tgz", "integrity": "sha512-1Nj1fusFd+rIMKU/qC9yGKVG+3ofh11d3OdBQKL1iVvQfKvcB8vhvTGQf2TkfxW3bamxN+hCZIXmNuU0mRkyDg==", "license": "MIT", + "peer": true, "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" @@ -11002,6 +11023,7 @@ "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.5.tgz", "integrity": "sha512-L4go5jA+GWutdJ/JucuN20cjAbMg1HmMtAP+wZ+3JLCf6Jd0bhXQHxciRP/AQm/FlrIEZwkMcHNZP+FXAiic0w==", "license": "MIT", + "peer": true, "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3" @@ -11102,6 +11124,7 @@ "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.9.1.tgz", "integrity": "sha512-kb6lGtBI5Ap41tvBPM09Np472r2GXuJ+jRApIFy1eXBk699eChG3U+lyqRC2/wz/VDpaJAy6i5XPcceNOoH3mA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.27.1", "@babel/plugin-transform-class-properties": "^7.28.6", @@ -12545,6 +12568,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13026,6 +13050,7 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/apps/mobile/src/screens/ScanScreen.tsx b/apps/mobile/src/screens/ScanScreen.tsx index 7ab207f2..de924186 100644 --- a/apps/mobile/src/screens/ScanScreen.tsx +++ b/apps/mobile/src/screens/ScanScreen.tsx @@ -10,6 +10,10 @@ import { Share, Platform, PermissionsAndroid, + Linking, + AppState, + type AppStateStatus, + NativeModules, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useFocusEffect } from '@react-navigation/native'; @@ -65,6 +69,31 @@ export default function ScanScreen({ navigation }: Props) { } }; + const checkInitialCameraPermission = useCallback(async () => { + if (Platform.OS === 'android') { + const hasPerm = await PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.CAMERA); + setHasPermission(hasPerm); + } else if (Platform.OS === 'ios') { + try { + const RNCameraKitModule = NativeModules.RNCameraKitModule; + if (RNCameraKitModule) { + const status = await RNCameraKitModule.checkDeviceCameraAuthorizationStatus(); + if (status === true || status === -1) { + // true: authorized, -1: not determined (iOS will prompt when mounts) + setHasPermission(true); + } else { + setHasPermission(false); + } + } else { + setHasPermission(true); + } + } catch (err) { + console.warn(err); + setHasPermission(false); + } + } + }, []); + const requestCameraPermission = async () => { if (Platform.OS === 'android') { try { @@ -82,10 +111,36 @@ export default function ScanScreen({ navigation }: Props) { } catch (err) { console.warn(err); } - } else { - // iOS permissions would typically be handled via react-native-permissions - // For this demo, assume true if not Android - setHasPermission(true); + } else if (Platform.OS === 'ios') { + try { + const RNCameraKitModule = NativeModules.RNCameraKitModule; + if (RNCameraKitModule) { + const status = await RNCameraKitModule.checkDeviceCameraAuthorizationStatus(); + if (status === true) { + setHasPermission(true); + } else if (status === -1) { + // iOS will prompt automatically when the Camera component is rendered. + // Since it hasn't been determined, setHasPermission(true) to trigger the prompt. + setHasPermission(true); + } else { + // Denied or restricted. Prompt to open Settings. + setHasPermission(false); + Alert.alert( + 'Camera Access Required', + 'Please enable camera access in your device settings to scan QR codes.', + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Settings', onPress: () => Linking.openURL('app-settings:') }, + ], + ); + } + } else { + setHasPermission(true); + } + } catch (err) { + console.warn(err); + setHasPermission(false); + } } }; @@ -126,7 +181,8 @@ export default function ScanScreen({ navigation }: Props) { useFocusEffect( useCallback(() => { fetchCards(); - }, [fetchCards]) + checkInitialCameraPermission(); + }, [fetchCards, checkInitialCameraPermission]) ); useEffect(() => { @@ -144,6 +200,19 @@ export default function ScanScreen({ navigation }: Props) { loadStoredCardId(); }, []); + useEffect(() => { + const handleAppStateChange = (nextAppState: AppStateStatus) => { + if (nextAppState === 'active') { + checkInitialCameraPermission(); + } + }; + + const subscription = AppState.addEventListener('change', handleAppStateChange); + return () => { + subscription.remove(); + }; + }, [checkInitialCameraPermission]); + useEffect(() => { if (!hasLoadedStoredCard) return; From 6df27fbe9c1eea717800c6ce240d0ecb24b2d61a Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 21 Jun 2026 22:11:13 +0530 Subject: [PATCH 2/4] fix(mobile): resolve ESLint violation for unused error variable in catch block --- apps/mobile/src/screens/ScanScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/screens/ScanScreen.tsx b/apps/mobile/src/screens/ScanScreen.tsx index de924186..15137046 100644 --- a/apps/mobile/src/screens/ScanScreen.tsx +++ b/apps/mobile/src/screens/ScanScreen.tsx @@ -159,7 +159,7 @@ export default function ScanScreen({ navigation }: Props) { title: 'My DevCard QR', url: uri, }); - } catch (err) { + } catch { Alert.alert('Error', 'Failed to save QR code'); } } From 6da1b2cc0f1dd4bf7a3c2bbfa22c9527169e91ec Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 21 Jun 2026 22:28:15 +0530 Subject: [PATCH 3/4] ci: filter changed files to code extensions and ignore lockfiles in eslint --- .github/scripts/ciScript.js | 25 +++++++++++++++++++------ .github/workflows/ci.yml | 19 ++++++++++++++++++- apps/mobile/.eslintignore | 3 +++ 3 files changed, 40 insertions(+), 7 deletions(-) create mode 100644 apps/mobile/.eslintignore diff --git a/.github/scripts/ciScript.js b/.github/scripts/ciScript.js index 21bd910e..2321fd19 100644 --- a/.github/scripts/ciScript.js +++ b/.github/scripts/ciScript.js @@ -26,6 +26,10 @@ module.exports = async ({ github, context, core }) => { const webFiles = []; const dbFiles = []; + let backendChanged = false; + let mobileChanged = false; + let webChanged = false; + try { if (prState === 'closed') { console.log(`PR state is: ${prState}`); @@ -49,11 +53,20 @@ module.exports = async ({ github, context, core }) => { const fileName = file.filename; if (fileName.startsWith('apps/backend/')) { - backendFiles.push(fileName); + backendChanged = true; + if (/\.(js|jsx|ts|tsx)$/.test(fileName)) { + backendFiles.push(fileName); + } } else if (fileName.startsWith('apps/mobile/')) { - mobileFiles.push(fileName); + mobileChanged = true; + if (/\.(js|jsx|ts|tsx)$/.test(fileName)) { + mobileFiles.push(fileName); + } } else if (fileName.startsWith('apps/web/')) { - webFiles.push(fileName); + webChanged = true; + if (/\.(js|jsx|ts|tsx)$/.test(fileName)) { + webFiles.push(fileName); + } }else if(fileName.startsWith('apps/backend/prisma')){ dbFiles.push(fileName) }else if(fileName.includes('schema.prisma') || fileName.includes('/migrations/')){ @@ -72,9 +85,9 @@ module.exports = async ({ github, context, core }) => { core.setOutput('webFiles', webFiles.map(f => f.replace('apps/web/', '')).join(' ')); core.setOutput('backendTestFiles', deriveTestFiles(strippedBackend).join(' ')); core.setOutput('mobileTestFiles', deriveTestFiles(strippedMobile).join(' ')); - core.setOutput('backendChanged', backendFiles.length > 0); - core.setOutput('mobileChanged', mobileFiles.length > 0); - core.setOutput('webChanged', webFiles.length > 0); + core.setOutput('backendChanged', backendChanged); + core.setOutput('mobileChanged', mobileChanged); + core.setOutput('webChanged', webChanged); } catch (error) { console.error(error); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71aedeb6..12c83167 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,7 +143,24 @@ jobs: - name: Mobile lint id: mobile_lint continue-on-error: true - run: cd apps/mobile && npx eslint ${{ needs.detect-changes.outputs.mobileFiles }} + run: | + cd apps/mobile + files="${{ needs.detect-changes.outputs.mobileFiles }}" + if [ -z "$files" ]; then + echo "No mobile files to lint" + exit 0 + fi + filtered_files="" + for f in $files; do + if [[ "$f" =~ \.(js|jsx|ts|tsx)$ ]]; then + filtered_files="$filtered_files $f" + fi + done + if [ -z "${filtered_files// /}" ]; then + echo "No mobile code files to lint after filtering" + exit 0 + fi + npx eslint $filtered_files - name: Mobile test id: mobile_test diff --git a/apps/mobile/.eslintignore b/apps/mobile/.eslintignore new file mode 100644 index 00000000..45cf256a --- /dev/null +++ b/apps/mobile/.eslintignore @@ -0,0 +1,3 @@ +package-lock.json +yarn.lock +pnpm-lock.yaml From 3b064feefbaca9ea91d9068a864d5dd7bf8e8b16 Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 21 Jun 2026 23:02:53 +0530 Subject: [PATCH 4/4] feat(profile): store previous usernames and 301 redirect old public URLs to new username --- .../migration.sql | 19 +++ apps/backend/prisma/schema.prisma | 14 ++ apps/backend/src/__tests__/redirects.test.ts | 152 ++++++++++++++++++ apps/backend/src/routes/public.ts | 42 +++++ apps/backend/src/services/profileService.ts | 30 +++- apps/web/src/pages/ProfilePage.tsx | 6 +- 6 files changed, 259 insertions(+), 4 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260621223000_add_username_redirects/migration.sql create mode 100644 apps/backend/src/__tests__/redirects.test.ts 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 44190c5d..a3fa57ac 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -43,6 +43,7 @@ model User { attendedEvents EventAttendee[] ownedTeams Team[] @relation("TeamOwner") teamMemberships TeamMember[] @relation("TeamMember") + usernameRedirects UsernameRedirect[] @@map("users") } @@ -260,4 +261,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__/redirects.test.ts b/apps/backend/src/__tests__/redirects.test.ts new file mode 100644 index 00000000..d6e26ef5 --- /dev/null +++ b/apps/backend/src/__tests__/redirects.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Fastify from 'fastify'; +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() { + 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/routes/public.ts b/apps/backend/src/routes/public.ts index 4333b9cd..bcf1eb5e 100644 --- a/apps/backend/src/routes/public.ts +++ b/apps/backend/src/routes/public.ts @@ -11,6 +11,48 @@ const MAX_QR_SIZE = 2048; const CACHE_CONTROL_HEADER = 'public, max-age=300, stale-while-revalidate=60'; 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; + + 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(current)) { + 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/profileService.ts b/apps/backend/src/services/profileService.ts index 4d300091..6159402b 100644 --- a/apps/backend/src/services/profileService.ts +++ b/apps/backend/src/services/profileService.ts @@ -29,9 +29,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) => { + 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/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);