Base URL:
http://localhost:4000
Auth:/healthand/auth/*are public; all other endpoints requireAuthorization: Bearer <JWT>header
Content-Type:application/json
Flately supports two sign-in modes:
- Email/password via
/auth/signupand/auth/login - Google OAuth via
/auth/google/start->/auth/google/callback->/auth/google/exchange
All protected endpoints use local JWT validation middleware:
Authorization: Bearer <access_token>
Tokens are issued by:
POST /auth/signupPOST /auth/login
The frontend transport layer uses native fetch with explicit Adapter + Strategy roles.
- Replaced Axios transport with Adapter + Strategy in
api.ts - Kept existing service call contract unchanged, so feature modules still call
apiRequest(...) - Preserved auth token injection and one-shot 401 unauthorized handling behavior
- Added a structured manual error model (
ApiError) so existing UI error mapping still works
Pattern fit for transport:
- Strategy:
FetchRequestStrategyhandles low-level HTTP execution - Adapter:
HttpClientAdapteradapts app-level request config to the strategy and centralizes cross-cutting auth behavior
Create an email/password account.
Request Body:
{
"email": "user@example.com",
"password": "Password123!",
"name": "John Doe"
}Response (201):
{
"accessToken": "<jwt>",
"user": {
"id": "665f1a2b3c4d5e6f7a8b9c0d",
"email": "user@example.com",
"name": "John Doe",
"picture": null
}
}Error Responses:
400—Email and password are required409—EMAIL_ALREADY_EXISTS500—AUTH_STORAGE_CONFLICT
Authenticate an existing email/password account.
Request Body:
{
"email": "user@example.com",
"password": "Password123!"
}Response (200):
{
"accessToken": "<jwt>",
"user": {
"id": "665f1a2b3c4d5e6f7a8b9c0d",
"email": "user@example.com",
"name": "John Doe",
"picture": null
}
}Error Responses:
400—Email and password are required401—INVALID_CREDENTIALS
The middleware validates the token with JWT_ACCESS_SECRET and extracts req.userId from payload.sub when it is an ObjectId-compatible value.
Start Google OAuth login/signup flow.
Optional query params:
source:login,signup, orquestionnaireredirectOrigin: frontend origin (for examplehttp://localhost:5174)
Behavior:
- If Google OAuth is configured, redirects to Google consent.
- If not configured, redirects back to frontend
/loginwitherror=GOOGLE_OAUTH_NOT_CONFIGURED.
OAuth callback endpoint used by Google.
Expected query params:
codestate
Behavior:
- Validates OAuth state.
- Exchanges authorization code with Google.
- Resolves user by
googleIdor email and issues JWT session. - Redirects to frontend
/auth/callback?code=<one-time-exchange-code>.
Exchange one-time code for a standard Flately auth session.
Input:
code(required, query param in canonical flow)
Compatibility behavior:
- Controller also reads body
codefor mixed-client compatibility.
Success response (200):
{
"accessToken": "<jwt>",
"user": {
"id": "665f1a2b3c4d5e6f7a8b9c0d",
"email": "user@example.com",
"name": "John Doe",
"picture": "https://lh3.googleusercontent.com/..."
}
}Error responses:
400—GOOGLE_AUTH_CODE_MISSING400—GOOGLE_EXCHANGE_CODE_INVALID
Return a signed Cloudinary payload for authenticated image uploads.
Auth: Required
Request body:
{}Success response (200):
{
"cloudName": "demo",
"apiKey": "123456789012345",
"folder": "flately/profiles",
"timestamp": 1775427000,
"signature": "<sha1-signature>"
}Error responses:
401—Unauthorized503—CLOUDINARY_NOT_CONFIGURED500—UPLOAD_SIGNATURE_FAILED
No auth required. Returns server status.
Response:
{ "status": "ok" }Get or create the authenticated user's record. Called automatically after a successful login/signup session bootstrap.
Auth: Required
Middleware: checkJwt → attachUserId → getUserProfile
Logic:
- Extract
sub,email,name,picturefrom JWT payload - Look up User by
id == payload.sub - If not found and email exists -> create new User by email
- Return a sanitized user object (never returns
passwordHash)
Response (200):
{
"id": "665f1a2b3c4d5e6f7a8b9c0d",
"email": "user@example.com",
"name": "John Doe",
"picture": "https://lh3.googleusercontent.com/...",
"createdAt": "2026-03-20T10:30:00.000Z",
"updatedAt": "2026-03-20T10:35:00.000Z"
}Error Responses:
401— Missing or invalid JWT500— Internal server error
Get the authenticated user's profile.
Auth: Required
Response (200):
{
"id": "665f1a2b3c4d5e6f7a8b9c0e",
"userId": "665f1a2b3c4d5e6f7a8b9c0d",
"name": "John Doe",
"age": 26,
"gender": "male",
"bio": "Looking for a clean roommate...",
"photos": ["https://..."],
"city": "San Francisco",
"hasRoom": true,
"occupation": "professional",
"sleepSchedule": "night-owl",
"noiseLevel": 3,
"guestPolicy": "sometimes",
"smoking": "no",
"pets": "love",
"onboardingCompleted": true,
"createdAt": "2026-03-20T10:30:00.000Z",
"updatedAt": "2026-03-20T10:35:00.000Z"
}Returns null if no profile exists yet.
Create or update the authenticated user's profile. Used by the onboarding flow.
Auth: Required
Request Body:
{
"name": "John Doe",
"age": 26,
"gender": "male",
"bio": "Looking for a clean roommate",
"photos": ["https://..."],
"city": "San Francisco",
"hasRoom": true,
"occupation": "professional",
"sleepSchedule": "night-owl",
"noiseLevel": 3,
"guestPolicy": "sometimes",
"smoking": "no",
"pets": "love"
}All fields are optional. Undefined fields are stripped before save. onboardingCompleted is automatically set to true.
Logic:
- Extract all known fields from
req.body - Remove
undefinedentries - If profile exists →
prisma.profile.update() - If not →
prisma.profile.create()withuserId
Response (200): Updated/created Profile object (same schema as GET)
Get the authenticated user's matching preferences.
Auth: Required
Response (200):
{
"id": "665f1a2b3c4d5e6f7a8b9c0f",
"userId": "665f1a2b3c4d5e6f7a8b9c0d",
"genderPreference": "any",
"minBudget": 1200,
"maxBudget": 2000,
"city": "San Francisco",
"cleanliness": 4,
"sleepSchedule": 3,
"smoking": false,
"drinking": true,
"pets": true,
"socialLevel": 4,
"weightCleanliness": 30,
"weightSleep": 25,
"weightHabits": 20,
"weightSocial": 25,
"createdAt": "2026-03-20T10:30:00.000Z",
"updatedAt": "2026-03-20T10:35:00.000Z"
}Returns null if no preferences set yet.
Create or update preferences. Validates that weights sum to 100.
Auth: Required
Request Body:
{
"genderPreference": "any",
"minBudget": 1200,
"maxBudget": 2000,
"city": "San Francisco",
"cleanliness": 4,
"sleepSchedule": 3,
"smoking": false,
"drinking": true,
"pets": true,
"socialLevel": 4,
"weightCleanliness": 30,
"weightSleep": 25,
"weightHabits": 20,
"weightSocial": 25
}Validation:
weightCleanliness + weightSleep + weightHabits + weightSocial === 100
If not → returns 400 { error: "Weights must sum to 100" }
Response (200): Updated/created Preference object
Compute compatibility scores for the authenticated user against all other eligible users.
Auth: Required
Onboarding Gate: Profile + preferences must exist and onboardingCompleted must be true.
Error Responses:
403 { "message": "Onboarding completion is required" }— Onboarding is incomplete
Response (200):
[
{ "userId": "665f...001", "score": 87 },
{ "userId": "665f...002", "score": 72 },
{ "userId": "665f...003", "score": 65 }
]Sorted by score descending.
Algorithm: See docs/matching-algorithm.md for full details.
Get the discovery feed — ranked potential roommates excluding already-swiped users.
Compatibility contract:
- Canonical route:
GET /discovery/feed - Legacy alias:
GET /discovery(same handler, kept for backward compatibility)
Auth: Required
Onboarding Gate: Profile + preferences must exist and onboardingCompleted must be true.
Error Responses:
403 { "message": "Onboarding completion is required" }— Onboarding is incomplete
Response (200):
[
{
"id": "665f...001",
"name": "Sarah Mitchell",
"age": 26,
"gender": "female",
"occupation": "UX Designer",
"city": "San Francisco",
"hasRoom": true,
"photos": ["https://..."],
"compatibility": 87,
"budgetMin": 1200,
"budgetMax": 2000,
"tags": ["Has Room", "Non-Smoker", "Clean & Tidy", "UX Designer"]
}
]Logic:
- Get all swipes by current user →
excludedUserIds - Run matching algorithm → ranked scores
- Filter out already-swiped users
- Enrich each candidate with profile, preference, and auto-generated tags
- Return array (sorted by compatibility score, descending)
Tag generation rules:
hasRoom === true→ "Has Room"pets === true→ "Pet Friendly"smoking === false→ "Non-Smoker"cleanliness >= 4→ "Clean & Tidy"socialLevel >= 4→ "Social"socialLevel <= 2→ "Quiet"sleepSchedule <= 2→ "Early Bird"sleepSchedule >= 4→ "Night Owl"occupation→ added as tag if present- Maximum 4 tags returned
Record a swipe action on a potential roommate.
Compatibility contract:
- Canonical route:
POST /discovery/swipe - Legacy alias:
POST /matches/connect/:toUserId(maps to alikeaction)
Auth: Required
Onboarding Gate: Profile + preferences must exist and onboardingCompleted must be true.
Error Responses:
403 { "message": "Onboarding completion is required" }— Onboarding is incomplete
Request Body:
{
"toUserId": "665f...001",
"action": "like" // "like" | "dislike" | "skip" | "superlike"
}Action normalization:
"superlike"→ stored as"like""skip"→ stored as"dislike"
Logic:
- Upsert
Swiperecord (fromUserId, toUserId) - If action is
"like": a. Check for reverse swipe (toUser liked fromUser) b. If mutual like → create Match + return{ swipe, matched: true } - Return
{ success: true }
Response (200):
{ "success": true }Alias response note:
POST /matches/connect/:toUserIdreturns{ "success": true, "matched": boolean }POST /discovery/swipecurrently returns{ "success": true }while still creating a match on mutual likes.
Get all confirmed matches for the authenticated user with enriched data.
Auth: Required
Onboarding Gate: Profile + preferences must exist and onboardingCompleted must be true.
Error Responses:
403 { "message": "Onboarding completion is required" }— Onboarding is incomplete
Response (200):
[
{
"id": "665f...match001",
"matchedAt": "2026-03-20T10:30:00.000Z",
"createdAt": "2026-03-20T10:30:00.000Z",
"otherUser": {
"id": "665f...001",
"name": "Sarah Mitchell",
"age": 26,
"gender": "female",
"occupation": "UX Designer",
"city": "San Francisco",
"hasRoom": true,
"photos": ["https://..."],
"budgetMin": 1200,
"budgetMax": 2000,
"tags": ["Has Room", "Non-Smoker", "UX Designer"]
},
"compatibility": 85,
"lastMessage": "Saturday at 2pm sounds great!",
"conversationId": "665f...convo001"
}
]Logic:
- Find all Match records where
userAId == userId OR userBId == userId - For each match, determine the "other" user
- Fetch the other user's profile, preference, and last conversation message
- Generate tags (same algorithm as discovery, max 3)
- Return sorted by
createdAtdescending
Note: compatibility is computed dynamically from the matching service.
Open a chat conversation for a specific match. Creates the conversation if it doesn't exist.
Auth: Required
URL Params: matchId — the Match document ID
Access Control: Validates that the authenticated user is a participant in the match (userAId or userBId).
Response (200):
{
"conversation": {
"id": "665f...convo001",
"matchId": "665f...match001",
"createdAt": "2026-03-20T10:30:00.000Z"
},
"messages": [
{
"id": "665f...msg001",
"conversationId": "665f...convo001",
"senderId": "665f...user001",
"content": "Hey! I saw your profile!",
"createdAt": "2026-03-20T10:31:00.000Z"
}
],
"otherUser": {
"id": "665f...user002",
"name": "Sarah Mitchell",
"picture": "https://...",
"city": "San Francisco",
"occupation": "UX Designer"
}
}Error Responses:
400— Missing matchId or userId403— User is not part of this match404— Match not found
const socket = io("http://localhost:4000");| Event | Payload | Description |
|---|---|---|
joinRoom |
conversationId: string |
Canonical event: join a conversation room |
sendMessage |
{ conversationId, senderId, content } |
Canonical event: send a message |
Legacy aliases (still accepted):
| Event | Alias Of |
|---|---|
join |
joinRoom |
send_message |
sendMessage |
| Event | Payload | Description |
|---|---|---|
message |
{ id, senderId, content, createdAt, timestamp } |
Canonical event: new message broadcast |
Legacy alias (still emitted for backward compatibility):
| Event | Alias Of |
|---|---|
new_message |
message |
Payload contract notes:
createdAtandtimestampare both ISO datetime strings.- Both fields represent the same persisted message creation time.
Client A sends:
socket.emit('sendMessage', { conversationId, senderId, content })
→ Server receives, persists to DB via prisma.message.create()
→ Server broadcasts canonical: io.to(conversationId).emit('message', payload)
→ Server also broadcasts alias: io.to(conversationId).emit('new_message', payload)
→ Client B can receive either event during compatibility window
All error responses follow this pattern:
{
"error": "Error type",
"message": "Human-readable description"
}Or the simpler format used by most modules:
{
"message": "Error description"
}| Status | Meaning | When Used |
|---|---|---|
| 200 | OK | Successful request |
| 400 | Bad Request | Invalid input, missing required fields, weight validation |
| 401 | Unauthorized | Missing/invalid JWT, no userId in request |
| 403 | Forbidden | User not authorized for this resource (e.g., wrong match) |
| 404 | Not Found | Resource doesn't exist |
| 429 | Too Many Requests | Rate limit exceeded (100 req / 15 min) |
| 500 | Internal Server Error | Unhandled server error |
Window: 15 minutes
Max Requests: 100 per window
Headers: Standard (RateLimit-*)
Response on exceed:
429 { "error": "Too many requests, please try again later." }