

The core of Shiftolic is the shift post lifecycle — a doctor or locum company posts an open shift, physicians apply, and the posting party approves a provider. Two coverage types are supported: Drop (someone takes the shift outright) and Swap (a mutual exchange of dates between two doctors).
Each shift post embeds an array of ShiftDate subdocuments representing up to seven individual dates, each carrying its own time, shift type (day/night/swing), coverage state, and applicant list. Applicants are tracked in a coveredByIds array on each date, and once one is approved the coveredBy field is set and the date is locked.
One nuanced constraint required careful implementation: once any shift date has been claimed by a provider, the coverage type (drop vs swap) can no longer be changed for that post. This is enforced at both the backend — where the aggregation pipeline adds a hasClaimedDates boolean computed from the shiftDates array — and at the frontend, where the coverage toggle cards receive a disabled prop and a contextual lock hint is displayed.
CalendarFeedToken — a UUID stored in MongoDB. The URL is unauthenticated by design (calendar apps can't send auth headers), secured by the token's entropy. Doctors subscribe in Google Calendar, Apple Calendar, or Outlook for passive shift sync.



Doctors can create and join shift trading groups — private or public — organized around a hospital or department. Groups act as a scoped distribution layer: shift posts can be targeted at up to three specific groups rather than (or in addition to) the open marketplace. This lets physicians coordinate coverage within trusted peer networks.
Group invitations support both registered users and non-users (via email or SMS). When a non-user accepts an invitation via the link in their email or text, their account is pre-associated with the group on registration.
Every shift application opens a conversation scoped to that shift post between the applicant and the posting doctor. Messages are delivered in real time via WebSocket and persisted to MongoDB. If the recipient is offline, an Expo push notification is dispatched as a fallback. Each user can independently archive or delete a conversation without affecting the other party's view.
SIDEBAR_MENU_ITEMS called as a plain function — but it internally used useCurrentPlan(), a React hook, causing "must be used within provider" errors on SSR and fast refresh. Renamed to useSidebarMenuItems(), called inside the component tree, and the result passed down as a prop — eliminating the error class entirely.passwordHash on their record. Every credential-sensitive operation (password change, account deletion) must first determine which path the user registered with via the hasPassword API.password optional in the DTO, skipped the hash check when no passwordHash exists, and built a frontend modal that conditionally renders the password field only for email users after fetching hasPassword on modal open.verificationStatus on DoctorProfile (UNVERIFIED → PENDING → VERIFIED). Document images are stored in S3 and never served publicly.JWT-based authentication issues short-lived access tokens (1 day) and longer-lived refresh tokens (7 days). All passwords are bcrypt-hashed and excluded from all API responses via Mongoose's select: false directive — they cannot be accidentally leaked in any query response.
Google OAuth is implemented separately for the doctor portal, locum portal, and mobile app — each with its own redirect URI registered in Google Cloud Console. The mobile app uses @react-native-google-signin with platform-specific client IDs for iOS and Android.
Beyond identity verification, doctors can upload a full set of credentialing documents — medical license, board certification, DEA certificate, malpractice insurance, and resume/CV. Each document type stores an array of file references (S3 URL, file name, upload timestamp). All uploads and deletes are audit-logged in a separate CredentialHistory collection



The mobile app is a React Native + Expo application targeting both iOS and Android from a single codebase. It mirrors the doctor web portal's feature set — shift hub, marketplace, groups, messages, credentials, push notifications, and profile management.
Builds are managed via Expo EAS Build, which handles iOS code signing and Android keystore management without manual certificate setup. Submissions to both stores use EAS Submit. The Apple Developer Account and Google Play Console are owned by the client; the development team operated under delegated access.
The first App Store submission was rejected under Guideline 5.1.1(ii): the camera and photo library permission strings were generic defaults from Expo's build system — "Allow Shiftolic to access your camera." Apple requires strings that explain the specific use with a concrete example.
The fix involved a careful audit of the codebase to determine exactly where each permission is used. Camera and photo library are used only for profile photo capture — not for ID verification (which uses the system document picker, requiring no permission string) and not for credentialing. The infoPlist block in app.config.ts was updated with precise, example-driven strings for all four permission types.
DeviceTokens collection keyed by userId + token, with an isActive flag set to false when Expo reports an invalid token. Notifications fan out to all active tokens for a user, so multi-device users receive on all devices simultaneously.T12:00:00.000Z instead of midnight. A date stored at midnight UTC becomes "the previous day" for UTC+n users. Storing at noon means no timezone offset — positive or negative — can cross a day boundary. Nine instances of moment.utc(date) were replaced with a dateStringToNoonUTC() helper across the shift post service.https:// invitation links to http:// before the user clicks. This produced "site doesn't support a secure connection" warnings in Chrome. The fix: disable Click Tracking in SendGrid dashboard → Settings → Tracking.$addFields stage was added to the getMyShiftPosts aggregation pipeline, computing hasClaimedDates as a boolean from the shiftDates.coveredBy array — zero additional database round-trips.