From 108621b3e98102c22ffd238fdca9bf2a25312882 Mon Sep 17 00:00:00 2001 From: prakash meena Date: Fri, 29 May 2026 17:00:38 +0530 Subject: [PATCH 1/2] feat(nfc): build complete NFC write + deep link system with env-aware URL generation Implement full NFC feature end-to-end: - Fix hardcoded payload URL to use PUBLIC_APP_URL and /u/:username path - Add PUBLIC_APP_URL startup validation in validateEnv.ts - Add 'nfc' as valid source in CardView model and analytics overview with viewsBySource breakdown - Install react-native-nfc-manager dependency for mobile NFC support - Build NFCWriteScreen with hardware check, payload fetch, NDEF write, and error handling - Add NFC write option to HomeScreen share flow - Add NFCWriteScreen to navigation stack - Document iOS NFC entitlement setup in mobile README - Add unit tests for payload URL generation Closes #287 Signed-off-by: prakash meena --- apps/backend/prisma/schema.prisma | 2 +- apps/backend/src/__tests__/analytics.test.ts | 59 ++- apps/backend/src/__tests__/app.test.ts | 3 + apps/backend/src/__tests__/nfc.test.ts | 293 +++++++++++++ .../backend/src/__tests__/validateEnv.test.ts | 4 + apps/backend/src/routes/analytics.ts | 13 + apps/backend/src/routes/nfc.ts | 9 +- apps/backend/src/utils/validateEnv.ts | 11 + apps/mobile/README.md | 40 ++ apps/mobile/package.json | 1 + apps/mobile/src/navigation/MainTabs.tsx | 7 + apps/mobile/src/screens/HomeScreen.tsx | 8 + apps/mobile/src/screens/NFCWriteScreen.tsx | 409 ++++++++++++++++++ packages/shared/src/types.ts | 1 + pnpm-lock.yaml | 15 + 15 files changed, 857 insertions(+), 18 deletions(-) create mode 100644 apps/backend/src/__tests__/nfc.test.ts create mode 100644 apps/mobile/src/screens/NFCWriteScreen.tsx diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 7017ca81..6b5d419f 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 648d98a6..dcf00bbf 100644 --- a/apps/backend/src/__tests__/app.test.ts +++ b/apps/backend/src/__tests__/app.test.ts @@ -1,4 +1,7 @@ process.env.NODE_ENV = 'test'; +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'; import { describe, it, expect } from 'vitest'; import { buildApp } from '../app'; 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 eb0574bd..8d1eb589 100644 --- a/apps/backend/src/__tests__/validateEnv.test.ts +++ b/apps/backend/src/__tests__/validateEnv.test.ts @@ -55,6 +55,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 @@ -64,6 +65,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(); @@ -105,6 +107,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(); @@ -113,6 +116,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 a2615cf8..3241ba15 100644 --- a/apps/backend/src/routes/analytics.ts +++ b/apps/backend/src/routes/analytics.ts @@ -77,12 +77,25 @@ export async function analyticsRoutes( const uniqueViewers = uniqueViewersQuery.length; + // 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 0f8330c7..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 = `https://dev-card.vercel.app/${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 32e69ac0..61deb7bd 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -43,6 +43,46 @@ cd ios && pod install && cd .. pnpm ios ``` +## 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 + pnpm 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. + ## Architecture - **Screens**: Located in `src/screens` diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 8bb6ccf1..7e84e334 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -27,6 +27,7 @@ "react-native-gesture-handler": "^2.28.0", "react-native-qrcode-svg": "^6.3.0", "react-native-reanimated": "^4.1.7", + "react-native-nfc-manager": "^3.14.0", "react-native-safe-area-context": "^5.6.2", "react-native-screens": "^4.16.0", "react-native-svg": "^15.12.1", diff --git a/apps/mobile/src/navigation/MainTabs.tsx b/apps/mobile/src/navigation/MainTabs.tsx index 203da2c2..2475ad7c 100644 --- a/apps/mobile/src/navigation/MainTabs.tsx +++ b/apps/mobile/src/navigation/MainTabs.tsx @@ -13,6 +13,7 @@ import DevCardViewScreen from '../screens/DevCardViewScreen'; import WebViewScreen from '../screens/WebViewScreen'; import { ConnectPlatformsScreen } from '../screens/ConnectPlatformsScreen'; +import NFCWriteScreen from '../screens/NFCWriteScreen'; import { ViewsScreen } from '../screens/ViewsScreen'; // ─── Types ─── @@ -41,6 +42,7 @@ export type RootStackParamList = { DevCardView: { username: string; followSuccessLinkId?: string }; WebViewConnect: WebViewConnectParams; ConnectPlatforms: undefined; + NFCWrite: undefined; Views: undefined; }; @@ -123,6 +125,11 @@ export default function MainTabs() { component={ConnectPlatformsScreen} options={{ title: 'Connected Platforms', headerShown: true, headerStyle: { backgroundColor: COLORS.bgPrimary }, headerTintColor: COLORS.textPrimary }} /> + Share Card + (navigation as any).navigate('NFCWrite')} + activeOpacity={0.85}> + 📶 + Write NFC + + (navigation as any).navigate('Views')} 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 { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5badd097..11c8e69d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,6 +150,9 @@ importers: react-native-gesture-handler: specifier: ^2.28.0 version: 2.28.0(react-native@0.81.5(@babel/core@7.29.0)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + react-native-nfc-manager: + specifier: ^3.14.0 + version: 3.17.2(@expo/config-plugins@54.0.4) react-native-qrcode-svg: specifier: ^6.3.0 version: 6.3.21(react-native-svg@15.12.1(react-native@0.81.5(@babel/core@7.29.0)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -5416,6 +5419,14 @@ packages: react: '*' react-native: '*' + react-native-nfc-manager@3.17.2: + resolution: {integrity: sha512-0NryP/Iw2hzw4MVH5KCngoRerNUrnRok6VfLrlFcFZRKyTQ7KTgpsdDxCB6cR33qYNyEDrWGBayfAI+ym5gt8Q==} + peerDependencies: + '@expo/config-plugins': '*' + peerDependenciesMeta: + '@expo/config-plugins': + optional: true + react-native-qrcode-svg@6.3.21: resolution: {integrity: sha512-6vcj4rcdpWedvphDR+NSJcudJykNuLgNGFwm2p4xYjR8RdyTzlrELKI5LkO4ANS9cQUbqsfkpippPv64Q2tUtA==} peerDependencies: @@ -12925,6 +12936,10 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.29.0)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0) + react-native-nfc-manager@3.17.2(@expo/config-plugins@54.0.4): + optionalDependencies: + '@expo/config-plugins': 54.0.4 + react-native-qrcode-svg@6.3.21(react-native-svg@15.12.1(react-native@0.81.5(@babel/core@7.29.0)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@react-native-community/cli@20.1.0(typescript@5.9.3))(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): dependencies: prop-types: 15.8.1 From 1c69322506c019d73def79aca808280a3fef1d18 Mon Sep 17 00:00:00 2001 From: OpenCode Agent Date: Sun, 7 Jun 2026 20:06:38 +0530 Subject: [PATCH 2/2] Restore non-workflow .github files needed for CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .github/scripts/*.js (ciScript, commentResults, etc.) — used by ci.yml detect-changes - .github/ISSUE_TEMPLATE/, .github/pull_request_template.md — project templates Workflow files (.github/workflows/) excluded to avoid token scope restriction --- .github/ISSUE_TEMPLATE/custom.md | 29 +++++ .github/pull_request_template.md | 64 ++++++++++ .github/scripts/ciScript.js | 81 ++++++++++++ .github/scripts/commentResults.js | 100 +++++++++++++++ .github/scripts/discordPinReminder.js | 37 ++++++ .github/scripts/unassignIssues.js | 177 ++++++++++++++++++++++++++ .github/scripts/welcomeScript.js | 72 +++++++++++ 7 files changed, 560 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/custom.md create mode 100644 .github/pull_request_template.md create mode 100644 .github/scripts/ciScript.js create mode 100644 .github/scripts/commentResults.js create mode 100644 .github/scripts/discordPinReminder.js create mode 100644 .github/scripts/unassignIssues.js create mode 100644 .github/scripts/welcomeScript.js diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md new file mode 100644 index 00000000..efdbbe85 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/custom.md @@ -0,0 +1,29 @@ +--- +name: Custom issue template +about: Mandatory followed issue template for DevCard +title: '' +labels: '' +assignees: '' + +--- + +## Summary +Short goal (why it’s useful). + +## Contexts +More details on issue + +## Tasks +- [ ] Task 1 +- [ ] Task 2 +- [ ] Task 3 + +## Acceptance Criteria +- [ ] Behavior X works +- [ ] Tests added +- [ ] docs updated (if needed) + +## Area +`backend` / `mobile` / `web` / `shared` + +## Difficulty diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..6c10ac89 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,64 @@ +## Summary + + + +Closes # + +--- + +## Type of Change + + + +- [ ] Bug fix +- [ ] New feature +- [ ] Refactor (no functional change) +- [ ] UI / Design change +- [ ] Tests only +- [ ] Documentation +- [ ] Infrastructure / DevOps +- [ ] Security + +--- + +## What Changed + + + +- +- +- + +--- + +## How to Test + + + +1. +2. +3. + +--- + +## Checklist + +- [ ] My code follows the project's coding style (`pnpm -r run lint` passes). +- [ ] TypeScript compiles without errors (`pnpm -r run typecheck`). +- [ ] I have added or updated tests for the changes I made. +- [ ] All tests pass locally (`pnpm -r run test`). +- [ ] I have updated documentation where necessary. +- [ ] No new `console.log` or debug statements left in the code. +- [ ] Breaking changes are documented in this PR description. + +--- + +## Screenshots / Recordings + + + +--- + +## Additional Context + + diff --git a/.github/scripts/ciScript.js b/.github/scripts/ciScript.js new file mode 100644 index 00000000..4e4e4792 --- /dev/null +++ b/.github/scripts/ciScript.js @@ -0,0 +1,81 @@ +const isTestFile = (file) => /\.(test|spec)\.[jt]sx?$/.test(file); + +const deriveTestFiles = (files) => { + return files.map((file) => { + if (isTestFile(file)) return file; + + const withoutExt = file.replace(/\.[jt]sx?$/, ''); + const parts = withoutExt.split('/'); + const baseName = parts[parts.length - 1]; + const dir = parts.slice(0, -1).join('/'); + + return `${dir}/__tests__/${baseName}.test.ts`; + }); +}; + +module.exports = async ({ github, context, core }) => { + const owner = context.repo.owner; + const repo = context.repo.repo; + const pr = context.payload.pull_request; + const prNumber = pr.number; + const prState = pr.state; + + const backendFiles = []; + const mobileFiles = []; + const webFiles = []; + + try { + if (prState === 'closed') { + console.log(`PR state is: ${prState}`); + return { + backendChanged: false, + mobileChanged: false, + webChanged: false + }; + } + + const changedFiles = await github.paginate( + github.rest.pulls.listFiles, + { + owner, + repo, + pull_number: prNumber + } + ); + + changedFiles.forEach((file) => { + const fileName = file.filename; + + if (fileName.startsWith('apps/backend/')) { + backendFiles.push(fileName); + } else if (fileName.startsWith('apps/mobile/')) { + mobileFiles.push(fileName); + } else if (fileName.startsWith('apps/web/')) { + webFiles.push(fileName); + } + }); + + const strippedBackend = backendFiles.map(f => f.replace('apps/backend/', '')); + const strippedMobile = mobileFiles.map(f => f.replace('apps/mobile/', '')); + + console.log({ backendFiles, mobileFiles, webFiles }); + + core.setOutput('backendFiles', strippedBackend.join(' ')); + core.setOutput('mobileFiles', strippedMobile.join(' ')); + 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); + + } catch (error) { + console.error(error); + + return { + backendChanged: false, + mobileChanged: false, + webChanged: false + }; + } +}; \ No newline at end of file diff --git a/.github/scripts/commentResults.js b/.github/scripts/commentResults.js new file mode 100644 index 00000000..a1a96089 --- /dev/null +++ b/.github/scripts/commentResults.js @@ -0,0 +1,100 @@ +module.exports = async ({ + github, + context, + backend, + mobile, + web, + backendLint, + backendTest, + backendTypecheck, + mobileLint, + mobileTest, + webCheck, + webBuild, + backendLintOutput, + mobileLintOutput, +}) => { + const owner = context.repo.owner; + const repo = context.repo.repo; + const prNumber = context.payload.pull_request.number; + + const status = (s) => { + if (s === 'success') return 'PASS'; + if (s === 'failure') return 'FAIL'; + if (s === 'skipped') return 'SKIP'; + return '-'; + }; + + const lintDetails = (output) => { + if (!output || !output.trim()) return ''; + return `\n
\nView lint errors\n\n\`\`\`\n${output.trim()}\n\`\`\`\n
`; + }; + + const anyFailure = [backend, mobile, web].includes('failure'); + const title = anyFailure ? 'CI — Checks Failed' : 'CI — All Checks Passed'; + const timestamp = new Date().toUTCString(); + + const body = `## ${title} + +### Backend — ${status(backend)} + +| Check | Result | +|---|---| +| Lint | ${status(backendLint)} | +| Test | ${status(backendTest)} | +| Typecheck | ${status(backendTypecheck)} | +${backendLint === 'failure' ? lintDetails(backendLintOutput) : ''} + +### Mobile — ${status(mobile)} + +| Check | Result | +|---|---| +| Lint | ${status(mobileLint)} | +| Test | ${status(mobileTest)} | +${mobileLint === 'failure' ? lintDetails(mobileLintOutput) : ''} + +### Web — ${status(web)} + +| Check | Result | +|---|---| +| Check | ${status(webCheck)} | +| Build | ${status(webBuild)} | + +--- +Last updated: \`${timestamp}\``; + + const COMMENT_MARKER = '## CI —'; + + try { + const comments = await github.paginate( + github.rest.issues.listComments, + { + owner, + repo, + issue_number: prNumber + } + ); + + const existing = comments.find( + c => c.body && c.body.startsWith(COMMENT_MARKER) + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body + }); + } + } catch (err) { + console.error(err); + } +}; \ No newline at end of file diff --git a/.github/scripts/discordPinReminder.js b/.github/scripts/discordPinReminder.js new file mode 100644 index 00000000..d5724578 --- /dev/null +++ b/.github/scripts/discordPinReminder.js @@ -0,0 +1,37 @@ +module.exports = async ({ github, context }) => { + const pr = context.payload.pull_request; + const ignoreUsers = [ + 'ShantKhatri', + 'Harxhit', + 'blankirigaya' + ] + try { + // Only continue if merged + if (!pr || !pr.merged) { + console.log('PR not merged.'); + return; + } + + const prNumber = pr.number; + const contributor = pr.user.login; + + if(ignoreUsers.includes(contributor)){ + console.log(`Ignoring PR #${prNumber} by ${contributor}`); + return; + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `Congratulations @${contributor} on getting PR #${prNumber} merged! + + Thank you for your contribution. Please mention @Harxhit in our Discord server to receive the appropriate GSSoC labels and recognition. + ` + }); + + console.log(`Comment added to PR #${prNumber}`); + } catch (error) { + console.error(error) + } +}; diff --git a/.github/scripts/unassignIssues.js b/.github/scripts/unassignIssues.js new file mode 100644 index 00000000..b4886e91 --- /dev/null +++ b/.github/scripts/unassignIssues.js @@ -0,0 +1,177 @@ +module.exports = async ({ github, context }) => { + const owner = context.repo.owner; + const repo = context.repo.repo; + + const PROTECTED_ASSIGNEES = [ + 'ShantKhatri', + 'Harxhit', + 'blankirigaya' + ]; + + // Fetch all open issues (excluding PRs) + let page = 1; + let issues = []; + + while (true) { + const { data } = await github.rest.issues.listForRepo({ + owner, + repo, + state: 'open', + per_page: 100, + page, + }); + + const onlyIssues = data.filter( + item => !item.pull_request + ); + + issues = issues.concat(onlyIssues); + + if (data.length < 100) break; + page++; + } + + console.log( + `Found ${issues.length} open issue(s) to check.` + ); + + for (const issue of issues) { + const issueNumber = issue.number; + + // Skip if no assignees + if ( + !issue.assignees || + issue.assignees.length === 0 + ) { + console.log( + `Issue #${issueNumber} has no assignees — skipping.` + ); + continue; + } + + const assigneeLogins = + issue.assignees.map(a => a.login); + + // Skip protected assignees + const hasProtectedAssignee = + assigneeLogins.some(login => + PROTECTED_ASSIGNEES.includes(login) + ); + + if (hasProtectedAssignee) { + console.log( + `Issue #${issueNumber} has protected assignee(s) — skipping.` + ); + continue; + } + + let linkedPRFound = false; + let assignedAt = null; + + try { + const timeline = + await github.rest.issues.listEventsForTimeline({ + owner, + repo, + issue_number: issueNumber, + per_page: 100, + }); + + // Check linked PR + linkedPRFound = timeline.data.some(event => { + const pr = event.source?.issue; + + return ( + event.event === 'cross-referenced' && + pr?.pull_request && + pr.state === 'open' + ); + }); + + // Find latest assignment event + const assignEvents = + timeline.data.filter( + event => event.event === 'assigned' + ); + + if (assignEvents.length > 0) { + assignedAt = + assignEvents[ + assignEvents.length - 1 + ].created_at; + } + } catch (err) { + console.log( + `Could not fetch timeline for issue #${issueNumber}: ${err.message}` + ); + continue; + } + + // Skip if no assignment timestamp + if (!assignedAt) { + console.log( + `Issue #${issueNumber} has no assignment timestamp — skipping.` + ); + continue; + } + + const assignedDate = + new Date(assignedAt); + const now = new Date(); + + const daysAssigned = + (now - assignedDate) / + (1000 * 60 * 60 * 24); + + console.log( + `Issue #${issueNumber} assigned for ${daysAssigned.toFixed( + 1 + )} day(s).` + ); + + // Skip if assigned <= 5 days + if (daysAssigned <= 5) { + console.log( + `Issue #${issueNumber} assigned less than 5 days ago — skipping.` + ); + continue; + } + + // Skip if linked PR exists + if (linkedPRFound) { + console.log( + `Issue #${issueNumber} has linked open/draft PR — keeping assignment.` + ); + continue; + } + + // Remove assignees + await github.rest.issues.removeAssignees({ + owner, + repo, + issue_number: issueNumber, + assignees: assigneeLogins, + }); + + const assigneesMention = + assigneeLogins + .map(user => `@${user}`) + .join(', '); + + // Comment + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: `Hey @ShantKhatri (Project Admin) and @Harxhit (Maintainer), + +This issue (previously assigned to ${assigneesMention}) has been **automatically unassigned** because no linked pull request was found within 5 days of assignment. + +If work is in progress, please open and link a PR to keep the assignment active.`, + }); + + console.log( + `Issue #${issueNumber} unassigned successfully.` + ); + } +}; diff --git a/.github/scripts/welcomeScript.js b/.github/scripts/welcomeScript.js new file mode 100644 index 00000000..aa48ce7b --- /dev/null +++ b/.github/scripts/welcomeScript.js @@ -0,0 +1,72 @@ +module.exports = async ({ github, context }) => { + const owner = context.repo.owner; + const repo = context.repo.repo; + const issueNumber = context.issue.number; + const eventName = context.eventName; + const ghUsername = context.payload.sender.login; + + try { + const issueAssociation = + context.payload.issue?.author_association; + + if ( + eventName === 'issues' && + issueAssociation === 'NONE' + ) { + // Verify this is truly their first issue (listForRepo returns PRs too) + const userIssues = await github.rest.issues.listForRepo({ + owner, + repo, + state: 'all', + creator: ghUsername, + per_page: 10 + }); + + const actualIssues = userIssues.data.filter(issue => !issue.pull_request); + + if (actualIssues.length === 1) { + return await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: `👋 Thanks for opening your first issue, @${ghUsername}! + +We appreciate your contribution and are excited to have you here. Please make sure to follow the contribution guidelines and provide as much detail as possible. + +To stay updated, ask questions, and connect with maintainers and contributors, please join our Discord community: +https://discord.gg/QueQN83wn + +Looking forward to collaborating with you!` + }); + } + } + + const prAssociation = + context.payload.pull_request?.author_association; + + if ( + eventName === 'pull_request_target' && + ( + prAssociation === 'FIRST_TIMER' || + prAssociation === 'FIRST_TIME_CONTRIBUTOR' + ) + ) { + return await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: `🎉 Thanks for your first contribution, @${ghUsername}! + +We're excited to have you here. A maintainer will review your PR soon. Please check CI results and review any feedback if needed. + +To stay updated, ask questions, and connect with maintainers and contributors, please join our Discord community: +https://discord.gg/QueQN83wn + +Looking forward to collaborating with you!` + }); + } + + } catch (error) { + console.error(error); + } +}; \ No newline at end of file