Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 212 additions & 0 deletions .github/workflows/mobile-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
name: mobile-build

# Build the mobile artifacts (Android AAB, iOS archive) from the wrapped SPA.
#
# Notes on what this workflow does and does not do:
# * It always rebuilds the web bundle from `frontend/` and stores it as an
# artifact, so anyone can grab `web-dist` even if the native jobs fail.
# * Android: produces a signed AAB if the keystore secrets are present, an
# unsigned debug AAB otherwise.
# * iOS: produces a signed IPA only if the App Store secrets are present.
# Without them, the job still archives the project unsigned and uploads
# the .xcarchive so an operator can sign it locally with Fastlane.
#
# Required GitHub Actions secrets (set under Settings → Secrets → Actions):
# ANDROID_KEYSTORE_BASE64 base64 of the upload .jks keystore
# ANDROID_KEYSTORE_PASSWORD keystore password
# ANDROID_KEY_ALIAS key alias inside the keystore
# ANDROID_KEY_PASSWORD key password
# GOOGLE_PLAY_JSON_KEY service-account JSON for Play Console
# APPLE_TEAM_ID 10-char Apple Developer team ID
# FASTLANE_USER Apple ID
# FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD
# MATCH_PASSWORD passphrase for fastlane match
# MATCH_GIT_URL private repo URL with signing material

on:
push:
branches: [main, 'feature/**']
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
VITE_API_BASE_URL: https://careerflow-api-production.up.railway.app/api/v1

jobs:
build-web:
name: Build web bundle
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: frontend/package-lock.json

- name: Install
working-directory: frontend
run: npm ci

- name: Lint, typecheck, test
working-directory: frontend
run: |
npm run lint
npm run typecheck
npm test

- name: Build
working-directory: frontend
run: npm run build

- name: Upload web bundle
uses: actions/upload-artifact@v4
with:
name: web-dist
path: frontend/dist
retention-days: 14

build-android:
name: Build Android AAB
needs: build-web
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: frontend/package-lock.json

- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'

- uses: android-actions/setup-android@v3
with:
packages: 'platform-tools platforms;android-34 build-tools;34.0.0'

- name: Install frontend
working-directory: frontend
run: npm ci

- name: Add Capacitor & Android platform
working-directory: frontend
run: |
npm install --save-dev @capacitor/cli@^6
npm install --save @capacitor/core@^6 @capacitor/android@^6
npm run build:web
npx cap add android || true
npx cap sync android

- name: Decode upload keystore
if: env.ANDROID_KEYSTORE_BASE64 != ''
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > frontend/android/app/upload.keystore

- name: Build release bundle
working-directory: frontend/android
env:
ANDROID_KEYSTORE_FILE: upload.keystore
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
if [ -n "$ANDROID_KEYSTORE_PASSWORD" ]; then
./gradlew bundleRelease \
-Pandroid.injected.signing.store.file=upload.keystore \
-Pandroid.injected.signing.store.password="$ANDROID_KEYSTORE_PASSWORD" \
-Pandroid.injected.signing.key.alias="$ANDROID_KEY_ALIAS" \
-Pandroid.injected.signing.key.password="$ANDROID_KEY_PASSWORD"
else
echo "::warning::No keystore secrets — building unsigned debug bundle."
./gradlew bundleDebug
fi

- name: Upload AAB
uses: actions/upload-artifact@v4
with:
name: careerflow-android-aab
path: |
frontend/android/app/build/outputs/bundle/release/*.aab
frontend/android/app/build/outputs/bundle/debug/*.aab
if-no-files-found: warn
retention-days: 14

build-ios:
name: Build iOS archive
needs: build-web
runs-on: macos-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: frontend/package-lock.json

- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2'
bundler-cache: false

- name: Install frontend
working-directory: frontend
run: npm ci

- name: Add Capacitor & iOS platform
working-directory: frontend
run: |
npm install --save-dev @capacitor/cli@^6
npm install --save @capacitor/core@^6 @capacitor/ios@^6
npm run build:web
npx cap add ios || true
npx cap sync ios

- name: Install Fastlane
working-directory: fastlane/ios
run: |
gem install bundler
bundle install

- name: Build (signed if secrets, unsigned archive otherwise)
env:
FASTLANE_USER: ${{ secrets.FASTLANE_USER }}
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
working-directory: fastlane/ios
run: |
if [ -n "$MATCH_PASSWORD" ] && [ -n "$MATCH_GIT_URL" ]; then
bundle exec fastlane build
else
echo "::warning::No Apple signing secrets — producing an unsigned xcarchive."
cd ../../frontend/ios/App
xcodebuild \
-workspace App.xcworkspace \
-scheme App \
-configuration Release \
-archivePath "$GITHUB_WORKSPACE/build/CareerFlow.xcarchive" \
CODE_SIGNING_ALLOWED=NO \
archive || true
fi

- name: Upload IPA / archive
uses: actions/upload-artifact@v4
with:
name: careerflow-ios
path: |
fastlane/build/ios/*.ipa
build/CareerFlow.xcarchive
if-no-files-found: warn
retention-days: 14
27 changes: 22 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Stop losing track of your job search across spreadsheets and inboxes — manage
from wishlist to offer, in one professional workspace.

[![CI](https://img.shields.io/badge/CI-GitHub_Actions-2088FF?logo=githubactions&logoColor=white)](.github/workflows/ci.yml)
[![Mobile build](https://img.shields.io/badge/Mobile-iOS_·_Android-000?logo=apple&logoColor=white)](.github/workflows/mobile-build.yml)
[![Backend](https://img.shields.io/badge/Python-3.11_·_3.12-3776AB?logo=python&logoColor=white)](backend/)
[![Frontend](https://img.shields.io/badge/React_18-TypeScript-3178C6?logo=react&logoColor=white)](frontend/)
[![Coverage](https://img.shields.io/badge/backend_coverage-97%25-brightgreen)](backend/)
Expand Down Expand Up @@ -52,7 +53,16 @@ reminders, and analytics on what's converting.

## Try it in 30 seconds

**Live demo:** [frontend-jade-two-zfchqjb5ws.vercel.app](https://frontend-jade-two-zfchqjb5ws.vercel.app) — open it and press **▶ Try demo — no signup needed** on the login screen.
**One link, every platform:**
[**app.careerflow.app/install**](https://frontend-jade-two-zfchqjb5ws.vercel.app/install) — the page detects your device and points you at the App Store, Google Play, or the PWA install prompt.

**Live demo on the web:** [frontend-jade-two-zfchqjb5ws.vercel.app](https://frontend-jade-two-zfchqjb5ws.vercel.app) — open it and press **▶ Try demo — no signup needed** on the login screen.

[![App Store](https://img.shields.io/badge/App%20Store-coming%20soon-000?logo=apple&logoColor=white)](https://frontend-jade-two-zfchqjb5ws.vercel.app/install)
[![Google Play](https://img.shields.io/badge/Google%20Play-coming%20soon-000?logo=googleplay&logoColor=white)](https://frontend-jade-two-zfchqjb5ws.vercel.app/install)
[![Install PWA](https://img.shields.io/badge/Install%20PWA-from%20your%20browser-4f46e5)](https://frontend-jade-two-zfchqjb5ws.vercel.app)

> Native iOS and Android apps build from the same codebase via Capacitor — see [`docs/app-store-submission.md`](docs/app-store-submission.md) for how to ship them.

Want your own copy? Deploy the full stack (API + Postgres + web) with a single click:

Expand Down Expand Up @@ -89,7 +99,9 @@ It then launches in its own window, works offline for the shell, and shows up al
- 📝 **Notes** — Markdown notes per application.
- 📎 **Attachments** — secure resume/cover-letter uploads (validated, owner-only download).
- 📊 **Dashboard & analytics** — headline stats, upcoming interviews, pending tasks, and Recharts visualizations (applications over time, status & industry distribution, conversion rates).
- 📤 **CSV export** — download your whole pipeline as an RFC-4180 CSV (Excel/Sheets-ready) in one click.
- ⚙️ **Settings** — update your profile and change your password.
- 📱 **Installable PWA + mobile UI** — install to a phone/desktop home screen; on small screens the sidebar becomes a slide-in drawer.
- 🌗 **Polished UX** — responsive layout, light/dark themes, Zod-validated forms, and proper loading / empty / error states throughout.

## Screenshots
Expand All @@ -100,13 +112,18 @@ It then launches in its own window, works offline for the shell, and shows up al
<img src="docs/screenshots/demo.gif" alt="CareerFlow walkthrough: dashboard, pipeline board, application detail, tasks, analytics" width="780" />
</p>

| Pipeline board | Application detail |
| Dashboard | Pipeline board |
| --- | --- |
| <img src="docs/screenshots/applications-board.png" alt="Kanban pipeline board" /> | <img src="docs/screenshots/application-detail.png" alt="Application detail with interviews, notes, attachments" /> |
| <img src="docs/screenshots/dashboard.png" alt="Dashboard with headline stats, upcoming interviews and pending tasks" /> | <img src="docs/screenshots/applications-board.png" alt="Kanban pipeline board" /> |
| **Application detail** | **Analytics** |
| <img src="docs/screenshots/application-detail.png" alt="Application detail with interviews, notes, attachments" /> | <img src="docs/screenshots/analytics.png" alt="Analytics charts" /> |
| **Offers** | **Interviews** |
| <img src="docs/screenshots/offers.png" alt="Offers with salary, bonus, and decision status" /> | <img src="docs/screenshots/interviews.png" alt="Global interviews list" /> |
| **Analytics** | **Light theme** |
| <img src="docs/screenshots/analytics.png" alt="Analytics charts" /> | <img src="docs/screenshots/dashboard-light.png" alt="Dashboard in light mode" /> |

<p align="center">
<img src="docs/screenshots/mobile-dashboard.png" alt="CareerFlow on mobile with the slide-in navigation drawer" width="300" />
</p>
<p align="center"><sub>On phones the sidebar collapses into a slide-in drawer — the app installs to your home screen as a PWA.</sub></p>

## Tech stack

Expand Down
6 changes: 4 additions & 2 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ REFRESH_TOKEN_EXPIRE_MINUTES=10080
# Password hashing cost (bcrypt rounds)
BCRYPT_ROUNDS=12

# CORS — comma-separated list of allowed SPA origins
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# CORS — comma-separated list of allowed SPA origins.
# The Capacitor entries let the iOS/Android mobile wrapper talk to a local
# backend; remove them if you don't ship a mobile build.
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,https://localhost,http://localhost,capacitor://localhost,ionic://localhost

# Error reporting (optional). Leave blank to disable Sentry.
SENTRY_DSN=
Expand Down
15 changes: 13 additions & 2 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,20 @@ class Settings(BaseSettings):

# --- CORS --------------------------------------------------------------
# ``NoDecode`` stops pydantic-settings from JSON-decoding the env value so
# the validator below can accept a plain comma-separated string.
# the validator below can accept a plain comma-separated string. Defaults
# cover the Vite dev server plus the Capacitor wrapper's WebView origins
# (https://localhost on Android, capacitor://localhost on iOS) so the
# mobile app talks to a local backend out of the box.
cors_origins: Annotated[list[str], NoDecode] = Field(
default_factory=lambda: ["http://localhost:5173"], alias="CORS_ORIGINS"
default_factory=lambda: [
"http://localhost:5173",
"http://127.0.0.1:5173",
"https://localhost",
"http://localhost",
"capacitor://localhost",
"ionic://localhost",
],
alias="CORS_ORIGINS",
)

# --- Observability -----------------------------------------------------
Expand Down
Loading
Loading