diff --git a/.env.example b/.env.example index 9d632f2..8228592 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ EXPO_PUBLIC_API_URL=http://localhost:3000 EXPO_PUBLIC_SENTRY_DSN= +EXPO_PUBLIC_SENTRY_ENVIRONMENT=development diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce1f492..e263b41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,9 +5,15 @@ on: branches: [main] pull_request: +permissions: + contents: read + jobs: validate: runs-on: ubuntu-latest + env: + EXPO_PUBLIC_API_URL: https://backend.ruffl.thomaswhite.me + EXPO_PUBLIC_SENTRY_ENVIRONMENT: test steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -18,3 +24,4 @@ jobs: - run: npm run typecheck - run: npm run lint - run: npm test + - run: npm run export:web diff --git a/README.md b/README.md index 28df4ff..4dff373 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ This guide assumes you have not used React Native, Expo, or a mobile emulator be - **React Native** lets the project build Android and iOS interfaces from one codebase. - **Expo** supplies the development server and mobile tooling around React Native. -- **Expo Go** is a phone app that can open Ruffl during early development without creating an app-store build. +- **Expo Go** is a phone app that can open Ruffl for early JavaScript and layout checks without creating an app-store build. It cannot test Ruffl's remote push notifications. +- **Expo development builds** contain Ruffl's own native libraries, including Sentry and notification configuration, and are the production-like test path. - **Expo Router** turns files under `app/` into screens and navigation routes. - **TypeScript** is JavaScript with additional checks that catch many mistakes before the app runs. - **npm** downloads the libraries listed in `package.json` and runs the commands under `scripts`. @@ -23,9 +24,11 @@ Ruffl-Frontend/ |-- src/components/ Shared interface components |-- src/context/ Login session and live account-status checks |-- src/lib/ Calculations and display helpers +|-- src/services/ Native services such as push registration |-- src/theme.ts Colours and shared visual values |-- test/ Automated tests |-- app.json Expo application configuration +|-- eas.json Development, preview, and production cloud-build profiles |-- .env.example Example local settings `-- package.json Libraries and development commands ``` @@ -35,7 +38,7 @@ Ruffl-Frontend/ Install the following: 1. **Node.js 22 LTS** from [nodejs.org](https://nodejs.org/). npm is installed with Node.js. -2. **Expo Go** from the iOS App Store or Google Play if you want to test on a physical phone. +2. **Expo Go** from the iOS App Store or Google Play for basic physical-phone testing, or an Expo development build for push and native integration testing. 3. A code editor such as [Visual Studio Code](https://code.visualstudio.com/). 4. The `Ruffl-Backend` repository beside this repository. @@ -76,6 +79,16 @@ The `.env` file is ignored by Git. Do not commit it. Open `.env` and set `EXPO_PUBLIC_API_URL`. +The complete local file is: + +```dotenv +EXPO_PUBLIC_API_URL=http://localhost:3000 +EXPO_PUBLIC_SENTRY_DSN= +EXPO_PUBLIC_SENTRY_ENVIRONMENT=development +``` + +`EXPO_PUBLIC_*` values are compiled into the app and are not secret. Never put a Sentry auth token, database password, R2 secret, JWT secret, or Expo access token in them. + ### Physical phone `localhost` on a phone means the phone itself, not the development computer. Use the computer's local network address instead. @@ -179,6 +192,7 @@ If `.env` changes, stop Expo with `Ctrl+C` and start it again. A normal hot relo | `npm run android` | Starts Expo and attempts to open an Android emulator/device | | `npm run ios` | Starts Expo and attempts to open the iOS Simulator | | `npm run web` | Runs the web-compatible version for quick layout checks | +| `npm run export:web` | Creates a production static web bundle in `dist` | | `npm run typecheck` | Checks TypeScript without creating a build | | `npm run lint` | Checks code style and common programming mistakes | | `npm test` | Runs the automated tests once | @@ -192,6 +206,7 @@ Run these before committing: npm run typecheck npm run lint npm test +npm run export:web ``` The mobile tests cover calculations, progress display, API errors, rate-limit messages, and account restriction propagation. Backend permissions and commission lifecycle tests live in `Ruffl-Backend`. @@ -205,25 +220,94 @@ The mobile tests cover calculations, progress display, API errors, rate-limit me - Suspension, soft deletion, and permanent deletion clear the local authenticated session and force the dedicated account-status screen. - The backend also rejects every authenticated action immediately, so the three-second client check is not the security boundary. -The interface update is not literally instantaneous. Its normal maximum delay is approximately three seconds. True real-time delivery would require WebSockets or push-notification handling. +The interface update is not literally instantaneous. Its normal maximum delay is approximately three seconds. Background push alerts notify users about new activity, but the recurring `/me` check and backend request enforcement remain the account-restriction controls. ## Product flows currently available -- Commissioner and maker signup/login +- Commissioner and maker signup/login with production email verification +- Verification-email resend and forgot-password recovery - Role-aware home summaries - Maker search, profiles, pricing, queue state, reviews, and waitlists - Structured commission requests - Price negotiation +- Two-step commission cancellation before deposit; later cancellation routes through a dispute - Simulated deposits and milestone releases - Ordered progress updates and approvals +- Three-second commission-detail refresh so counterpart actions appear without reopening the screen - Shipping and receipt confirmation -- Reviews and dispute entry points +- Five-category 1–5 reviews from either party, with duplicate-review prevention, plus dispute entry points - Commission, direct, dispute, and support conversation types +- Direct “message maker” and “contact Ruffl support” actions with polling conversation screens +- R2-backed image attachments in messages, milestone updates, disputes, and profile images +- Activity notifications that can be acknowledged as read +- Opt-in Expo push alerts for new messages and other backend-created activity notifications +- In-app account deletion with active-commission protection and immediate session revocation - Maker price and payout calculator - Warning, suspension, and deletion handling +- Sentry JavaScript/native crash reporting when a mobile project DSN is configured No real payment is taken. Every payment-related action is symbolic. +### Account email during development + +- Production signup sends a verification email and does not sign the new account in until the link is confirmed. +- **Forgot password?** sends a reset email when the address matches an account. The message is intentionally identical for unknown addresses. +- Verification and reset links open a secure page hosted by the backend, so users do not need Ruffl universal links configured on their phone. +- A local development backend without Resend automatically verifies signup accounts. Recovery responses include an **Open development-only email link** button instead of sending real email. +- Demo-login buttons are compiled only into development builds. They are absent from preview and production builds. + +To test the production behavior, configure Resend and `BACKEND_PUBLIC_URL` in the backend, then use an inbox you control. Do not use a real user's address for testing. + +## Push notifications during development + +Remote push notifications require an Expo development, preview, or production build. Expo Go can still test screens and API flows, but it cannot prove Ruffl's native remote-notification setup. + +First link this repository to an Expo project: + +```powershell +npx eas-cli@latest login +npx eas-cli@latest init +``` + +`eas init` writes an EAS project ID into the Expo configuration. Ruffl passes that ID to `getExpoPushTokenAsync`; notification registration displays an explanatory error when the ID is missing. + +Configure the platform credentials: + +1. Run `npx eas-cli@latest credentials`. +2. For Android, configure an FCM v1 service-account key for the Ruffl project. +3. For iOS, configure APNs credentials. A paid Apple Developer account is required for physical-device push delivery. +4. Create a development build: + + ```powershell + npx eas-cli@latest build --profile development --platform android + npx eas-cli@latest build --profile development --platform ios + ``` + +5. Install the resulting build on the device. +6. Sign in, open **Profile**, and select **Enable push notifications**. +7. Grant the operating-system permission. +8. Sign in as the other demo role on another device or browser and send a direct message. +9. Background the receiving app and confirm the device displays the alert. +10. Tap the alert and confirm Ruffl opens the authenticated tabs. + +The backend must have `EXPO_ACCESS_TOKEN` configured and `/ready` must report `"pushDelivery":"configured"`. Disabling notifications or signing out clears the backend token. Ruffl currently stores one active device token per account, so enabling notifications on a second device replaces the first device. + +## Image attachments during development + +Ruffl uses the operating system's image library and uploads directly to the short-lived R2 URL issued by the backend: + +1. Configure all five `R2_*` backend variables. +2. Configure the R2 bucket's browser CORS rule if you test the web build. Native Android/iOS uploads do not use browser CORS. +3. Start the backend and mobile app. +4. Open a direct/support conversation, active maker milestone, dispute form, or profile. +5. Select **Attach an image** or **Change profile image**. +6. Choose a JPEG, PNG, WebP, or GIF smaller than 10 MiB. +7. Wait until the selected image row appears before submitting the message or form. + +The app first requests `POST /uploads/slot`, uploads the bytes directly to R2 with `PUT`, then submits only the public URL, filename, and content type to the backend. The backend rejects arbitrary external URLs and another account's R2 path. + +The current picker intentionally supports images only. HEIC, AVIF, video, and document selection display a clear unsupported-type message. Anyone who obtains a complete R2 public media URL can read it, so do not use the current upload flow for identity documents or other highly sensitive files. + ## Troubleshooting ### “Could not connect to Ruffl” or “Network request failed” @@ -267,6 +351,13 @@ ok ruffl-api `POST /auth/login` allows ten attempts from one IP address within fifteen minutes. Successful and unsuccessful attempts both count. Wait for the displayed time or restart the development backend to clear its in-memory limiter. +### Login says the email is not verified + +- Select **Resend verification email** on the sign-in screen. +- Check spam and confirm the email address was entered correctly. +- In local development without Resend, create the account again after restarting the in-memory backend; local signup is automatically verified. +- In production, the operator should inspect Resend delivery logs and confirm the sending domain is verified. + ### Account warnings or suspension do not update - Confirm the mobile app is connected to the same backend instance as the admin dashboard. @@ -287,19 +378,16 @@ Use `npx expo install ` for Expo-native packages because Expo choo ## Expo Go versus a development build -Expo Go is the easiest first development environment, but it only contains a fixed set of native libraries. A **development build** is a custom version of the Ruffl app containing its own native libraries. Use development builds before adding full native push notifications, monitoring, or app-store testing. +Expo Go is useful for quick JavaScript/layout checks, but it contains a fixed native runtime and cannot test Ruffl's remote push setup. Ruffl is currently on supported Expo SDK 54, while the newest SDK is 57. Expo recommends incremental one-version-at-a-time upgrades and development builds for production apps. SDK 54 continues receiving critical fixes until the next Expo SDK release, expected in September or October 2026. -Install the development client: +`expo-dev-client` and `eas.json` are already installed/configured. Link the repository to your Expo account once: ```powershell -npx expo install expo-dev-client +npx eas-cli@latest login +npx eas-cli@latest init ``` -Configure Expo Application Services: - -```powershell -npx eas-cli@latest build:configure -``` +`eas init` adds the real Expo project ID to app configuration. Review that change before committing it. Create development builds: @@ -310,12 +398,35 @@ npx eas-cli@latest build --profile development --platform ios EAS requires an Expo account. Apple Developer and Google Play accounts are required for store distribution. +Create preview and production builds only after configuring EAS environment variables: + +```powershell +npx eas-cli@latest env:create --environment preview --name EXPO_PUBLIC_API_URL --value https://backend.ruffl.thomaswhite.me +npx eas-cli@latest env:create --environment production --name EXPO_PUBLIC_API_URL --value https://backend.ruffl.thomaswhite.me +npx eas-cli@latest build --profile preview --platform android +npx eas-cli@latest build --profile production --platform all +``` + +Create a separate Sentry React Native project first, then add its DSN as `EXPO_PUBLIC_SENTRY_DSN` in preview/production. Configure `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`, and `SENTRY_PROJECT` as protected EAS build secrets for source-map upload; those three are not `EXPO_PUBLIC_*` values. + +For a clean checkout, `npm install` installs the native libraries declared in `package.json`. If you need to repair only the notification and image-upload dependencies, use the SDK-aware installer: + +```powershell +npx expo install expo-notifications expo-constants expo-modules-core expo-image-picker expo-file-system +``` + ## Security and current limitations -- Authentication tokens are stored through Expo SecureStore. +- Authentication tokens are stored through Expo SecureStore on Android and iOS. The optional web build uses browser `localStorage`, so it is intended for development/layout checks rather than as a hardened production client. +- The app clears expired or password-revoked bearer sessions immediately and returns to sign-in. Suspended and deleted accounts instead see the applicable restriction notice while the backend blocks every protected request. +- Production signup requires email verification, and password recovery uses short-lived one-use links served by the backend. - Never place private server keys in `EXPO_PUBLIC_*` variables. Anything beginning with `EXPO_PUBLIC_` is included in the client application. -- Media selection/upload UI, Expo Push registration, and Sentry initialisation still require production service integration. -- The backend currently stores development data in memory, so restarting it resets accounts and commissions. +- Native image selection/upload is implemented, but video/document selection and private authenticated media downloads are not. +- Canceling a form after an image has uploaded can leave an unreferenced R2 object. Do not configure a blanket R2 expiry rule because that would also remove images referenced by old messages; an upload registry is still needed for safe orphan cleanup. +- Expo push registration and backend ticket/receipt processing are implemented, but physical Android/iOS delivery remains unverified until EAS project credentials and real development builds are provisioned. +- Push registration currently supports one active device per account. +- Sentry is initialized, but the `ruffl-frontend` Sentry project/DSN and protected source-map credentials still need to be created. +- Production backend data uses PostgreSQL. Local development without `DATABASE_URL` intentionally uses memory and resets on restart. - No payment processor is integrated. -As of 26 July 2026, `npm audit --omit=dev` reports high-severity advisories in transitive Expo/React Native build-tool dependencies (`brace-expansion` and `postcss`) with no compatible fix published for the SDK 54 dependency tree. Do not run `npm audit fix --force` blindly because that can move native packages outside Expo's supported versions. +As of 30 July 2026, `package.json` overrides transitive PostCSS to the compatible patched `8.5.23` release. Npm still reports a high-severity `brace-expansion` advisory and an indirect moderate `uuid` advisory through Expo's build tooling. Their available fixes require overriding major-version contracts used by Expo SDK 54, so do not run `npm audit fix --force` or add a major override without native development builds. Recheck after each Expo patch and perform the SDK 54 → 55 → 56 → 57 upgrade incrementally in a dedicated change with native development builds on both platforms. diff --git a/app.json b/app.json index dd989ac..523f2ce 100644 --- a/app.json +++ b/app.json @@ -17,7 +17,7 @@ }, "android": { "adaptiveIcon": { - "backgroundColor": "#F8F4EC", + "backgroundColor": "#F8F4EC", "foregroundImage": "./assets/images/android-icon-foreground.png", "backgroundImage": "./assets/images/android-icon-background.png", "monochromeImage": "./assets/images/android-icon-monochrome.png" @@ -44,7 +44,17 @@ "backgroundColor": "#1D2A24" } } - ] + ], + [ + "expo-image-picker", + { + "photosPermission": "Allow Ruffl to select photos you choose to share in commissions and messages.", + "cameraPermission": false, + "microphonePermission": false + } + ], + "expo-notifications", + "@sentry/react-native" ], "experiments": { "typedRoutes": true, diff --git a/app/(tabs)/inbox.tsx b/app/(tabs)/inbox.tsx index 8ca1239..021aaff 100644 --- a/app/(tabs)/inbox.tsx +++ b/app/(tabs)/inbox.tsx @@ -39,6 +39,8 @@ export default function InboxScreen() { useEffect(() => { void load(); + const interval = setInterval(() => void load(), 5_000); + return () => clearInterval(interval); }, [load]); return ( diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 282400b..7c15342 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -47,6 +47,25 @@ export default function HomeScreen() { void load(); }, [load]); + const markNotificationRead = async (notification: Notification) => { + if (!token || notification.read) return; + setNotifications((current) => + current.map((item) => + item.id === notification.id ? { ...item, read: true } : item, + ), + ); + try { + await api.readNotification(token, notification.id); + } catch (caught) { + setNotifications((current) => + current.map((item) => + item.id === notification.id ? { ...item, read: false } : item, + ), + ); + setError(caught instanceof ApiError ? caught.message : 'Could not update this activity.'); + } + }; + const active = commissions.filter((commission) => ['pending', 'negotiating', 'price_proposed', 'accepted', 'active', 'shipping', 'disputed'].includes( commission.status, @@ -69,7 +88,10 @@ export default function HomeScreen() { {user?.role === 'maker' ? 'Maker studio' : 'Commission desk'} Hi, {user?.displayName.split(' ')[0]} - + {error ? : null} @@ -170,15 +192,22 @@ export default function HomeScreen() { .reverse() .slice(0, 4) .map((notification, index) => ( - void markNotificationRead(notification)} style={[styles.activity, index > 0 && styles.activityBorder]}> - + {notification.title} {notification.body} - + )) )} @@ -220,4 +249,5 @@ const styles = StyleSheet.create({ marginTop: 6, width: 8, }, + activityDotRead: { backgroundColor: colours.line }, }); diff --git a/app/(tabs)/makers.tsx b/app/(tabs)/makers.tsx index 8c3a4d3..3fec055 100644 --- a/app/(tabs)/makers.tsx +++ b/app/(tabs)/makers.tsx @@ -92,7 +92,11 @@ export default function MakersScreen() { - + {user.displayName} diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx index 8ffc422..239f4a2 100644 --- a/app/(tabs)/profile.tsx +++ b/app/(tabs)/profile.tsx @@ -1,11 +1,14 @@ import { Ionicons } from '@expo/vector-icons'; import { router } from 'expo-router'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { useEffect, useState } from 'react'; +import { Platform, Pressable, StyleSheet, Text, View } from 'react-native'; +import { api, ApiError } from '@/src/api/client'; import { Avatar, Button, Card, + ErrorNotice, Eyebrow, Pill, Screen, @@ -13,19 +16,132 @@ import { Title, } from '@/src/components/ui'; import { useSession } from '@/src/context/session'; +import { pickAndUploadImage } from '@/src/services/media-upload'; +import { + disablePushNotifications, + enablePushNotifications, + pushNotificationsEnabled, +} from '@/src/services/push-notifications'; import { colours } from '@/src/theme'; export default function ProfileScreen() { - const { signOut, user } = useSession(); + const { deleteAccount, refresh, signOut, token, user } = useSession(); + const [supportError, setSupportError] = useState(''); + const [openingSupport, setOpeningSupport] = useState(false); + const [pushEnabled, setPushEnabled] = useState(false); + const [pushBusy, setPushBusy] = useState(false); + const [pushError, setPushError] = useState(''); + const [avatarBusy, setAvatarBusy] = useState(false); + const [avatarError, setAvatarError] = useState(''); + const [deleteConfirming, setDeleteConfirming] = useState(false); + const [deleteBusy, setDeleteBusy] = useState(false); + const [deleteError, setDeleteError] = useState(''); + + useEffect(() => { + void pushNotificationsEnabled().then(setPushEnabled); + }, []); + if (!user) return null; + const openSupport = async () => { + if (!token) return; + setOpeningSupport(true); + try { + const { conversation } = await api.supportConversation(token); + setSupportError(''); + router.push({ pathname: '/messages/[id]', params: { id: conversation.id } }); + } catch (caught) { + setSupportError(caught instanceof ApiError ? caught.message : 'Could not contact support.'); + } finally { + setOpeningSupport(false); + } + }; + + const enablePush = async () => { + if (!token) return; + setPushBusy(true); + setPushError(''); + try { + const pushToken = await enablePushNotifications(); + if (!pushToken) { + setPushError('Notification permission was not granted on this device.'); + return; + } + await api.updateMe(token, { pushToken }); + setPushEnabled(true); + } catch (caught) { + setPushError( + caught instanceof Error + ? caught.message + : 'Could not enable push notifications.', + ); + } finally { + setPushBusy(false); + } + }; + + const disablePush = async () => { + if (!token) return; + setPushBusy(true); + setPushError(''); + try { + await api.updateMe(token, { pushToken: '' }); + await disablePushNotifications(); + setPushEnabled(false); + } catch (caught) { + setPushError( + caught instanceof ApiError + ? caught.message + : 'Could not disable push notifications.', + ); + } finally { + setPushBusy(false); + } + }; + + const updateAvatar = async () => { + if (!token) return; + setAvatarBusy(true); + setAvatarError(''); + try { + const attachment = await pickAndUploadImage(token, 'avatar'); + if (!attachment) return; + await api.updateMe(token, { avatarUrl: attachment.url }); + await refresh(); + } catch (caught) { + setAvatarError( + caught instanceof ApiError || caught instanceof Error + ? caught.message + : 'Could not update the profile image.', + ); + } finally { + setAvatarBusy(false); + } + }; + + const removeAccount = async () => { + setDeleteBusy(true); + setDeleteError(''); + try { + await deleteAccount(); + } catch (caught) { + setDeleteError( + caught instanceof ApiError + ? caught.message + : 'Could not delete this account.', + ); + } finally { + setDeleteBusy(false); + } + }; + return ( Your Ruffl Account - + {user.displayName} {user.email} @@ -33,6 +149,14 @@ export default function ProfileScreen() { {user.bio ? {user.bio} : null} + {avatarError ? : null} +