diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index e8cedac7..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,195 +0,0 @@ -name: CI - -on: - pull_request_target: - types: [opened, synchronize, reopened] - -permissions: - pull-requests: write - -jobs: - detect-changes: - runs-on: ubuntu-latest - - outputs: - backendChanged: ${{ steps.detect.outputs.backendChanged }} - mobileChanged: ${{ steps.detect.outputs.mobileChanged }} - webChanged: ${{ steps.detect.outputs.webChanged }} - backendFiles: ${{ steps.detect.outputs.backendFiles }} - mobileFiles: ${{ steps.detect.outputs.mobileFiles }} - webFiles: ${{ steps.detect.outputs.webFiles }} - backendTestFiles: ${{ steps.detect.outputs.backendTestFiles }} - mobileTestFiles: ${{ steps.detect.outputs.mobileTestFiles }} - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - with: - ref: ${{ github.event.pull_request.head.sha }} - - - - name: Detect changed files - id: detect - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const script = require('./.github/scripts/ciScript.js'); - return await script({ github, context, core }); - - backend-ci: - needs: detect-changes - if: needs.detect-changes.outputs.backendChanged == 'true' - runs-on: ubuntu-latest - - outputs: - lint_result: ${{ steps.backend_lint.outcome }} - test_result: ${{ steps.backend_test.outcome }} - typecheck_result: ${{ steps.backend_typecheck.outcome }} - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - with: - ref: ${{ github.event.pull_request.head.sha }} - - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e - with: - node-version: 22 - - - name: Install shared dependencies - run: npm --prefix packages/shared install - - - name: Install backend dependencies - run: npm --prefix apps/backend install - - - name: Backend lint - id: backend_lint - continue-on-error: true - run: cd apps/backend && npx eslint ${{ needs.detect-changes.outputs.backendFiles }} - - - name: Backend test - id: backend_test - if: needs.detect-changes.outputs.backendTestFiles != '' - continue-on-error: true - run: npm --prefix apps/backend run test -- --passWithNoTests ${{ needs.detect-changes.outputs.backendTestFiles }} - - - name: Backend typecheck - id: backend_typecheck - continue-on-error: true - run: npm --prefix apps/backend run typecheck - - - name: Fail job if any check failed - if: > - steps.backend_lint.outcome == 'failure' || - steps.backend_test.outcome == 'failure' || - steps.backend_typecheck.outcome == 'failure' - run: exit 1 - - web-ci: - needs: detect-changes - if: needs.detect-changes.outputs.webChanged == 'true' - runs-on: ubuntu-latest - - outputs: - check_result: ${{ steps.web_check.outcome }} - build_result: ${{ steps.web_build.outcome }} - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - with: - ref: ${{ github.event.pull_request.head.sha }} - - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e - with: - node-version: 22 - - - name: Install web dependencies - run: npm --prefix apps/web install - - - name: Web check - id: web_check - continue-on-error: true - run: npm --prefix apps/web run lint - - - name: Web build - id: web_build - continue-on-error: true - run: npm --prefix apps/web run build - - - name: Fail job if any check failed - if: > - steps.web_check.outcome == 'failure' || - steps.web_build.outcome == 'failure' - run: exit 1 - - mobile-ci: - needs: detect-changes - if: needs.detect-changes.outputs.mobileChanged == 'true' - runs-on: ubuntu-latest - - outputs: - lint_result: ${{ steps.mobile_lint.outcome }} - test_result: ${{ steps.mobile_test.outcome }} - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - with: - ref: ${{ github.event.pull_request.head.sha }} - - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e - with: - node-version: 22 - - - name: Install shared dependencies - run: npm --prefix packages/shared install - - - name: Install mobile dependencies - run: npm --prefix apps/mobile install - - - name: Mobile lint - id: mobile_lint - continue-on-error: true - run: cd apps/mobile && npx eslint ${{ needs.detect-changes.outputs.mobileFiles }} - - - name: Mobile test - id: mobile_test - if: needs.detect-changes.outputs.mobileTestFiles != '' - continue-on-error: true - run: npm --prefix apps/mobile run test -- --passWithNoTests ${{ needs.detect-changes.outputs.mobileTestFiles }} - - - name: Fail job if any check failed - if: > - steps.mobile_lint.outcome == 'failure' || - steps.mobile_test.outcome == 'failure' - run: exit 1 - - comment-results: - needs: - - backend-ci - - web-ci - - mobile-ci - if: always() - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - - - name: Comment results - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const script = require('./.github/scripts/commentResults.js'); - await script({ - github, - context, - backend: '${{ needs.backend-ci.result }}', - web: '${{ needs.web-ci.result }}', - mobile: '${{ needs.mobile-ci.result }}', - backendLint: '${{ needs.backend-ci.outputs.lint_result }}', - backendTest: '${{ needs.backend-ci.outputs.test_result }}', - backendTypecheck: '${{ needs.backend-ci.outputs.typecheck_result }}', - webCheck: '${{ needs.web-ci.outputs.check_result }}', - webBuild: '${{ needs.web-ci.outputs.build_result }}', - mobileLint: '${{ needs.mobile-ci.outputs.lint_result }}', - mobileTest: '${{ needs.mobile-ci.outputs.test_result }}', - }); \ No newline at end of file diff --git a/.github/workflows/gssoc-discord-pin-reminder.yml b/.github/workflows/gssoc-discord-pin-reminder.yml deleted file mode 100644 index 5c6cd7cb..00000000 --- a/.github/workflows/gssoc-discord-pin-reminder.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: GSSoC Discord Pin Reminder - -on: - pull_request_target: - types: [closed] - workflow_dispatch: - -jobs: - discord-pin-reminder: - if: github.event.pull_request.merged == true - runs-on: ubuntu-latest - - permissions: - pull-requests: write - issues: write - - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Notify contributor about Discord GSSoC labels - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const script = require('./.github/scripts/discordPinReminder.js'); - await script({ github, context }); diff --git a/.github/workflows/unassign-unlinked-issues.yml b/.github/workflows/unassign-unlinked-issues.yml deleted file mode 100644 index 40bcab5f..00000000 --- a/.github/workflows/unassign-unlinked-issues.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Unassign Issues Without Linked PR - -on: - schedule: - - cron: '0 9 */5 * *' # Runs every 5 days at 9:00 AM UTC - workflow_dispatch: # Also allows manual triggering - -jobs: - unassign-issues: - runs-on: ubuntu-latest - permissions: - issues: write - - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 - - - name: Unassign issues with no linked PR and notify - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9.0.0 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const script = require('./.github/scripts/unassignIssues.js'); - await script({ github, context }); \ No newline at end of file diff --git a/.github/workflows/welcome-first-time.yml b/.github/workflows/welcome-first-time.yml deleted file mode 100644 index 2f3acc4e..00000000 --- a/.github/workflows/welcome-first-time.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Welcome First-Time Contributors - -on: - issues: - types: [opened] - pull_request_target: - types: [opened] - -permissions: - issues: write - pull-requests: write - -jobs: - welcome: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 - - - name: Welcome first-time contributor - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const script = require('./.github/scripts/welcomeScript.js'); - await script({ github, context }); \ No newline at end of file diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 28458021..8b10ba6e 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -106,7 +106,7 @@ model CardView { viewerId String? @map("viewer_id") // null = anonymous web viewer viewerIp String? @map("viewer_ip") viewerAgent String? @map("viewer_agent") - source String @default("qr") // "qr" | "link" | "web" | "app" + source String @default("qr") // "qr" | "link" | "web" | "app" | "nfc" createdAt DateTime @default(now()) @map("created_at") card Card? @relation(fields: [cardId], references: [id], onDelete: SetNull) diff --git a/apps/backend/src/__tests__/analytics.test.ts b/apps/backend/src/__tests__/analytics.test.ts index 4f0d07ae..2a8dde28 100644 --- a/apps/backend/src/__tests__/analytics.test.ts +++ b/apps/backend/src/__tests__/analytics.test.ts @@ -157,22 +157,39 @@ describe( ] ); - prismaMock.cardView.groupBy.mockResolvedValue( - [ - { - viewerId: - 'u1', - viewerIp: - null, - }, - { - viewerId: - 'u2', - viewerIp: - null, - }, - ] - ); + prismaMock.cardView.groupBy + .mockResolvedValueOnce( + [ + { + viewerId: + 'u1', + viewerIp: + null, + }, + { + viewerId: + 'u2', + viewerIp: + null, + }, + ] + ) + .mockResolvedValueOnce( + [ + { + source: 'qr', + _count: { id: 80 }, + }, + { + source: 'link', + _count: { id: 15 }, + }, + { + source: 'nfc', + _count: { id: 5 }, + }, + ] + ); const res = await app.inject( @@ -214,6 +231,16 @@ describe( ).toHaveLength( 1 ); + + expect( + body.viewsBySource + ).toMatchObject( + { + qr: 80, + link: 15, + nfc: 5, + } + ); } ); diff --git a/apps/backend/src/__tests__/app.test.ts b/apps/backend/src/__tests__/app.test.ts index 92b5ce48..90d566a9 100644 --- a/apps/backend/src/__tests__/app.test.ts +++ b/apps/backend/src/__tests__/app.test.ts @@ -3,8 +3,9 @@ import { describe, it, expect, vi } from 'vitest'; import { buildApp } from '../app'; process.env.NODE_ENV = 'test'; -process.env.JWT_SECRET ||= 'test-jwt-secret'; -process.env.ENCRYPTION_KEY ||= 'test-encryption-key'; +process.env.JWT_SECRET = 'test-jwt-secret-that-is-long-enough-for-testing'; +process.env.ENCRYPTION_KEY = 'test-encryption-key-that-is-exactly-32-chars!!'; +process.env.PUBLIC_APP_URL = 'http://localhost:5173'; describe('GET /health', () => { it('should return status ok', async () => { @@ -36,4 +37,4 @@ describe('request logging hook', () => { await app.close(); }); -}); \ No newline at end of file +}); diff --git a/apps/backend/src/__tests__/nfc.test.ts b/apps/backend/src/__tests__/nfc.test.ts new file mode 100644 index 00000000..e9002fb0 --- /dev/null +++ b/apps/backend/src/__tests__/nfc.test.ts @@ -0,0 +1,293 @@ +import { + describe, + it, + expect, + beforeEach, + afterEach, + vi, +} from 'vitest'; + +import Fastify, { + type FastifyInstance, +} from 'fastify'; + +import type { PrismaClient } from '@prisma/client'; + +import { nfcRoutes } from '../routes/nfc'; + +// ─── Shared mock data ──────────────────────────────────────────────────────── + +const MOCK_USER_ID = 'user-001'; +const MOCK_USERNAME = 'johndoe'; +const MOCK_CARD_ID = '123e4567-e89b-12d3-a456-426614174000'; + +// ─── Prisma mock ───────────────────────────────────────────────────────────── + +const prismaMock = { + user: { + findUnique: vi.fn(), + }, + card: { + findUnique: vi.fn(), + }, +}; + +// ─── App factory ───────────────────────────────────────────────────────────── + +let mockJwtVerify = vi.fn(); + +async function buildApp( + envOverrides?: Record, +): Promise { + // Apply env overrides + if (envOverrides) { + for (const [key, value] of Object.entries(envOverrides)) { + process.env[key] = value; + } + } + + const app = Fastify({ + logger: false, + }); + + app.decorate( + 'prisma', + prismaMock as unknown as PrismaClient, + ); + + app.decorateRequest( + 'jwtVerify', + function () { + return mockJwtVerify(); + }, + ); + + app.decorate( + 'authenticate', + async function (request: any, reply: any) { + try { + const user = await request.jwtVerify(); + request.user = user; + } catch (_err) { + return reply.status(401).send({ + error: 'Unauthorized', + }); + } + }, + ); + + await app.register(nfcRoutes, { + prefix: '/api/nfc', + }); + + await app.ready(); + return app; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function authHeader(): Record { + return { + Authorization: 'Bearer mock-token', + }; +} + +// ─── Test Suite ────────────────────────────────────────────────────────────── + +describe('NFC API', () => { + let app: FastifyInstance; + + beforeEach(async () => { + vi.clearAllMocks(); + + // Set default env + process.env.PUBLIC_APP_URL = 'https://devcard.dev'; + + mockJwtVerify.mockResolvedValue({ + id: MOCK_USER_ID, + }); + + app = await buildApp(); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + delete process.env.PUBLIC_APP_URL; + await app.close(); + }); + + describe('GET /api/nfc/payload', () => { + it('200 — returns NFC payload with correct URL using PUBLIC_APP_URL', async () => { + prismaMock.user.findUnique.mockResolvedValue({ + username: MOCK_USERNAME, + }); + prismaMock.card.findUnique.mockResolvedValue(null); + + const res = await app.inject({ + method: 'GET', + url: '/api/nfc/payload', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.type).toBe('URI'); + expect(body.payload).toBe( + 'https://devcard.dev/u/johndoe', + ); + }); + + it('200 — returns NFC payload with card-specific query param', async () => { + prismaMock.user.findUnique.mockResolvedValue({ + username: MOCK_USERNAME, + }); + prismaMock.card.findUnique.mockResolvedValue({ + userId: MOCK_USER_ID, + }); + + const res = await app.inject({ + method: 'GET', + url: `/api/nfc/payload?card=${MOCK_CARD_ID}`, + headers: authHeader(), + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.type).toBe('URI'); + expect(body.payload).toBe( + `https://devcard.dev/u/johndoe?card=${MOCK_CARD_ID}`, + ); + }); + + it('200 — uses correct /u/:username path format', async () => { + prismaMock.user.findUnique.mockResolvedValue({ + username: 'test-user', + }); + prismaMock.card.findUnique.mockResolvedValue(null); + + const res = await app.inject({ + method: 'GET', + url: '/api/nfc/payload', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.payload).toContain('/u/test-user'); + }); + + it('500 — returns error when PUBLIC_APP_URL is not set', async () => { + await app.close(); + vi.restoreAllMocks(); + delete process.env.PUBLIC_APP_URL; + + mockJwtVerify = vi.fn().mockResolvedValue({ + id: MOCK_USER_ID, + }); + + app = await buildApp({}); + + prismaMock.user.findUnique.mockResolvedValue({ + username: MOCK_USERNAME, + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/nfc/payload', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(500); + expect(res.json()).toMatchObject({ + error: 'Server configuration error: PUBLIC_APP_URL is not set', + }); + }); + + it('404 — returns error when user not found', async () => { + prismaMock.user.findUnique.mockResolvedValue(null); + + const res = await app.inject({ + method: 'GET', + url: '/api/nfc/payload', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(404); + expect(res.json()).toMatchObject({ + error: 'User not found', + }); + }); + + it('404 — returns error when card does not belong to user', async () => { + prismaMock.user.findUnique.mockResolvedValue({ + username: MOCK_USERNAME, + }); + prismaMock.card.findUnique.mockResolvedValue({ + userId: 'other-user', + }); + + const res = await app.inject({ + method: 'GET', + url: `/api/nfc/payload?card=${MOCK_CARD_ID}`, + headers: authHeader(), + }); + + expect(res.statusCode).toBe(404); + expect(res.json()).toMatchObject({ + error: 'Card not found', + }); + }); + + it('400 — returns error for invalid card UUID query param', async () => { + prismaMock.user.findUnique.mockResolvedValue({ + username: MOCK_USERNAME, + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/nfc/payload?card=not-a-uuid', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ + error: 'Invalid query parameters', + }); + }); + + it('401 — rejects unauthenticated request', async () => { + mockJwtVerify.mockRejectedValue( + new Error('Unauthorized'), + ); + + const res = await app.inject({ + method: 'GET', + url: '/api/nfc/payload', + }); + + expect(res.statusCode).toBe(401); + expect(res.json()).toMatchObject({ + error: 'Unauthorized', + }); + }); + + it('200 — handles username with special characters via encoding', async () => { + prismaMock.user.findUnique.mockResolvedValue({ + username: 'user name+special@chars', + }); + prismaMock.card.findUnique.mockResolvedValue(null); + + const res = await app.inject({ + method: 'GET', + url: '/api/nfc/payload', + headers: authHeader(), + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.payload).toBe( + 'https://devcard.dev/u/user%20name%2Bspecial%40chars', + ); + }); + }); +}); diff --git a/apps/backend/src/__tests__/validateEnv.test.ts b/apps/backend/src/__tests__/validateEnv.test.ts index 34fce500..afaa25cb 100644 --- a/apps/backend/src/__tests__/validateEnv.test.ts +++ b/apps/backend/src/__tests__/validateEnv.test.ts @@ -56,6 +56,7 @@ describe('validateEnv', () => { // works with the default value without requiring a full secrets setup. vi.stubEnv('JWT_SECRET', 'dev-secret-change-me'); vi.stubEnv('ENCRYPTION_KEY', 'a-valid-encryption-key'); + vi.stubEnv('PUBLIC_APP_URL', 'http://localhost:5173'); vi.stubEnv('NODE_ENV', 'development'); // Must not throw / call process.exit @@ -65,6 +66,7 @@ describe('validateEnv', () => { it('allows the known insecure default when NODE_ENV is not set', () => { vi.stubEnv('JWT_SECRET', 'dev-secret-change-me'); vi.stubEnv('ENCRYPTION_KEY', 'a-valid-encryption-key'); + vi.stubEnv('PUBLIC_APP_URL', 'http://localhost:5173'); vi.stubEnv('NODE_ENV', undefined as unknown as string); expect(() => validateEnv()).not.toThrow(); @@ -106,6 +108,7 @@ describe('validateEnv', () => { it('passes when both secrets are valid in development', () => { vi.stubEnv('JWT_SECRET', 'a-valid-jwt-secret-that-is-sufficiently-long'); vi.stubEnv('ENCRYPTION_KEY', 'a-valid-32-char-encryption-key!!'); + vi.stubEnv('PUBLIC_APP_URL', 'http://localhost:5173'); vi.stubEnv('NODE_ENV', 'development'); expect(() => validateEnv()).not.toThrow(); @@ -114,6 +117,7 @@ describe('validateEnv', () => { it('passes when both secrets are valid in production', () => { vi.stubEnv('JWT_SECRET', 'a-long-random-production-jwt-secret-with-enough-entropy'); vi.stubEnv('ENCRYPTION_KEY', 'a-64-char-hex-encryption-key-for-aes-256-gcm-0000000000000000'); + vi.stubEnv('PUBLIC_APP_URL', 'http://localhost:5173'); vi.stubEnv('NODE_ENV', 'production'); expect(() => validateEnv()).not.toThrow(); diff --git a/apps/backend/src/routes/analytics.ts b/apps/backend/src/routes/analytics.ts index efc22fe5..8f0443ea 100644 --- a/apps/backend/src/routes/analytics.ts +++ b/apps/backend/src/routes/analytics.ts @@ -79,12 +79,25 @@ export async function analyticsRoutes( const uniqueViewers = Number(uniqueViewersQuery[0]?.count ?? 0); + // Break down views by source for analytics + const viewsBySourceRaw = await app.prisma.cardView.groupBy({ + by: ['source'], + where: { ownerId: userId }, + _count: { id: true }, + }); + + const viewsBySource: Record = {}; + for (const entry of viewsBySourceRaw) { + viewsBySource[entry.source] = entry._count.id; + } + return { totalViews, viewsToday, totalFollows, uniqueViewers, recentViews, + viewsBySource, }; } ); diff --git a/apps/backend/src/routes/nfc.ts b/apps/backend/src/routes/nfc.ts index 5cf13f0c..89ec87c7 100644 --- a/apps/backend/src/routes/nfc.ts +++ b/apps/backend/src/routes/nfc.ts @@ -99,8 +99,15 @@ export async function nfcRoutes(app: FastifyInstance) { } } +const publicAppUrl = process.env.PUBLIC_APP_URL; +if (!publicAppUrl) { + return reply.status(500).send({ + error: 'Server configuration error: PUBLIC_APP_URL is not set', + }); +} + const safeUsername = encodeURIComponent(username); -const payloadUrl = `${process.env.PUBLIC_APP_URL}/${safeUsername}${ +const payloadUrl = `${publicAppUrl.replace(/\/+$/, '')}/u/${safeUsername}${ cardId ? `?card=${encodeURIComponent(cardId)}` : '' }`; const response: NfcPayloadResponse = { diff --git a/apps/backend/src/utils/validateEnv.ts b/apps/backend/src/utils/validateEnv.ts index cd361fc8..30ebf9fd 100644 --- a/apps/backend/src/utils/validateEnv.ts +++ b/apps/backend/src/utils/validateEnv.ts @@ -57,6 +57,17 @@ export function validateEnv(): void { ); } + // ── PUBLIC_APP_URL ──────────────────────────────────────────────────────────── + const publicAppUrl = process.env.PUBLIC_APP_URL; + + if (!publicAppUrl) { + errors.push( + 'PUBLIC_APP_URL is not set. NFC payloads, QR codes, and share links\n' + + ' will not work without it. Set it to the public-facing URL of the web app\n' + + ' (e.g. https://devcard.dev).', + ); + } + // ── Fail fast ─────────────────────────────────────────────────────────────── if (errors.length === 0) { return; diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 3e2c3f85..fc25a1de 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -66,7 +66,7 @@ This is one way to run your app — you can also build it directly from Android Now that you have successfully run the app, let's make changes! -Open `App.tsx` in your text editor of choice and make some changes. When you save, your app will automatically update and reflect these changes — this is powered by [Fast Refresh](https://reactnative.dev/docs/fast-refresh). +Open `App.tsx` in your text editor of choice and make some changes. When you save, your app will automatically update and reflect these changes — this is powered by [Fast Refresh](https://reactnative.dev/docs/fast-refresh). When you want to forcefully reload, for example to reset the state of your app, you can perform a full reload: @@ -86,6 +86,50 @@ You've successfully run and modified your React Native App. :partying_face: If you're having issues getting the above steps to work, see the [Troubleshooting](https://reactnative.dev/docs/troubleshooting) page. +## NFC Tag Writing + +The mobile app supports writing DevCard URLs to physical NFC tags. + +### iOS NFC Entitlement Setup + +NFC writing on iOS requires additional configuration: + +1. Open `ios/DevCard/Info.plist` and add the following entries: + +```xml +NFCReaderUsageDescription +This app needs NFC access to write DevCard URLs to NFC tags. +``` + +2. Enable the **Near Field Communication Tag Reading** capability in Xcode: + - Open `ios/DevCard.xcworkspace` in Xcode + - Select the DevCard target + - Go to **Signing & Capabilities** + - Click **+ Capability** and search for "Near Field Communication Tag Reading" + - Add it to the target + +3. Ensure your `ios/DevCard/DevCard.entitlements` file contains: + +```xml +com.apple.developer.nfc.readersession.iso7816.select-identifiers + +``` + +4. Build and run on a physical iPhone (NFC is not available on the iOS simulator): + ```bash + npm run ios -- --device + ``` + +> **Note**: NFC writing requires iPhone XR/XS or newer running iOS 13+. + +### Android NFC Setup + +Android NFC writing works out of the box with `react-native-nfc-manager`. No additional configuration is needed beyond installing the dependency. + +# Troubleshooting + +If you're having issues getting the above steps to work, see the [Troubleshooting](https://reactnative.dev/docs/troubleshooting) page. + # Learn More To learn more about React Native, take a look at the following resources: diff --git a/apps/mobile/package.json b/apps/mobile/package.json index e152d529..fe09cd73 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -26,6 +26,7 @@ "react-native-screens": "^4.0.0", "react-native-svg": "^15.0.0", "react-native-draggable-flatlist": "^4.0.1", + "react-native-nfc-manager": "^3.14.0", "react-native-vector-icons": "^10.0.0", "react-native-view-shot": "^5.1.0", "react-native-webview": "^13.0.0", diff --git a/apps/mobile/src/navigation/MainTabs.tsx b/apps/mobile/src/navigation/MainTabs.tsx index 8742ebc2..6660aa05 100644 --- a/apps/mobile/src/navigation/MainTabs.tsx +++ b/apps/mobile/src/navigation/MainTabs.tsx @@ -21,6 +21,7 @@ import TeamDetailScreen from '../screens/TeamDetailScreen'; import NfcScreen from '../screens/NfcScreen'; import { ConnectPlatformsScreen } from '../screens/ConnectPlatformsScreen'; +import NFCWriteScreen from '../screens/NFCWriteScreen'; import { ViewsScreen } from '../screens/ViewsScreen'; // ─── Types ─── @@ -49,6 +50,7 @@ export type RootStackParamList = { DevCardView: { username: string; followSuccessLinkId?: string }; WebViewConnect: WebViewConnectParams; ConnectPlatforms: undefined; + NFCWrite: undefined; Views: undefined; Links: undefined; Events: undefined; @@ -144,6 +146,11 @@ export default function MainTabs() { component={ConnectPlatformsScreen} options={{ title: 'Connected Platforms', headerShown: true, headerStyle: { backgroundColor: COLORS.bgPrimary }, headerTintColor: COLORS.textPrimary }} /> + (navigation as any).navigate('Contacts')}> Saved Cards + (navigation as any).navigate('Nfc')}> + Write NFC + diff --git a/apps/mobile/src/screens/NFCWriteScreen.tsx b/apps/mobile/src/screens/NFCWriteScreen.tsx new file mode 100644 index 00000000..b8590458 --- /dev/null +++ b/apps/mobile/src/screens/NFCWriteScreen.tsx @@ -0,0 +1,409 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + ActivityIndicator, + Alert, + Platform, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import NfcManager, { NfcTech, Ndef } from 'react-native-nfc-manager'; +import { COLORS, SPACING, FONT_SIZE, BORDER_RADIUS } from '../theme/tokens'; +import { API_BASE_URL } from '../config'; +import { useAuth } from '../context/AuthContext'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import type { RootStackParamList } from '../navigation/MainTabs'; + +type Props = { + navigation: NativeStackNavigationProp; +}; + +type NfcStatus = 'idle' | 'checking' | 'unsupported' | 'supported' | 'fetching' | 'ready' | 'writing' | 'success' | 'error'; + +export default function NFCWriteScreen({ navigation }: Props) { + const { token } = useAuth(); + const [status, setStatus] = useState('idle'); + const [payloadUrl, setPayloadUrl] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + + const checkNfc = useCallback(async () => { + setStatus('checking'); + try { + await NfcManager.start(); + const isSupported = await NfcManager.isSupported(); + if (!isSupported) { + setStatus('unsupported'); + return; + } + const isEnabled = await NfcManager.isEnabled(); + if (!isEnabled) { + setErrorMessage('NFC is disabled. Please enable NFC in your device settings.'); + setStatus('unsupported'); + return; + } + setStatus('supported'); + } catch { + setStatus('unsupported'); + setErrorMessage('Failed to check NFC availability.'); + } + }, []); + + const fetchPayload = useCallback(async () => { + setStatus('fetching'); + try { + const res = await fetch(`${API_BASE_URL}/api/nfc/payload`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || 'Failed to fetch NFC payload'); + } + const data = await res.json(); + setPayloadUrl(data.payload); + setStatus('ready'); + } catch (error: any) { + setErrorMessage(error.message || 'Failed to fetch NFC payload'); + setStatus('error'); + } + }, [token]); + + useEffect(() => { + checkNfc(); + }, [checkNfc]); + + useEffect(() => { + if (status === 'supported') { + fetchPayload(); + } + }, [status, fetchPayload]); + + const handleWriteTag = async () => { + if (!payloadUrl) return; + setStatus('writing'); + try { + const bytes = Ndef.encodeMessage([Ndef.uriRecord(payloadUrl)]); + if (!bytes) { + throw new Error('Failed to encode NDEF message'); + } + + await NfcManager.requestTechnology(NfcTech.Ndef); + await NfcManager.writeTag(bytes); + await NfcManager.setAlertMessage('DevCard written to tag!'); + await NfcManager.cleanTechnology(); + + setStatus('success'); + } catch (error: any) { + await NfcManager.cancelTechnologyRequest().catch(() => {}); + const message = error.message || 'Failed to write NFC tag'; + setErrorMessage(message); + setStatus('error'); + } + }; + + const handleRetry = () => { + setStatus('idle'); + setErrorMessage(''); + checkNfc(); + }; + + const handleDone = () => { + navigation.goBack(); + }; + + return ( + + + {/* Header */} + Write NFC Tag + + Write your DevCard URL to a physical NFC tag + + + {/* NFC Icon */} + + 📶 + + + {/* Status Area */} + + {status === 'checking' && ( + + + Checking NFC... + + )} + + {status === 'unsupported' && ( + + ⚠️ + + NFC Not Available + + {errorMessage || (Platform.OS === 'ios' + ? 'NFC writing requires an iPhone XR/XS or newer with the NFC entitlement enabled.' + : 'Your device does not support NFC or NFC is disabled.')} + + + + )} + + {status === 'fetching' && ( + + + Preparing payload... + + )} + + {status === 'ready' && ( + + + URL to write: + + {payloadUrl} + + + + Hold your NFC tag near the top of your device + + + )} + + {status === 'writing' && ( + + + Writing to tag... + + )} + + {status === 'success' && ( + + + + Success! + + Your DevCard has been written to the NFC tag. + + + + )} + + {status === 'error' && ( + + + + Write Failed + + {errorMessage || 'An unexpected error occurred.'} + + + + )} + + + {/* Actions */} + + {status === 'ready' && ( + + Write to NFC Tag + + )} + + {(status === 'error' || status === 'unsupported') && ( + + Retry + + )} + + {status === 'success' && ( + + Done + + )} + + navigation.goBack()} + activeOpacity={0.85}> + Cancel + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: COLORS.bgPrimary, + }, + content: { + flex: 1, + padding: SPACING.lg, + alignItems: 'center', + justifyContent: 'center', + }, + title: { + fontSize: FONT_SIZE.xxl, + fontWeight: '800', + color: COLORS.textPrimary, + marginBottom: SPACING.xs, + }, + subtitle: { + fontSize: FONT_SIZE.md, + color: COLORS.textSecondary, + textAlign: 'center', + marginBottom: SPACING.xl, + }, + iconContainer: { + width: 100, + height: 100, + borderRadius: 50, + backgroundColor: COLORS.bgCard, + alignItems: 'center', + justifyContent: 'center', + marginBottom: SPACING.xl, + borderWidth: 1, + borderColor: COLORS.border, + }, + nfcIcon: { + fontSize: 48, + }, + statusArea: { + width: '100%', + marginBottom: SPACING.xl, + }, + statusRow: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: COLORS.bgCard, + borderRadius: BORDER_RADIUS.md, + padding: SPACING.lg, + borderWidth: 1, + borderColor: COLORS.border, + gap: SPACING.md, + }, + statusText: { + fontSize: FONT_SIZE.md, + color: COLORS.textPrimary, + fontWeight: '500', + }, + readyContainer: { + gap: SPACING.md, + }, + urlPreview: { + backgroundColor: COLORS.bgCard, + borderRadius: BORDER_RADIUS.md, + padding: SPACING.lg, + borderWidth: 1, + borderColor: COLORS.border, + }, + urlLabel: { + fontSize: FONT_SIZE.sm, + color: COLORS.textMuted, + fontWeight: '600', + marginBottom: SPACING.xs, + }, + urlText: { + fontSize: FONT_SIZE.md, + color: COLORS.primary, + fontWeight: '500', + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + }, + readyHint: { + fontSize: FONT_SIZE.sm, + color: COLORS.textMuted, + textAlign: 'center', + fontStyle: 'italic', + }, + errorContent: { + flex: 1, + }, + errorTitle: { + fontSize: FONT_SIZE.md, + fontWeight: '700', + color: '#EF4444', + marginBottom: SPACING.xs, + }, + errorDetail: { + fontSize: FONT_SIZE.sm, + color: COLORS.textSecondary, + lineHeight: 20, + }, + errorIcon: { + fontSize: 24, + }, + successIcon: { + fontSize: 24, + }, + successTitle: { + fontSize: FONT_SIZE.md, + fontWeight: '700', + color: COLORS.success, + marginBottom: SPACING.xs, + }, + successDetail: { + fontSize: FONT_SIZE.sm, + color: COLORS.textSecondary, + lineHeight: 20, + }, + actions: { + width: '100%', + gap: SPACING.md, + }, + writeButton: { + backgroundColor: COLORS.primary, + borderRadius: BORDER_RADIUS.md, + paddingVertical: 16, + alignItems: 'center', + }, + writeButtonText: { + color: COLORS.white, + fontSize: FONT_SIZE.lg, + fontWeight: '700', + }, + retryButton: { + backgroundColor: COLORS.bgCard, + borderRadius: BORDER_RADIUS.md, + paddingVertical: 16, + alignItems: 'center', + borderWidth: 1, + borderColor: COLORS.border, + }, + retryButtonText: { + color: COLORS.textPrimary, + fontSize: FONT_SIZE.md, + fontWeight: '600', + }, + doneButton: { + backgroundColor: COLORS.success, + borderRadius: BORDER_RADIUS.md, + paddingVertical: 16, + alignItems: 'center', + }, + doneButtonText: { + color: COLORS.white, + fontSize: FONT_SIZE.lg, + fontWeight: '700', + }, + cancelButton: { + paddingVertical: 12, + alignItems: 'center', + }, + cancelButtonText: { + color: COLORS.textMuted, + fontSize: FONT_SIZE.md, + fontWeight: '500', + }, +}); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 4a4a9dcc..146d1db8 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -135,6 +135,7 @@ export interface AnalyticsOverview { title: string; } | null; }>; + viewsBySource: Record; } export interface ConnectedPlatform {