Plan. Show up. Repeat.
I made Pull Up? so that making spontaneous plans would specifically become easier in college. You post a plan, your friends join, and everyone proves they actually showed up with a BeReal-style dual-camera photo. It has the features of every social media such as streaks, profiles, reactions; but to actually do something healthy instead of spending hours scrolling: going outside to do real activities with your friends. It's built as an iOS app (SwiftUI) talking to a Node/Express backend over REST + WebSockets, with Postgres for storage, Cloudinary for images, APNs for push notifications, and Resend for transactional email.
- What it does
- Repo layout
- Architecture at a glance
- The iOS app
- The backend
- Running it locally
- Deployment
- Known limitations
- Plans — post a plan ("pizza tonight, 8pm"), optionally attach it to a group, and friends can join/leave. The plan's host can lock it once it's underway (freezing who's "in") and unlock it again.
- Pull-up photos — once you show up, you open the in-app dual camera (front + back, like BeReal) and post proof you pulled up. This also marks your attendance as "done," which locks the plan against deletion.
- Friends — send/accept/decline/cancel friend requests, remove friends, search by username/name, and get "suggested friends" ranked by mutual-friend count.
- Groups — create a group of friends, add/remove members, and post plans scoped to just that group instead of your whole friends list.
- Reactions — emoji-react to a plan or to a pull-up photo.
- Streaks — a personal daily pull-up streak, plus a shared streak with a specific friend (days you both pulled up to the same plan), both computed in the user's local timezone.
- Push notifications — APNs alerts for friend requests, new attendees, reactions, cancellations, etc., tapping one deep-links you to the right tab.
- Moderation & safety — profanity filtering on names/titles, automatic image moderation (nudity/violence/drugs/etc. via Cloudinary + AWS Rekognition) on every upload, blocking, and reporting (users/posts/photos).
- Account management — email/password auth with verification, Google Sign-In, Sign in with Apple, forgot/reset password, change password, delete account.
pullup/
├── Pull Up/ # iOS app (SwiftUI, Xcode project)
└── pullup-backend/ # Node/Express/TypeORM API + Socket.IO server
┌─────────────────────┐ REST (JSON over HTTPS) ┌──────────────────────────┐
│ │ ───────────────────────────────────────▶│ │
│ Pull Up (iOS) │ │ Express API │
│ SwiftUI + Combine │◀─────────── WebSocket (Socket.IO) ───────│ + Socket.IO server │
│ │ live push of state changes │ │
└──────────┬───────────┘ └────────────┬─────────────┘
│ │
│ push notifications TypeORM (Postgres)
▼ ▼
┌───────────────┐ ┌──────────────────────┐
│ Apple APNs │ │ Postgres database │
└───────────────┘ └──────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌──────────┐ ┌───────────┐ ┌─────────────┐
│Cloudinary│ │ Resend │ │ Google/Apple│
│ (images +│ │ (emails) │ │ OAuth │
│moderation)│ └───────────┘ │ verification│
└──────────┘ └─────────────┘
Every mutation (join a plan, react, add a friend, etc.) follows the same two-step pattern:
- The client makes a REST call and gets a response for its own UI to update immediately.
- The server then emits a Socket.IO event to every other affected user (friends, group members, the post's author) so their UI updates live, without polling.
APNs push notifications are a third channel, layered on top for the case where the recipient's app isn't in the foreground to receive the socket event.
| Layer | Choice |
|---|---|
| UI | SwiftUI, NavigationStack, @AppStorage/@StateObject/@EnvironmentObject |
| Reactive state | Combine (@Published, .sink) |
| Networking | URLSession (async/await), hand-rolled NetworkManager — no third-party HTTP client |
| Real-time | socket.io-client-swift (over Starscream WebSockets) |
| Image loading/caching | Kingfisher |
| Camera | AVFoundation (dual AVCaptureSessions, front + back) |
| Auth providers | GoogleSignIn-iOS, native AuthenticationServices (Sign in with Apple) |
| Secrets storage | Security framework (Keychain) for the JWT |
| Misc | CoreLocation (city-level location for context), Network (NWPathMonitor for online/offline banner), ConfettiSwiftUI |
Pull Up/
├── Core/ Pull_UpApp.swift — app entry point, root navigation, login-state routing
├── Models/ Codable structs mirroring the backend's JSON shapes (User, Post, Reaction, ...)
├── Services/ Singletons: NetworkManager, SocketService, KeychainManager, CameraManager,
│ BadgeManager, LocationManager, NetworkMonitor, AppDelegate, CardRenderer, ImageStorage
├── ViewModels/ One per feature area (HomeViewModel, PlansViewModel, FriendsViewModel, ...)
├── Views/
│ ├── Login/ Onboarding, login/signup, forgot/reset password, set-username, MainView (tab root)
│ ├── Home/ Feed of plans, create-plan sheet, settings
│ ├── Plans/ "My plans" list, the dual pull-up camera, camera preview
│ ├── Friends/ Friends list, add-friends/search, create/edit group
│ ├── Pulled Up/ Feed of everyone's pull-up photos
│ └── Detailed/ Shared subviews — attendee lists, reactions UI, user profile, report flow, share card, etc.
├── Resources/ Fonts (Montserrat, Bricolage Grotesque), image assets, app icon, launch screen
└── Utils/ Constants.swift — design tokens (colors, fonts, shadow/glass modifiers)
Each feature area is a View + ViewModel pair — the ViewModel owns @Published state, talks to NetworkManager for REST calls, and registers with SocketService for live updates. MainView hosts the four main tabs (Home, Plans, Pulled Up, Friends) inside a NavigationStack, and Pull_UpApp decides what to show at the root based on login state (onboarding → login → set-username-if-needed → main app), wrapped by a SplashView and a persistent offline banner driven by NetworkMonitor.
SocketService is a singleton that wraps a single socket.io-client-swift connection to the backend (wss://api.hellopullup.app in release, a Railway dev environment in debug builds). It's designed around a multi-subscriber pattern: each event name (new_post, new_attendee, friend_request_accepted, new_reaction, ...) has its own array of handler closures. Multiple ViewModels can call e.g. SocketService.shared.onNewPost { ... } independently, and the first subscriber for that event registers the actual socket.on(...) listener with socket.io-client-swift — every handler added after that just gets appended to the array and fanned out to on the main thread when an event arrives. This avoids re-registering (and double-firing) the underlying listener every time a view appears.
Connecting: after login, Pull_UpApp calls SocketService.shared.connect(userId:). On the connect client event, it emits a bare join event with the user's id. The server (src/index.ts) listens for that and does:
socket.on('join', (userId) => socket.join(`user_${userId}`))This puts the client's socket into a Socket.IO room named user_<id>. From then on, the server never needs to track individual socket ids — it just emits to user_${someUserId} and Socket.IO delivers to whichever socket(s) that user currently has joined to that room (multiple devices/sessions all just work). On logout, the client calls disconnect().
Receiving events: every mutation route on the backend follows the same shape — do the DB write, respond to the caller's REST request, then look up who else cares (friends of the author, group members, the post's author, etc.) and io.to('user_<id>').emit(eventName, payload) to each of them. On the client, the relevant ViewModel's SocketService.shared.on<Event> { payload in ... } handler decodes the raw [String: Any] dictionary into a typed model and mutates its own @Published array — SwiftUI re-renders automatically. For example:
- Post a plan →
posts.tsemitsnew_postto every accepted friend (or every group member, if the plan is group-scoped) → theirHomeViewModelprepends it to the feed. - Join a plan →
attendance.tsemitsnew_attendeeto the author's friends and a separatejoined_postback to the joiner (so their own "My Plans" list updates) → also triggers an APNs push to the author if they're not looking at the app. - React to a photo →
reactions.tsemitsnew_reaction_phototo the photo's author, the reactor, and the author's friends. - Delete your account →
users.tsemitsaccount_deleted_selfto your own socket (so the client can immediately log you out) and a broadcastaccount_deletedto everyone, so any client currently rendering your posts/reactions can prune them.
This is a classic pub/sub over rooms design: the REST API is the source of truth and the thing that actually mutates state, while Socket.IO is purely a push channel telling already-connected clients "go re-fetch/patch this" — nothing is ever mutated purely via a socket event.
AppDelegate requests notification permission, registers for remote notifications, and on receiving an APNs device token, POSTs it to /users/device-token so the backend can address this device directly. The backend uses @parse/node-apn (src/services/notificationService.ts) to send alerts for things that matter even when the app isn't open — new friend request, someone joined/left your plan, a reaction, a cancelled plan. Each push carries a type (and sometimes a postId/photoId) in its payload; AppDelegate.userNotificationCenter(_:didReceive:) reads that type and posts an NSNotification (navigateToPlans/navigateToFriends/navigateToFeed/navigateToPulledUp) that the relevant view listens for, so tapping a push deep-links straight to the right tab.
CameraManager runs two live AVCaptureSessions simultaneously (front + back camera) so switching cameras in the capture UI is instant rather than re-configuring a session on the fly. PullUpCameraView/CameraPreviewView drive the capture flow; captured images are held in ImageStorage (backed by UserDefaults, keyed by post id) while the user reviews/retakes, then both images are uploaded via NetworkManager.uploadImage(_:folder:) (multipart POST to /upload) before creating the PullUpPhoto record. CardRenderer can render a shareable image (via ImageRenderer + ShareCardView) for sharing a pull-up outside the app.
NetworkManager stores the backend's JWT in the iOS Keychain (KeychainManager) rather than UserDefaults, and attaches it as a Bearer token on every authenticated request. LoginViewModel supports four sign-in paths — email/password, Google (GIDSignIn → ID token → /auth/google), Apple (native AuthenticationServices → identity token → /auth/apple), and, for either OAuth path, a one-time SetUsernameView if the backend reports needsUsername: true (OAuth accounts don't have a username until the user picks one).
BadgeManager is a separate observable that watches the various ViewModels' @Published arrays via Combine (.sink) and derives "is there something new to look at" booleans per tab (new posts, new friend requests, new attendees on your plans, new reactions, etc.), persisting them to UserDefaults so unread dots survive an app relaunch. It's intentionally decoupled from the sockets themselves — it just reacts to whatever the ViewModels' state ends up being, regardless of whether that state came from a REST fetch or a socket push.
| Layer | Choice |
|---|---|
| Runtime | Node.js, TypeScript |
| HTTP framework | Express 5 |
| Real-time | Socket.IO server |
| ORM / DB | TypeORM → PostgreSQL |
| Auth | JWT (jsonwebtoken), bcrypt password hashing |
| OAuth verification | google-auth-library (Google ID tokens), apple-signin-auth (Apple identity tokens) |
| Image storage + moderation | Cloudinary (upload_stream with AWS Rekognition moderation add-on) |
| Transactional email | Resend |
| Push notifications | @parse/node-apn (APNs) |
| Content filtering | bad-words |
| Security | helmet, express-rate-limit, trust proxy (for Railway's reverse proxy) |
| File uploads | multer (in-memory, streamed straight to Cloudinary) |
TypeORM entities under src/entities/:
- User — email/username/password (bcrypt hash, excluded from default
SELECTs), OAuth ids (googleId/appleId), verification + password-reset tokens, APNs device token, timezone, profile image. - Post — a plan: title, time (freeform string, not a
Date),goingcount,isLocked, optionalgroup,author. - Attendance — join table between
UserandPost;isDonemarks that the attendee posted proof (aPullUpPhoto) and locks the post against deletion. - Friendship —
requester/receiver+status(pending/accepted); one row represents the whole relationship, direction only matters for who sent the request. - Group / GroupMember — a named group of friends a plan can be scoped to; soft-deleted (
isDeletedflag) rather than hard-deleted. - PullUpPhoto — the front+back proof photo pair, tied to the
Postit was taken for. - Reaction — a single emoji reaction, polymorphic-ish: tied to either a
Postor aPullUpPhoto(whichever is non-null). - Block / Report — safety primitives; blocking also tears down the friendship and shared-group membership between the two users.
REST routes, all mounted in src/index.ts, JWT-protected via the authenticate middleware unless noted:
| Route prefix | Covers |
|---|---|
/auth |
signup, email verification, login, Google/Apple sign-in, set-username, forgot/reset/change password |
/posts |
CRUD on plans, lock/unlock, mark done/undone |
/posts/:id/join, /leave (mounted via attendance.ts) |
joining/leaving a plan |
/friends |
requests (send/accept/decline/cancel), list, pending/sent, remove, suggested |
/groups |
create/update/delete (soft), add/remove members |
/pullupphotos |
post a pull-up photo, feed, per-user photos |
/reactions |
react/unreact to a post or photo |
/streaks |
personal streak, friend streak, another user's streak |
/users |
search, profile get/update/delete, device token, timezone, public user count |
/upload |
multipart image upload → Cloudinary |
/reports, /blocks |
safety features |
Email/password signup validates email format, rejects disposable-email domains (checked against a list refreshed from a public source every 24h), enforces a password-complexity regex, checks the username format and profanity, then creates the user unverified and emails a verification link (Resend) — login is blocked until that link is clicked. Google/Apple sign-in verifies the provider's token server-side, auto-creates a verified account on first sign-in (since the provider already vouches for the email), and tells the client needsUsername: true if this is a brand-new account so the client can show the one-time username picker. All three paths end in the same place: a JWT ({ userId }, 30-day expiry) that the client stores in the Keychain and sends as Authorization: Bearer <token> on every subsequent call.
- Text: usernames, display names, plan titles/times are checked against a profanity filter before being saved.
- Images: every upload goes through Cloudinary's moderation add-on with per-category thresholds (nudity, violence, drugs, hate symbols, etc.) — anything flagged
rejectedis bounced back to the client with an error instead of being stored. - Blocking: blocking someone immediately tears down any friendship, removes them from each other's groups, and pulls them off each other's upcoming (not-yet-done) plans, with socket events keeping both sides' UIs in sync.
- Reporting: any user/post/photo can be reported with a reason + free-text details; the backend emails the team (via Resend) on every new report.
- Rate limiting: a global limiter (500 req / 15 min / IP) plus a much tighter one (10 req / 15 min / IP) specifically on
/auth/login,/auth/signup, and/auth/forgot-passwordto blunt brute-forcing and spam signups.
The backend never emits blindly to everyone — for each mutation it computes the exact audience (accepted friends of the relevant user, members of the relevant group, or both) before calling io.to(...). A few representative examples:
- New plan: if it's scoped to a group, every group member except the poster gets
new_post; otherwise every accepted friend does. - Someone joins: the author's friends get
new_attendee(so a shared feed of "who's doing what" stays current even for people not attending), the joiner's own client getsjoined_postfor their personal plans list, and the author gets an APNs push if their attendance isn't already marked done (once it's done, the plan is "locked" from a notification standpoint too). - Account deletion: cascades through attendances, reactions, group memberships, owned groups (and their posts), friendships, and authored posts inside a single DB transaction, then notifies every affected friend/group member over sockets so nothing stale lingers in their UI.
- Xcode (for the iOS app) — Swift Package Manager dependencies resolve automatically on first build.
- Node.js + npm (for the backend).
- A PostgreSQL database (local or remote).
cd pullup-backend
npm install
cp .env.example .env # fill in DB_*, JWT_SECRET, CLOUDINARY_*, RESEND_API_KEY, APNS_*, GOOGLE_CLIENT_ID, APPLE_BUNDLE_ID, BACKEND_URL
npm run dev # ts-node, auto schema sync in non-production (TypeORM `synchronize`)The server listens on PORT (default 3000) and exposes both the REST API and the Socket.IO endpoint from the same HTTP server.
- Open
Pull Up.xcodeprojin Xcode. - Let Swift Package Manager resolve dependencies (Kingfisher, socket.io-client-swift, GoogleSignIn-iOS, ConfettiSwiftUI).
- Point
NetworkManager.baseURL/SocketService'ssocketURLat your local backend if you're not using the hosted dev environment (they're currently compiled per-build-configuration via#if DEBUG). - Build and run on a simulator or device.