diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1de3db4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: [tier0] + pull_request: + branches: [tier0] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Setup Node.js 24.15.0 + uses: actions/setup-node@v4 + with: + node-version: "24.15.0" + cache: "npm" + + - name: Install dependencies + run: npm ci diff --git a/.gitignore b/.gitignore index 5f04ba9..9fb72b4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,12 +2,13 @@ node_modules/ # Environment files — never commit any .env variant +# Environment files — never commit .env .env.local .env.azure +.env.render .env.*.local -.env.test - +env.render # Local file uploads — stored on disk in dev, Azure Blob in prod uploads/ @@ -24,4 +25,6 @@ Thumbs.db .idea/ client_secret_535680244018-fd6emgcmkbqs9em1a0tov7p9bepbd9ki.apps.googleusercontent.com.json -roomies_backend.zip \ No newline at end of file +roomies_backend.zip + +postman/ diff --git "a/Roomies API \342\200\224 Full E2E Test Suite.postman_collection.json" "b/Roomies API \342\200\224 Full E2E Test Suite.postman_collection.json" deleted file mode 100644 index a18da2f..0000000 --- "a/Roomies API \342\200\224 Full E2E Test Suite.postman_collection.json" +++ /dev/null @@ -1,2052 +0,0 @@ -{ - "info": { - "_postman_id": "6c203538-b7b5-4a25-8464-c75123c5fc1b", - "name": "Roomies API — Full E2E Test Suite", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "_exporter_id": "53941382" - }, - "item": [ - { - "name": "00 - Health Check", - "item": [ - { - "name": "GET Health", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/health", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "health" - ] - } - }, - "response": [] - } - ] - }, - { - "name": "01 - Auth", - "item": [ - { - "name": "Happy Path", - "item": [ - { - "name": "[1.1] Register Student 1", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 201\", () => pm.response.to.have.status(201));", - "pm.test(\"Returns access token\", () => {", - " const body = pm.response.json();", - " pm.expect(body.data.accessToken).to.be.a(\"string\");", - "});", - "", - "const body = pm.response.json();", - "pm.environment.set(\"student1AccessToken\", body.data.accessToken);", - "pm.environment.set(\"student1RefreshToken\", body.data.refreshToken);", - "pm.environment.set(\"student1Id\", body.data.user.userId);", - "", - "console.log(\"student1Id:\", body.data.user.userId);" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"email\": \"arjun.sharma@student.iitb.ac.in\",\n \"password\": \"Test1234\",\n \"role\": \"student\",\n \"fullName\": \"Arjun Sharma\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/auth/register", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "register" - ] - } - }, - "response": [] - }, - { - "name": "[1.2] Register Student 2", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 201\", () => pm.response.to.have.status(201));", - "", - "const body = pm.response.json();", - "pm.environment.set(\"student2AccessToken\", body.data.accessToken);", - "pm.environment.set(\"student2RefreshToken\", body.data.refreshToken);", - "pm.environment.set(\"student2Id\", body.data.user.userId);", - "", - "console.log(\"student2Id:\", body.data.user.userId);" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"email\": \"priya.nair@student.bits.ac.in\",\n \"password\": \"Test1234\",\n \"role\": \"student\",\n \"fullName\": \"Priya Nair\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/auth/register", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "register" - ] - } - }, - "response": [] - }, - { - "name": "[1.3] Register PG Owner 1", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 201\", () => pm.response.to.have.status(201));", - "", - "const body = pm.response.json();", - "pm.environment.set(\"pgOwner1AccessToken\", body.data.accessToken);", - "pm.environment.set(\"pgOwner1RefreshToken\", body.data.refreshToken);", - "pm.environment.set(\"pgOwner1Id\", body.data.user.userId);", - "", - "console.log(\"pgOwner1Id:\", body.data.user.userId);" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"email\": \"ravi.mehta@gmail.com\",\n \"password\": \"Test1234\",\n \"role\": \"pg_owner\",\n \"fullName\": \"Ravi Mehta\",\n \"businessName\": \"Mehta PG House\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/auth/register", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "register" - ] - } - }, - "response": [] - }, - { - "name": "[1.4] Register PG Owner 2", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 201\", () => pm.response.to.have.status(201));", - "", - "const body = pm.response.json();", - "pm.environment.set(\"pgOwner2AccessToken\", body.data.accessToken);", - "pm.environment.set(\"pgOwner2RefreshToken\", body.data.refreshToken);", - "pm.environment.set(\"pgOwner2Id\", body.data.user.userId);", - "", - "console.log(\"pgOwner2Id:\", body.data.user.userId);" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"email\": \"sunita.kapoor@gmail.com\",\n \"password\": \"Test1234\",\n \"role\": \"pg_owner\",\n \"fullName\": \"Sunita Kapoor\",\n \"businessName\": \"Kapoor Ladies Hostel\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/auth/register", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "register" - ] - } - }, - "response": [] - }, - { - "name": "[1.5] Register PG Owner 3", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 201\", () => pm.response.to.have.status(201));", - "", - "const body = pm.response.json();", - "pm.environment.set(\"pgOwner3AccessToken\", body.data.accessToken);", - "pm.environment.set(\"pgOwner3RefreshToken\", body.data.refreshToken);", - "pm.environment.set(\"pgOwner3Id\", body.data.user.userId);", - "", - "console.log(\"pgOwner3Id:\", body.data.user.userId);" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"email\": \"deepak.joshi@gmail.com\",\n \"password\": \"Test1234\",\n \"role\": \"pg_owner\",\n \"fullName\": \"Deepak Joshi\",\n \"businessName\": \"Joshi Paying Guest\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/auth/register", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "register" - ] - } - }, - "response": [] - }, - { - "name": "[1.6] Login Student 1", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", - "", - "const body = pm.response.json();", - "pm.environment.set(\"student1AccessToken\", body.data.accessToken);", - "pm.environment.set(\"student1RefreshToken\", body.data.refreshToken);", - "", - "console.log(\"Student 1 token refreshed via login\");" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"email\": \"arjun.sharma@student.iitb.ac.in\",\n \"password\": \"Test1234\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/auth/login", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "login" - ] - } - }, - "response": [] - }, - { - "name": "[1.7] Get Me — Student 1", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", - "pm.test(\"Has userId\", () => {", - " pm.expect(pm.response.json().data.userId).to.be.a(\"string\");", - "});", - "pm.test(\"Has student role\", () => {", - " pm.expect(pm.response.json().data.roles).to.include(\"student\");", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{student1AccessToken}}", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/auth/me", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "me" - ] - } - }, - "response": [] - }, - { - "name": "[1.8] Refresh Token — Student 1", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", - "", - "const body = pm.response.json();", - "pm.environment.set(\"student1AccessToken\", body.data.accessToken);", - "pm.environment.set(\"student1RefreshToken\", body.data.refreshToken);", - "", - "console.log(\"Student 1 tokens rotated via refresh\");" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"refreshToken\": \"{{student1RefreshToken}}\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/auth/refresh", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "refresh" - ] - } - }, - "response": [] - }, - { - "name": "[1.9] List Sessions — Student 1", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", - "pm.test(\"Returns array of sessions\", () => {", - " pm.expect(pm.response.json().data).to.be.an(\"array\");", - "});", - "pm.test(\"At least 1 session exists\", () => {", - " pm.expect(pm.response.json().data.length).to.be.above(0);", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "protocolProfileBehavior": { - "disableBodyPruning": true - }, - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{student1AccessToken}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/auth/sessions", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "sessions" - ] - } - }, - "response": [] - }, - { - "name": "[1.10] Send OTP — Student 1", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", - "pm.test(\"OTP sent message\", () => {", - " pm.expect(pm.response.json().message).to.include(\"OTP\");", - "});", - "console.log(\"CHECK YOUR SERVER TERMINAL for the Ethereal Mail preview URL\");" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{student1AccessToken}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/auth/otp/send", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "otp", - "send" - ] - } - }, - "response": [] - }, - { - "name": "[1.11] Verify OTP — Student 1", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", - "pm.test(\"Email verified message\", () => {", - " pm.expect(pm.response.json().message).to.include(\"verified\");", - "});", - "console.log(\"Student 1 email is now verified\");" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{student1AccessToken}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"otp\": \"933987\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/auth/otp/verify", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "otp", - "verify" - ] - } - }, - "response": [] - }, - { - "name": "[1.12] Login Student 2", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", - "", - "const body = pm.response.json();", - "pm.environment.set(\"student2AccessToken\", body.data.accessToken);", - "pm.environment.set(\"student2RefreshToken\", body.data.refreshToken);", - "", - "console.log(\"Student 2 token refreshed via login\");" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"email\": \"priya.nair@student.bits.ac.in\",\n \"password\": \"Test1234\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/auth/login", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "login" - ] - } - }, - "response": [] - }, - { - "name": "[1.13] Refresh Token - Student 2", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", - "", - "const body = pm.response.json();", - "pm.environment.set(\"student2AccessToken\", body.data.accessToken);", - "pm.environment.set(\"student2RefreshToken\", body.data.refreshToken);", - "", - "console.log(\"Student 2 tokens rotated via refresh\");" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"refreshToken\": \"{{student2RefreshToken}}\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/auth/refresh", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "refresh" - ] - } - }, - "response": [] - } - ] - }, - { - "name": "Error Cases", - "item": [ - { - "name": "[1.E1] Duplicate Email Registration", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 409 — duplicate email rejected\", () => {", - " pm.response.to.have.status(409);", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"email\": \"arjun.sharma@student.iitb.ac.in\",\n \"password\": \"Test1234\",\n \"role\": \"student\",\n \"fullName\": \"Fake Arjun\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/auth/register", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "register" - ] - } - }, - "response": [] - }, - { - "name": "[1.E2] Wrong Password Login", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 401 — invalid credentials\", () => {", - " pm.response.to.have.status(401);", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"email\": \"arjun.sharma@student.iitb.ac.in\",\n \"password\": \"WrongPassword99\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/auth/login", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "login" - ] - } - }, - "response": [] - }, - { - "name": "[1.E3] PG Owner Register Without businessName", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 400 — businessName required\", () => {", - " pm.response.to.have.status(400);", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"email\": \"nobusiness@test.com\",\n \"password\": \"Test1234\",\n \"role\": \"pg_owner\",\n \"fullName\": \"No Business\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/auth/register", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "register" - ] - } - }, - "response": [] - }, - { - "name": "[1.E4] Access Protected Route Without Token", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 401 — no token\", () => {", - " pm.response.to.have.status(401);", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/auth/me", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "me" - ] - } - }, - "response": [] - }, - { - "name": "[1.E5] Invalid Token", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 401 — invalid token\", () => {", - " pm.response.to.have.status(401);", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer thisisacompletlyfaketokenstring", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/auth/me", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "me" - ] - } - }, - "response": [] - } - ] - } - ] - }, - { - "name": "02 - Student Profiles", - "item": [ - { - "name": "Happy Path", - "item": [ - { - "name": "[2.1] Get Student 1 Profile (self)", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", - "pm.test(\"Returns correct user\", () => {", - " pm.expect(pm.response.json().data.full_name).to.eql(\"Arjun Sharma\");", - "});", - "pm.test(\"Self view includes email\", () => {", - " pm.expect(pm.response.json().data.email).to.be.a(\"string\");", - " pm.expect(pm.response.json().data.email).to.include(\"@\");", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{student1AccessToken}}", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/students/{{student1Id}}/profile", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "students", - "{{student1Id}}", - "profile" - ] - } - }, - "response": [] - }, - { - "name": "[2.2] Get Student 1 Profile (viewed by Student 2)", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", - "pm.test(\"Full name still visible\", () => {", - " pm.expect(pm.response.json().data.full_name).to.eql(\"Arjun Sharma\");", - "});", - "pm.test(\"Email hidden for non-owner\", () => {", - " pm.expect(pm.response.json().data.email).to.be.null;", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "protocolProfileBehavior": { - "disableBodyPruning": true - }, - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{student2AccessToken}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/students/{{student1Id}}/profile", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "students", - "{{student1Id}}", - "profile" - ] - } - }, - "response": [] - }, - { - "name": "[2.3] Update Student 1 Profile", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", - "pm.test(\"Bio updated correctly\", () => {", - " pm.expect(pm.response.json().data.bio).to.include(\"IIT Bombay\");", - "});", - "pm.test(\"Course updated\", () => {", - " pm.expect(pm.response.json().data.course).to.eql(\"B.Tech Computer Science\");", - "});", - "pm.test(\"Year of study updated\", () => {", - " pm.expect(pm.response.json().data.year_of_study).to.eql(4);", - "});", - "pm.test(\"Gender updated\", () => {", - " pm.expect(pm.response.json().data.gender).to.eql(\"male\");", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "PUT", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{student1AccessToken}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"bio\": \"Final year CSE student at IIT Bombay. Looking for a clean and quiet room near campus.\",\n \"course\": \"B.Tech Computer Science\",\n \"yearOfStudy\": 4,\n \"gender\": \"male\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/students/{{student1Id}}/profile", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "students", - "{{student1Id}}", - "profile" - ] - } - }, - "response": [] - }, - { - "name": "[2.4] Update Student 2 Profile", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", - "pm.test(\"Bio updated\", () => {", - " pm.expect(pm.response.json().data.bio).to.include(\"BITS Pilani\");", - "});", - "pm.test(\"Gender updated\", () => {", - " pm.expect(pm.response.json().data.gender).to.eql(\"female\");", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "PUT", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{student2AccessToken}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"bio\": \"Second year ECE at BITS Pilani. Non-smoker, vegetarian, looking for female-only PG.\",\n \"course\": \"B.E. Electronics\",\n \"yearOfStudy\": 2,\n \"gender\": \"female\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/students/{{student2Id}}/profile", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "students", - "{{student2Id}}", - "profile" - ] - } - }, - "response": [] - }, - { - "name": "[2.5] Set Preferences — Student 1", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", - "pm.test(\"Returns array of preferences\", () => {", - " pm.expect(pm.response.json().data).to.be.an(\"array\");", - "});", - "pm.test(\"Correct number of preferences saved\", () => {", - " pm.expect(pm.response.json().data.length).to.eql(3);", - "});", - "pm.test(\"Smoking preference saved\", () => {", - " const prefs = pm.response.json().data;", - " const smoking = prefs.find(p => p.preferenceKey === \"smoking\");", - " pm.expect(smoking).to.exist;", - " pm.expect(smoking.preferenceValue).to.eql(\"non_smoker\");", - "});", - "console.log(\"Student 1 preferences set — will power compatibility scoring in listing search\");" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - }, - { - "listen": "prerequest", - "script": { - "exec": [], - "type": "text/javascript" - } - } - ], - "request": { - "method": "PUT", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{student1AccessToken}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n\t\"preferences\": [\n\t\t{\n\t\t\t\"preferenceKey\": \"smoking\",\n\t\t\t\"preferenceValue\": \"non_smoker\"\n\t\t},\n\t\t{\n\t\t\t\"preferenceKey\": \"food_habit\",\n\t\t\t\"preferenceValue\": \"vegetarian\"\n\t\t},\n\t\t{\n\t\t\t\"preferenceKey\": \"sleep_schedule\",\n\t\t\t\"preferenceValue\": \"early_bird\"\n\t\t}\n\t]\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/students/{{student1Id}}/preferences", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "students", - "{{student1Id}}", - "preferences" - ] - } - }, - "response": [] - }, - { - "name": "[2.6] Set Preferences — Student 2", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", - "pm.test(\"Returns array of preferences\", () => {", - " pm.expect(pm.response.json().data).to.be.an(\"array\");", - "});", - "pm.test(\"Correct number of preferences saved\", () => {", - " pm.expect(pm.response.json().data.length).to.eql(3);", - "});", - "console.log(\"Student 2 preferences set\");" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - }, - { - "listen": "prerequest", - "script": { - "exec": [], - "type": "text/javascript" - } - } - ], - "request": { - "method": "PUT", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{student2AccessToken}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n\t\"preferences\": [\n\t\t{\n\t\t\t\"preferenceKey\": \"food_habit\",\n\t\t\t\"preferenceValue\": \"vegetarian\"\n\t\t},\n\t\t{\n\t\t\t\"preferenceKey\": \"alcohol\",\n\t\t\t\"preferenceValue\": \"not_okay\"\n\t\t},\n\t\t{\n\t\t\t\"preferenceKey\": \"sleep_schedule\",\n\t\t\t\"preferenceValue\": \"early_bird\"\n\t\t}\n\t]\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/students/{{student2Id}}/preferences", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "students", - "{{student2Id}}", - "preferences" - ] - } - }, - "response": [] - }, - { - "name": "[2.7] Get Preferences — Student 1 (verify read-back)", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", - "pm.test(\"Returns same preferences we set\", () => {", - " const data = pm.response.json().data;", - " pm.expect(data).to.be.an(\"array\");", - " pm.expect(data.length).to.eql(3);", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - }, - { - "listen": "prerequest", - "script": { - "exec": [], - "type": "text/javascript" - } - } - ], - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{student1AccessToken}}", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/students/{{student1Id}}/preferences", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "students", - "{{student1Id}}", - "preferences" - ] - } - }, - "response": [] - } - ] - }, - { - "name": "Error Cases", - "item": [ - { - "name": "[2.E1] Update Another Student's Profile", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 403 — cannot edit another user's profile\", () => {", - " pm.response.to.have.status(403);", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "PUT", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{student1AccessToken}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"bio\": \"This should not be allowed\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/students/{{student2Id}}/profile", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "students", - "{{student2Id}}", - "profile" - ] - } - }, - "response": [] - }, - { - "name": "[2.E3] Student 1 Tries to Read Student 2's Preferences", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 403 — cannot read another student's preferences\", () => {", - " pm.response.to.have.status(403);", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - }, - { - "listen": "prerequest", - "script": { - "exec": [], - "type": "text/javascript" - } - } - ], - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{student1AccessToken}}", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/students/{{student2Id}}/preferences", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "students", - "{{student2Id}}", - "preferences" - ] - } - }, - "response": [] - }, - { - "name": "[2.E4] Student 1 Tries to Update Student 2's Preferences", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 403 — cannot update another student's preferences\", () => {", - " pm.response.to.have.status(403);", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - }, - { - "listen": "prerequest", - "script": { - "exec": [], - "type": "text/javascript" - } - } - ], - "request": { - "method": "PUT", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{student1AccessToken}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n\t\"preferences\": [\n\t\t{\n\t\t\t\"preferenceKey\": \"smoking\",\n\t\t\t\"preferenceValue\": \"smoker\"\n\t\t}\n\t]\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/students/{{student2Id}}/preferences", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "students", - "{{student2Id}}", - "preferences" - ] - } - }, - "response": [] - } - ] - } - ] - }, - { - "name": "03 - PG Owner Profiles", - "item": [ - { - "name": "Happy Path", - "item": [ - { - "name": "Login Pg Owner : 1", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", - "", - "const body = pm.response.json();", - "pm.environment.set(\"pgOwner1AccessToken\", body.data.accessToken);", - "pm.environment.set(\"pgOwner1RefreshToken\", body.data.refreshToken);", - "", - "console.log(\"PG Owner 1, token refreshed via login\");" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - }, - { - "listen": "prerequest", - "script": { - "exec": [], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n\t\"email\": \"ravi.mehta@gmail.com\",\n\t\"password\": \"Test1234\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/auth/login", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "login" - ] - } - }, - "response": [] - }, - { - "name": "[3.1] Get PG Owner 1 Profile (self)", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", - "pm.test(\"Business name correct\", () => {", - " pm.expect(pm.response.json().data.business_name).to.eql(\"Mehta PG House\");", - "});", - "pm.test(\"Verification status is unverified\", () => {", - " pm.expect(pm.response.json().data.verification_status).to.eql(\"unverified\");", - "});", - "pm.test(\"Self view includes email\", () => {", - " pm.expect(pm.response.json().data.email).to.be.a(\"string\");", - " pm.expect(pm.response.json().data.email).to.include(\"@\");", - "});", - "pm.test(\"Self view includes business phone field\", () => {", - " pm.expect(pm.response.json().data).to.have.property(\"business_phone\");", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - }, - { - "listen": "prerequest", - "script": { - "exec": [], - "type": "text/javascript" - } - } - ], - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{pgOwner1AccessToken}}", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/pg-owners/{{pgOwner1Id}}/profile", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "pg-owners", - "{{pgOwner1Id}}", - "profile" - ] - } - }, - "response": [] - }, - { - "name": "[3.2] Update PG Owner 1 Profile", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", - "pm.test(\"Business phone updated\", () => {", - " pm.expect(pm.response.json().data.business_phone).to.eql(\"9876543210\");", - "});", - "pm.test(\"Operating since updated\", () => {", - " pm.expect(pm.response.json().data.operating_since).to.eql(2019);", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - }, - { - "listen": "prerequest", - "script": { - "exec": [], - "type": "text/javascript" - } - } - ], - "request": { - "method": "PUT", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{pgOwner1AccessToken}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n\t\"businessDescription\": \"Clean, well-maintained PG in Koramangala with 24hr water and WiFi.\",\n\t\"businessPhone\": \"9876543210\",\n\t\"operatingSince\": 2019\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/pg-owners/{{pgOwner1Id}}/profile", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "pg-owners", - "{{pgOwner1Id}}", - "profile" - ] - } - }, - "response": [] - }, - { - "name": "Login Pg Owner 2", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", - "", - "const body = pm.response.json();", - "pm.environment.set(\"pgOwner2AccessToken\", body.data.accessToken);", - "pm.environment.set(\"pgOwner2RefreshToken\", body.data.refreshToken);", - "", - "console.log(\"PG Owner 2, token refreshed via login\");" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - }, - { - "listen": "prerequest", - "script": { - "exec": [], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n\t\"email\": \"sunita.kapoor@gmail.com\",\n\t\"password\": \"Test1234\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/auth/login", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "login" - ] - } - }, - "response": [] - }, - { - "name": "[3.3] Update PG Owner 2 Profile", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", - "pm.test(\"Description updated\", () => {", - " pm.expect(pm.response.json().data.business_description).to.include(\"Ladies-only\");", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - }, - { - "listen": "prerequest", - "script": { - "exec": [], - "type": "text/javascript" - } - } - ], - "request": { - "method": "PUT", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{pgOwner2AccessToken}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n\t\"businessDescription\": \"Ladies-only hostel in Indiranagar with strict curfew and home food.\",\n\t\"businessPhone\": \"9123456780\",\n\t\"operatingSince\": 2021\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/pg-owners/{{pgOwner2Id}}/profile", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "pg-owners", - "{{pgOwner2Id}}", - "profile" - ] - } - }, - "response": [] - }, - { - "name": "Login PG Owner 3", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", - "", - "const body = pm.response.json();", - "pm.environment.set(\"pgOwner3AccessToken\", body.data.accessToken);", - "pm.environment.set(\"pgOwner3RefreshToken\", body.data.refreshToken);", - "", - "console.log(\"PG Owner 3, token refreshed via login\");" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - }, - { - "listen": "prerequest", - "script": { - "exec": [], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n\t\"email\": \"deepak.joshi@gmail.com\",\n\t\"password\": \"Test1234\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/auth/login", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "login" - ] - } - }, - "response": [] - }, - { - "name": "[3.4] Update PG Owner 3 Profile", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - }, - { - "listen": "prerequest", - "script": { - "exec": [], - "type": "text/javascript" - } - } - ], - "request": { - "method": "PUT", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{pgOwner3AccessToken}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n\t\"businessDescription\": \"Affordable PG near HSR Layout. Single and double rooms available.\",\n\t\"businessPhone\": \"9988776655\",\n\t\"operatingSince\": 2020\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/pg-owners/{{pgOwner3Id}}/profile", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "pg-owners", - "{{pgOwner3Id}}", - "profile" - ] - } - }, - "response": [] - }, - { - "name": "[3.5] Get PG Owner 1 Profile (viewed by Student 1 — sensitive fields hidden)", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", - "pm.test(\"Business name visible\", () => {", - " pm.expect(pm.response.json().data.business_name).to.eql(\"Mehta PG House\");", - "});", - "pm.test(\"Email hidden for non-owner\", () => {", - " pm.expect(pm.response.json().data.email).to.be.null;", - "});", - "pm.test(\"Business phone hidden for non-owner\", () => {", - " pm.expect(pm.response.json().data.business_phone).to.be.null;", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - }, - { - "listen": "prerequest", - "script": { - "exec": [ - "" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "GET", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{student1AccessToken}}", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/pg-owners/{{pgOwner1Id}}/profile", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "pg-owners", - "{{pgOwner1Id}}", - "profile" - ] - } - }, - "response": [] - } - ] - }, - { - "name": "Error Cases", - "item": [] - } - ] - }, - { - "name": "04 - Verification (Admin)", - "item": [ - { - "name": "Happy Path", - "item": [] - }, - { - "name": "Error Cases", - "item": [] - } - ] - }, - { - "name": "05 - Properties", - "item": [ - { - "name": "Happy Path", - "item": [] - }, - { - "name": "Error Cases", - "item": [] - } - ] - }, - { - "name": "06 - Listings", - "item": [ - { - "name": "Happy Path", - "item": [] - }, - { - "name": "Error Cases", - "item": [] - } - ] - }, - { - "name": "07 - Interest Requests", - "item": [ - { - "name": "Happy Path", - "item": [] - }, - { - "name": "Error Cases", - "item": [] - } - ] - }, - { - "name": "08 - Connections", - "item": [ - { - "name": "Happy Path", - "item": [] - }, - { - "name": "Error Cases", - "item": [] - } - ] - }, - { - "name": "09 - Ratings", - "item": [ - { - "name": "Happy Path", - "item": [] - }, - { - "name": "Error Cases", - "item": [] - } - ] - }, - { - "name": "10 - Reports (Admin)", - "item": [ - { - "name": "Happy Path", - "item": [] - }, - { - "name": "Error Cases", - "item": [] - } - ] - }, - { - "name": "DEV UTILS", - "item": [ - { - "name": "[DEV] Reset Rate Limits", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "// Pre-request script on [1.6] Login Student 1, for example", - "pm.sendRequest({", - " url: pm.environment.get(\"baseUrl\") + \"/test-utils/reset-rate-limits\",", - " method: \"POST\"", - "}, (err, res) => {", - " if (err) console.log(\"Could not reset rate limits:\", err);", - " else console.log(\"Rate limits cleared:\", res.json().deletedCount, \"keys deleted\");", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [], - "url": { - "raw": "{{baseUrl}}/test-utils/reset-rate-limits", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "test-utils", - "reset-rate-limits" - ] - } - }, - "response": [] - } - ] - } - ], - "event": [ - { - "listen": "prerequest", - "script": { - "type": "text/javascript", - "packages": {}, - "requests": {}, - "exec": [ - "pm.request.headers.add({", - " key: 'X-Client-Transport',", - " value: 'bearer'", - "});", - "// Safety net: ensure baseUrl is always set", - "if (!pm.environment.get(\"baseUrl\")) {", - " pm.environment.set(\"baseUrl\", \"http://localhost:3000/api/v1\");", - " console.log(\"baseUrl was missing — set to default\");", - "}" - ] - } - }, - { - "listen": "test", - "script": { - "type": "text/javascript", - "packages": {}, - "requests": {}, - "exec": [ - "" - ] - } - } - ] -} \ No newline at end of file diff --git "a/Roomies API \342\200\224 Postman Testing Master P.md" "b/Roomies API \342\200\224 Postman Testing Master P.md" deleted file mode 100644 index 0a63fd9..0000000 --- "a/Roomies API \342\200\224 Postman Testing Master P.md" +++ /dev/null @@ -1,3251 +0,0 @@ -# Roomies API — Postman Testing Master Plan - -## Part 1 — Postman Setup (New UI, Linux) - -### What changed in the latest Postman (2025–2026) - -The UI you will see on your Linux install has these key differences from older versions and from ThunderClient: - -- **Unified workbench** — the left sidebar has Collections, Environments, History. Everything opens as a tab in the - center panel. -- **Variables redesigned** — there is now **one value per variable** (no more "initial value" vs "current value" - confusion). Variables save locally by default and are private. You toggle a switch next to each variable to share it. -- **Mark as sensitive** — tick this on tokens and secrets so Postman masks the value in the UI and warns you before - syncing. -- **Scripts tab** — Pre-request and Post-response scripts are now under a single **Scripts** tab inside each request - (not separate tabs labeled Pre-request Script / Tests like the old UI). -- **Console** — bottom footer bar, click **Console** to see every request/response raw. Essential for debugging. -- **Autosave** — everything autosaves. You do not need to press Ctrl+S. - ---- - -### Step 1 — Create the Environment - -1. In the left sidebar click **Environments**. -2. Click **+** (Create Environment). -3. Name it `Roomies Local`. -4. Add every variable below. Leave the value blank for now — the scripts will fill them automatically. - -| Variable | Initial Value | Sensitive? | -| ------------------------ | ------------------------------ | ---------- | -| `baseUrl` | `http://localhost:3000/api/v1` | No | -| `student1AccessToken` | | Yes | -| `student1RefreshToken` | | Yes | -| `student1Id` | | No | -| `student2AccessToken` | | Yes | -| `student2RefreshToken` | | Yes | -| `student2Id` | | No | -| `pgOwner1AccessToken` | | Yes | -| `pgOwner1RefreshToken` | | Yes | -| `pgOwner1Id` | | No | -| `pgOwner2AccessToken` | | Yes | -| `pgOwner2RefreshToken` | | Yes | -| `pgOwner2Id` | | No | -| `pgOwner3AccessToken` | | Yes | -| `pgOwner3RefreshToken` | | Yes | -| `pgOwner3Id` | | No | -| `property1Id` | | No | -| `property2Id` | | No | -| `listing1Id` | | No | -| `listing2Id` | | No | -| `listing3Id` | | No | -| `listing4Id` | | No | -| `interestRequest1Id` | | No | -| `interestRequest2Id` | | No | -| `connection1Id` | | No | -| `connection2Id` | | No | -| `rating1Id` | | No | -| `report1Id` | | No | -| `verificationRequest1Id` | | No | -| `verificationRequest2Id` | | No | -| `verificationRequest3Id` | | No | - -5. Click **Save**. -6. In the top-right corner of Postman, click the environment dropdown and select **Roomies Local** to activate it. - ---- - -### Step 2 — Create the Collection - -1. Left sidebar → **Collections** → click **+** → **Blank collection**. -2. Name it `Roomies API — Full E2E Test Suite`. -3. Click the collection name → **Scripts** tab → paste this in the **Pre-request** section: - -```javascript -// Ensures baseUrl is always available even if the environment variable is missing -if (!pm.environment.get("baseUrl")) { - pm.environment.set("baseUrl", "http://localhost:3000/api/v1"); -} -``` - ---- - -### How to use Bearer tokens (recommended over cookies for Postman) - -Your server checks for the header `X-Client-Transport: bearer` to return tokens in the response body instead of cookies -only. Add this as a **collection-level header**: - -1. Click the collection name → **Headers** tab. -2. Add: `X-Client-Transport` → `bearer`. - -This applies to every request in the collection automatically. The post-response scripts below will capture the tokens -from the JSON body. - ---- - -### How to read the Scripts in each request below - -Every request that returns tokens has a **Scripts → Post-response** script. In the new Postman UI: - -1. Open the request. -2. Click the **Scripts** tab. -3. Paste the script into the **Post-response** section. - ---- - -## Part 2 — Fake Test Data - -### Users - -| User | Email | Password | Role | Full Name | Business Name | -| ---------- | --------------------------------- | ---------- | -------- | ------------- | -------------------- | -| Student 1 | `arjun.sharma@student.iitb.ac.in` | `Test1234` | student | Arjun Sharma | — | -| Student 2 | `priya.nair@student.bits.ac.in` | `Test1234` | student | Priya Nair | — | -| PG Owner 1 | `ravi.mehta@gmail.com` | `Test1234` | pg_owner | Ravi Mehta | Mehta PG House | -| PG Owner 2 | `sunita.kapoor@gmail.com` | `Test1234` | pg_owner | Sunita Kapoor | Kapoor Ladies Hostel | -| PG Owner 3 | `deepak.joshi@gmail.com` | `Test1234` | pg_owner | Deepak Joshi | Joshi Paying Guest | - ---- - -## Part 3 — Collection Folder Structure - -``` -Roomies API — Full E2E Test Suite -│ -├── 00 - Health Check -├── 01 - Auth -│ ├── Happy Path -│ └── Error Cases -├── 02 - Student Profiles -│ ├── Happy Path -│ └── Error Cases -├── 03 - PG Owner Profiles -│ ├── Happy Path -│ └── Error Cases -├── 04 - Verification (Admin) -│ ├── Happy Path -│ └── Error Cases -├── 05 - Properties -│ ├── Happy Path -│ └── Error Cases -├── 06 - Listings -│ ├── Happy Path -│ └── Error Cases -├── 07 - Interest Requests -│ ├── Happy Path -│ └── Error Cases -├── 08 - Connections -│ ├── Happy Path -│ └── Error Cases -├── 09 - Ratings -│ ├── Happy Path -│ └── Error Cases -└── 10 - Reports (Admin) - ├── Happy Path - └── Error Cases -``` - -To create a folder: right-click the collection → **Add folder**. Nest Error Cases inside each parent folder the same -way. - ---- - -## Part 4 — All Requests, Folder by Folder - ---- - -### 00 - Health Check - -**GET Health** - -- URL: `{{baseUrl}}/health` -- No headers, no body. -- Scripts → Post-response: - -```javascript -pm.test("Status is 200", () => pm.response.to.have.status(200)); -pm.test("All services ok", () => { - const body = pm.response.json(); - pm.expect(body.status).to.eql("ok"); - pm.expect(body.services.database).to.eql("ok"); - pm.expect(body.services.redis).to.eql("ok"); -}); -``` - ---- - -### 01 - Auth → Happy Path - -**[1.1] Register Student 1** - -- Method: POST -- URL: `{{baseUrl}}/auth/register` -- Body → raw → JSON: - -```json -{ - "email": "arjun.sharma@student.iitb.ac.in", - "password": "Test1234", - "role": "student", - "fullName": "Arjun Sharma" -} -``` - -- Scripts → Post-response: - -```javascript -pm.test("Status 201", () => pm.response.to.have.status(201)); -const body = pm.response.json(); -pm.test("Returns tokens", () => { - pm.expect(body.data.accessToken).to.be.a("string"); - pm.expect(body.data.refreshToken).to.be.a("string"); -}); -pm.environment.set("student1AccessToken", body.data.accessToken); -pm.environment.set("student1RefreshToken", body.data.refreshToken); -pm.environment.set("student1Id", body.data.user.userId); -``` - ---- - -**[1.2] Register Student 2** - -- Method: POST -- URL: `{{baseUrl}}/auth/register` -- Body: - -```json -{ - "email": "priya.nair@student.bits.ac.in", - "password": "Test1234", - "role": "student", - "fullName": "Priya Nair" -} -``` - -- Scripts → Post-response: - -```javascript -pm.test("Status 201", () => pm.response.to.have.status(201)); -const body = pm.response.json(); -pm.environment.set("student2AccessToken", body.data.accessToken); -pm.environment.set("student2RefreshToken", body.data.refreshToken); -pm.environment.set("student2Id", body.data.user.userId); -``` - ---- - -**[1.3] Register PG Owner 1** - -- Method: POST -- URL: `{{baseUrl}}/auth/register` -- Body: - -```json -{ - "email": "ravi.mehta@gmail.com", - "password": "Test1234", - "role": "pg_owner", - "fullName": "Ravi Mehta", - "businessName": "Mehta PG House" -} -``` - -- Scripts → Post-response: - -```javascript -pm.test("Status 201", () => pm.response.to.have.status(201)); -const body = pm.response.json(); -pm.environment.set("pgOwner1AccessToken", body.data.accessToken); -pm.environment.set("pgOwner1RefreshToken", body.data.refreshToken); -pm.environment.set("pgOwner1Id", body.data.user.userId); -``` - ---- - -**[1.4] Register PG Owner 2** - -- Method: POST -- URL: `{{baseUrl}}/auth/register` -- Body: - -```json -{ - "email": "sunita.kapoor@gmail.com", - "password": "Test1234", - "role": "pg_owner", - "fullName": "Sunita Kapoor", - "businessName": "Kapoor Ladies Hostel" -} -``` - -- Scripts → Post-response: - -```javascript -const body = pm.response.json(); -pm.environment.set("pgOwner2AccessToken", body.data.accessToken); -pm.environment.set("pgOwner2RefreshToken", body.data.refreshToken); -pm.environment.set("pgOwner2Id", body.data.user.userId); -``` - ---- - -**[1.5] Register PG Owner 3** - -- Method: POST -- URL: `{{baseUrl}}/auth/register` -- Body: - -```json -{ - "email": "deepak.joshi@gmail.com", - "password": "Test1234", - "role": "pg_owner", - "fullName": "Deepak Joshi", - "businessName": "Joshi Paying Guest" -} -``` - -- Scripts → Post-response: - -```javascript -const body = pm.response.json(); -pm.environment.set("pgOwner3AccessToken", body.data.accessToken); -pm.environment.set("pgOwner3RefreshToken", body.data.refreshToken); -pm.environment.set("pgOwner3Id", body.data.user.userId); -``` - ---- - -**[1.6] Login Student 1** - -- Method: POST -- URL: `{{baseUrl}}/auth/login` -- Body: - -```json -{ - "email": "arjun.sharma@student.iitb.ac.in", - "password": "Test1234" -} -``` - -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -const body = pm.response.json(); -pm.environment.set("student1AccessToken", body.data.accessToken); -pm.environment.set("student1RefreshToken", body.data.refreshToken); -``` - ---- - -**[1.7] Get Me — Student 1** - -- Method: GET -- URL: `{{baseUrl}}/auth/me` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("Returns user object", () => { - const body = pm.response.json(); - pm.expect(body.data.userId).to.be.a("string"); - pm.expect(body.data.roles).to.include("student"); -}); -``` - ---- - -**[1.8] Refresh Token — Student 1** - -- Method: POST -- URL: `{{baseUrl}}/auth/refresh` -- Body: - -```json -{ - "refreshToken": "{{student1RefreshToken}}" -} -``` - -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -const body = pm.response.json(); -pm.environment.set("student1AccessToken", body.data.accessToken); -pm.environment.set("student1RefreshToken", body.data.refreshToken); -``` - ---- - -**[1.9] List Sessions — Student 1** - -- Method: GET -- URL: `{{baseUrl}}/auth/sessions` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("Returns array", () => pm.expect(pm.response.json().data).to.be.an("array")); -``` - ---- - -**[1.10] Send OTP — Student 1** - -> Note: Student 1 used a non-institution email so they are not auto-verified. Use this to verify them. Check your -> Ethereal Mail preview URL in the server terminal logs after sending. - -- Method: POST -- URL: `{{baseUrl}}/auth/otp/send` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- No body. -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -``` - ---- - -**[1.11] Verify OTP — Student 1** - -> After sending, check your running server terminal. You will see a log line like -> `previewUrl: "https://ethereal.email/message/..."`. Open that URL in a browser to see the OTP code. - -- Method: POST -- URL: `{{baseUrl}}/auth/otp/verify` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Body: - -```json -{ - "otp": "PASTE_OTP_FROM_ETHEREAL_HERE" -} -``` - -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -``` - ---- - -### 01 - Auth → Error Cases - -**[1.E1] Register with duplicate email** - -- POST `{{baseUrl}}/auth/register` -- Body: same email as Student 1 above. -- Scripts → Post-response: - -```javascript -pm.test("Status 409 conflict", () => pm.response.to.have.status(409)); -``` - -**[1.E2] Login with wrong password** - -- POST `{{baseUrl}}/auth/login` -- Body: `{ "email": "arjun.sharma@student.iitb.ac.in", "password": "wrongpass" }` -- Scripts → Post-response: - -```javascript -pm.test("Status 401", () => pm.response.to.have.status(401)); -``` - -**[1.E3] Register pg_owner without businessName** - -- POST `{{baseUrl}}/auth/register` -- Body: `{ "email": "test@test.com", "password": "Test1234", "role": "pg_owner", "fullName": "Test" }` -- Scripts → Post-response: - -```javascript -pm.test("Status 400", () => pm.response.to.have.status(400)); -``` - -**[1.E4] Access protected route without token** - -- GET `{{baseUrl}}/auth/me` -- No Authorization header. -- Scripts → Post-response: - -```javascript -pm.test("Status 401", () => pm.response.to.have.status(401)); -``` - -**[1.E5] Use expired / invalid token** - -- GET `{{baseUrl}}/auth/me` -- Headers: `Authorization: Bearer thisisnotavalidtoken` -- Scripts → Post-response: - -```javascript -pm.test("Status 401", () => pm.response.to.have.status(401)); -``` - ---- - -### 02 - Student Profiles → Happy Path - -**[2.1] Get Student 1 Profile (self)** - -- GET `{{baseUrl}}/students/{{student1Id}}/profile` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("Returns profile", () => { - const data = pm.response.json().data; - pm.expect(data.full_name).to.eql("Arjun Sharma"); - pm.expect(data.email).to.be.a("string"); // self view includes email -}); -``` - -**[2.2] Get Student 1 Profile (as Student 2 — no email visible)** - -- GET `{{baseUrl}}/students/{{student1Id}}/profile` -- Headers: `Authorization: Bearer {{student2AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("Email hidden for non-owner", () => { - const data = pm.response.json().data; - pm.expect(data.email).to.be.null; -}); -``` - -**[2.3] Update Student 1 Profile** - -- PUT `{{baseUrl}}/students/{{student1Id}}/profile` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Body: - -```json -{ - "bio": "Final year CSE student at IIT Bombay. Looking for a clean and quiet room near campus.", - "course": "B.Tech Computer Science", - "yearOfStudy": 4, - "gender": "male" -} -``` - -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("Bio updated", () => { - pm.expect(pm.response.json().data.bio).to.include("IIT Bombay"); -}); -``` - -**[2.4] Update Student 2 Profile** - -- PUT `{{baseUrl}}/students/{{student2Id}}/profile` -- Headers: `Authorization: Bearer {{student2AccessToken}}` -- Body: - -```json -{ - "bio": "Second year ECE at BITS Pilani. Non-smoker, vegetarian, looking for female-only PG.", - "course": "B.E. Electronics", - "yearOfStudy": 2, - "gender": "female" -} -``` - ---- - -### 02 - Student Profiles → Error Cases - -**[2.E1] Update another student's profile** - -- PUT `{{baseUrl}}/students/{{student2Id}}/profile` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Body: `{ "bio": "Hacked bio" }` -- Scripts → Post-response: - -```javascript -pm.test("Status 403", () => pm.response.to.have.status(403)); -``` - -**[2.E2] Get non-existent profile** - -- GET `{{baseUrl}}/students/00000000-0000-0000-0000-000000000000/profile` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("Status 404", () => pm.response.to.have.status(404)); -``` - ---- - -### 03 - PG Owner Profiles → Happy Path - -**[3.1] Get PG Owner 1 Profile (self)** - -- GET `{{baseUrl}}/pg-owners/{{pgOwner1Id}}/profile` -- Headers: `Authorization: Bearer {{pgOwner1AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("verification_status is unverified", () => { - pm.expect(pm.response.json().data.verification_status).to.eql("unverified"); -}); -``` - -**[3.2] Update PG Owner 1 Profile** - -- PUT `{{baseUrl}}/pg-owners/{{pgOwner1Id}}/profile` -- Headers: `Authorization: Bearer {{pgOwner1AccessToken}}` -- Body: - -```json -{ - "businessDescription": "Clean, well-maintained PG in Koramangala with 24hr water and WiFi.", - "businessPhone": "9876543210", - "operatingSince": 2019 -} -``` - -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -``` - -**[3.3] Update PG Owner 2 Profile** - -- PUT `{{baseUrl}}/pg-owners/{{pgOwner2Id}}/profile` -- Headers: `Authorization: Bearer {{pgOwner2AccessToken}}` -- Body: - -```json -{ - "businessDescription": "Ladies-only hostel in Indiranagar with strict curfew and home food.", - "businessPhone": "9123456780", - "operatingSince": 2021 -} -``` - -**[3.4] Update PG Owner 3 Profile** - -- PUT `{{baseUrl}}/pg-owners/{{pgOwner3Id}}/profile` -- Headers: `Authorization: Bearer {{pgOwner3AccessToken}}` -- Body: - -```json -{ - "businessDescription": "Affordable PG near HSR Layout. Single and double rooms available.", - "businessPhone": "9988776655", - "operatingSince": 2020 -} -``` - ---- - -### 03 - PG Owner Profiles → Error Cases - -**[3.E1] Update another owner's profile** - -- PUT `{{baseUrl}}/pg-owners/{{pgOwner2Id}}/profile` -- Headers: `Authorization: Bearer {{pgOwner1AccessToken}}` -- Body: `{ "businessName": "Hacked" }` -- Scripts → Post-response: - -```javascript -pm.test("Status 403", () => pm.response.to.have.status(403)); -``` - -**[3.E2] Student tries to update a PG owner profile** - -- PUT `{{baseUrl}}/pg-owners/{{pgOwner1Id}}/profile` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Body: `{ "businessName": "Hacked" }` -- Scripts → Post-response: - -```javascript -pm.test("Status 403", () => pm.response.to.have.status(403)); -``` - ---- - -### 04 - Verification (Admin) → Happy Path - -> You need an admin user. The quickest approach for local dev: connect to your DB and run: -> -> ```sql -> INSERT INTO user_roles (user_id, role_name) -> SELECT user_id, 'admin' FROM users WHERE email = 'arjun.sharma@student.iitb.ac.in'; -> ``` -> -> Then re-login as Student 1 to get a fresh token that includes the admin role. - -**[4.1] PG Owner 1 Submits Verification Document** - -- POST `{{baseUrl}}/pg-owners/{{pgOwner1Id}}/documents` -- Headers: `Authorization: Bearer {{pgOwner1AccessToken}}` -- Body: - -```json -{ - "documentType": "owner_id", - "documentUrl": "https://example.com/fake-aadhaar-ravi.pdf" -} -``` - -- Scripts → Post-response: - -```javascript -pm.test("Status 201", () => pm.response.to.have.status(201)); -const body = pm.response.json(); -pm.environment.set("verificationRequest1Id", body.data.request_id); -``` - -**[4.2] PG Owner 2 Submits Verification Document** - -- POST `{{baseUrl}}/pg-owners/{{pgOwner2Id}}/documents` -- Headers: `Authorization: Bearer {{pgOwner2AccessToken}}` -- Body: - -```json -{ - "documentType": "rental_agreement", - "documentUrl": "https://example.com/fake-agreement-sunita.pdf" -} -``` - -- Scripts → Post-response: - -```javascript -const body = pm.response.json(); -pm.environment.set("verificationRequest2Id", body.data.request_id); -``` - -**[4.3] PG Owner 3 Submits Verification Document** - -- POST `{{baseUrl}}/pg-owners/{{pgOwner3Id}}/documents` -- Headers: `Authorization: Bearer {{pgOwner3AccessToken}}` -- Body: - -```json -{ - "documentType": "property_document", - "documentUrl": "https://example.com/fake-property-deepak.pdf" -} -``` - -- Scripts → Post-response: - -```javascript -const body = pm.response.json(); -pm.environment.set("verificationRequest3Id", body.data.request_id); -``` - -**[4.4] Admin Views Verification Queue** - -> Use Student 1's token here — they now have the admin role from the SQL above. - -- GET `{{baseUrl}}/admin/verification-queue` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("Queue has items", () => { - pm.expect(pm.response.json().data.items.length).to.be.above(0); -}); -``` - -**[4.5] Admin Approves PG Owner 1** - -- POST `{{baseUrl}}/admin/verification-queue/{{verificationRequest1Id}}/approve` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Body: - -```json -{ - "adminNotes": "All documents valid. Approved." -} -``` - -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("Status is verified", () => { - pm.expect(pm.response.json().data.status).to.eql("verified"); -}); -``` - -**[4.6] Admin Approves PG Owner 2** - -- POST `{{baseUrl}}/admin/verification-queue/{{verificationRequest2Id}}/approve` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Body: `{ "adminNotes": "Rental agreement verified." }` - -**[4.7] Admin Rejects PG Owner 3** - -- POST `{{baseUrl}}/admin/verification-queue/{{verificationRequest3Id}}/reject` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Body: - -```json -{ - "rejectionReason": "Document is blurry and unreadable. Please upload a clear scan.", - "adminNotes": "Rejected on first submission." -} -``` - -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("Status is rejected", () => { - pm.expect(pm.response.json().data.status).to.eql("rejected"); -}); -``` - -**[4.8] PG Owner 3 Resubmits After Rejection** - -- POST `{{baseUrl}}/pg-owners/{{pgOwner3Id}}/documents` -- Headers: `Authorization: Bearer {{pgOwner3AccessToken}}` -- Body: - -```json -{ - "documentType": "property_document", - "documentUrl": "https://example.com/fake-property-deepak-v2-clear.pdf" -} -``` - -- Scripts → Post-response: - -```javascript -const body = pm.response.json(); -pm.environment.set("verificationRequest3Id", body.data.request_id); -``` - -**[4.9] Admin Approves PG Owner 3 (second attempt)** - -- POST `{{baseUrl}}/admin/verification-queue/{{verificationRequest3Id}}/approve` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Body: `{ "adminNotes": "Clear document on second submission. Approved." }` - ---- - -### 04 - Verification → Error Cases - -**[4.E1] Non-admin tries to view verification queue** - -- GET `{{baseUrl}}/admin/verification-queue` -- Headers: `Authorization: Bearer {{student2AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("Status 403", () => pm.response.to.have.status(403)); -``` - -**[4.E2] PG Owner submits a second document while one is pending** - -> Do this before approving PG Owner 1. Send request 4.1 again to trigger this. - -- POST `{{baseUrl}}/pg-owners/{{pgOwner1Id}}/documents` -- Headers: `Authorization: Bearer {{pgOwner1AccessToken}}` -- Body: `{ "documentType": "owner_id", "documentUrl": "https://example.com/duplicate.pdf" }` -- Scripts → Post-response: - -```javascript -pm.test("Status 409", () => pm.response.to.have.status(409)); -``` - -**[4.E3] Unverified PG Owner tries to create a property** - -> PG Owner 3 is still unverified at this point. This should be rejected. - -- POST `{{baseUrl}}/properties` -- Headers: `Authorization: Bearer {{pgOwner3AccessToken}}` -- Body: - -```json -{ - "propertyName": "Should Fail PG", - "propertyType": "pg", - "addressLine": "123 Test Road", - "city": "Bangalore", - "amenityIds": [] -} -``` - -- Scripts → Post-response: - -```javascript -pm.test("Status 403", () => pm.response.to.have.status(403)); -``` - ---- - -### 05 - Properties → Happy Path - -> PG Owner 1 and PG Owner 2 are now verified. Use their tokens here. - -**[5.1] PG Owner 1 Creates Property 1** - -- POST `{{baseUrl}}/properties` -- Headers: `Authorization: Bearer {{pgOwner1AccessToken}}` -- Body: - -```json -{ - "propertyName": "Mehta PG House — Koramangala", - "description": "Well-maintained 3-storey PG with 24hr water, power backup, and high-speed WiFi.", - "propertyType": "pg", - "addressLine": "47/2, 5th Cross, Koramangala 4th Block", - "city": "Bangalore", - "locality": "Koramangala", - "landmark": "Near Jyoti Nivas College", - "pincode": "560034", - "latitude": 12.9352, - "longitude": 77.6245, - "houseRules": "No smoking. No alcohol. Gate closes at 11pm.", - "totalRooms": 12, - "amenityIds": [] -} -``` - -- Scripts → Post-response: - -```javascript -pm.test("Status 201", () => pm.response.to.have.status(201)); -const body = pm.response.json(); -pm.environment.set("property1Id", body.data.property_id); -``` - -**[5.2] PG Owner 2 Creates Property 2** - -- POST `{{baseUrl}}/properties` -- Headers: `Authorization: Bearer {{pgOwner2AccessToken}}` -- Body: - -```json -{ - "propertyName": "Kapoor Ladies Hostel — Indiranagar", - "description": "Ladies-only hostel with home food, laundry, and 24hr security.", - "propertyType": "hostel", - "addressLine": "12, 100 Feet Road, Indiranagar", - "city": "Bangalore", - "locality": "Indiranagar", - "landmark": "Behind CMH Hospital", - "pincode": "560038", - "latitude": 12.9784, - "longitude": 77.6408, - "totalRooms": 20, - "amenityIds": [] -} -``` - -- Scripts → Post-response: - -```javascript -const body = pm.response.json(); -pm.environment.set("property2Id", body.data.property_id); -``` - -**[5.3] Get Property 1 (as any authenticated user)** - -- GET `{{baseUrl}}/properties/{{property1Id}}` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("Property name matches", () => { - pm.expect(pm.response.json().data.property_name).to.include("Mehta"); -}); -``` - -**[5.4] PG Owner 1 Lists Their Properties** - -- GET `{{baseUrl}}/properties` -- Headers: `Authorization: Bearer {{pgOwner1AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("At least 1 property", () => { - pm.expect(pm.response.json().data.items.length).to.be.above(0); -}); -``` - -**[5.5] PG Owner 1 Updates Property 1** - -- PUT `{{baseUrl}}/properties/{{property1Id}}` -- Headers: `Authorization: Bearer {{pgOwner1AccessToken}}` -- Body: - -```json -{ - "houseRules": "No smoking. No alcohol. Gate closes at 11pm. Guests allowed till 9pm only.", - "totalRooms": 14 -} -``` - -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("totalRooms updated", () => { - pm.expect(pm.response.json().data.total_rooms).to.eql(14); -}); -``` - ---- - -### 05 - Properties → Error Cases - -**[5.E1] Student tries to create a property** - -- POST `{{baseUrl}}/properties` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Body: - `{ "propertyName": "Fake PG", "propertyType": "pg", "addressLine": "1 Test St", "city": "Bangalore", "amenityIds": [] }` -- Scripts → Post-response: - -```javascript -pm.test("Status 403", () => pm.response.to.have.status(403)); -``` - -**[5.E2] PG Owner 2 tries to update PG Owner 1's property** - -- PUT `{{baseUrl}}/properties/{{property1Id}}` -- Headers: `Authorization: Bearer {{pgOwner2AccessToken}}` -- Body: `{ "propertyName": "Hacked" }` -- Scripts → Post-response: - -```javascript -pm.test("Status 404", () => pm.response.to.have.status(404)); -// The service returns 404 — existence is not leaked to non-owners -``` - ---- - -### 06 - Listings → Happy Path - -**[6.1] PG Owner 1 Creates pg_room Listing (Listing 1)** - -- POST `{{baseUrl}}/listings` -- Headers: `Authorization: Bearer {{pgOwner1AccessToken}}` -- Body: - -```json -{ - "listingType": "pg_room", - "propertyId": "{{property1Id}}", - "title": "Single AC Room in Koramangala PG — Male Only", - "description": "Attached bathroom, furnished, WiFi included. Ideal for working professionals or students.", - "rentPerMonth": 12000, - "depositAmount": 24000, - "rentIncludesUtilities": true, - "isNegotiable": false, - "roomType": "single", - "bedType": "single_bed", - "totalCapacity": 1, - "preferredGender": "male", - "availableFrom": "2026-05-01", - "amenityIds": [], - "preferences": [ - { "preferenceKey": "smoking", "preferenceValue": "non_smoker" }, - { "preferenceKey": "food_habit", "preferenceValue": "vegetarian" } - ] -} -``` - -- Scripts → Post-response: - -```javascript -pm.test("Status 201", () => pm.response.to.have.status(201)); -const body = pm.response.json(); -pm.environment.set("listing1Id", body.data.listing_id); -pm.test("Rent is in rupees (not paise)", () => { - pm.expect(body.data.rentPerMonth).to.eql(12000); -}); -``` - -**[6.2] PG Owner 2 Creates hostel_bed Listing (Listing 2)** - -- POST `{{baseUrl}}/listings` -- Headers: `Authorization: Bearer {{pgOwner2AccessToken}}` -- Body: - -```json -{ - "listingType": "hostel_bed", - "propertyId": "{{property2Id}}", - "title": "Bed in Triple Sharing Room — Ladies Hostel Indiranagar", - "description": "Home food included. CCTV and security guard. Walking distance from metro.", - "rentPerMonth": 8000, - "depositAmount": 16000, - "rentIncludesUtilities": true, - "isNegotiable": true, - "roomType": "triple", - "bedType": "single_bed", - "totalCapacity": 3, - "preferredGender": "female", - "availableFrom": "2026-05-01", - "amenityIds": [], - "preferences": [ - { "preferenceKey": "food_habit", "preferenceValue": "vegetarian" }, - { "preferenceKey": "sleep_schedule", "preferenceValue": "early_bird" } - ] -} -``` - -- Scripts → Post-response: - -```javascript -const body = pm.response.json(); -pm.environment.set("listing2Id", body.data.listing_id); -``` - -**[6.3] Student 1 Creates student_room Listing (Listing 3)** - -- POST `{{baseUrl}}/listings` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Body: - -```json -{ - "listingType": "student_room", - "title": "Roommate Needed — 2BHK Flat Near IIT Bombay Gate 1", - "description": "Looking for a clean, studious roommate. 2BHK, sharing one room. No parties.", - "rentPerMonth": 9000, - "depositAmount": 18000, - "rentIncludesUtilities": false, - "isNegotiable": true, - "roomType": "double", - "bedType": "single_bed", - "totalCapacity": 2, - "preferredGender": "male", - "availableFrom": "2026-05-15", - "addressLine": "Room 204, Shastri Nagar CHS, Powai", - "city": "Mumbai", - "locality": "Powai", - "landmark": "Near IIT Bombay Gate 1", - "pincode": "400076", - "latitude": 19.1334, - "longitude": 72.9133, - "amenityIds": [], - "preferences": [ - { "preferenceKey": "smoking", "preferenceValue": "non_smoker" }, - { "preferenceKey": "sleep_schedule", "preferenceValue": "early_bird" } - ] -} -``` - -- Scripts → Post-response: - -```javascript -const body = pm.response.json(); -pm.environment.set("listing3Id", body.data.listing_id); -``` - -**[6.4] Student 2 Creates student_room Listing (Listing 4)** - -- POST `{{baseUrl}}/listings` -- Headers: `Authorization: Bearer {{student2AccessToken}}` -- Body: - -```json -{ - "listingType": "student_room", - "title": "Female Roommate Wanted — BITS Pilani Off-Campus Housing", - "description": "Spacious room in 3BHK. Sharing with 2 other girls. Veg household.", - "rentPerMonth": 7500, - "depositAmount": 15000, - "rentIncludesUtilities": false, - "isNegotiable": false, - "roomType": "triple", - "bedType": "single_bed", - "totalCapacity": 3, - "preferredGender": "female", - "availableFrom": "2026-06-01", - "addressLine": "B-12, Vidhya Vihar Colony", - "city": "Pilani", - "locality": "Vidhya Vihar", - "pincode": "333031", - "amenityIds": [], - "preferences": [ - { "preferenceKey": "food_habit", "preferenceValue": "vegetarian" }, - { "preferenceKey": "alcohol", "preferenceValue": "not_okay" } - ] -} -``` - -- Scripts → Post-response: - -```javascript -const body = pm.response.json(); -pm.environment.set("listing4Id", body.data.listing_id); -``` - -**[6.5] Search Listings — by city Bangalore** - -- GET `{{baseUrl}}/listings?city=Bangalore` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("Results returned", () => { - pm.expect(pm.response.json().data.items.length).to.be.above(0); -}); -pm.test("All results are in Bangalore", () => { - pm.response.json().data.items.forEach((item) => { - pm.expect(item.city.toLowerCase()).to.include("bangalore"); - }); -}); -``` - -**[6.6] Search Listings — by city Mumbai with rent filter** - -- GET `{{baseUrl}}/listings?city=Mumbai&maxRent=10000` -- Headers: `Authorization: Bearer {{student1AccessToken}}` - -**[6.7] Get Single Listing** - -- GET `{{baseUrl}}/listings/{{listing1Id}}` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("Has preferences", () => { - pm.expect(pm.response.json().data.preferences).to.be.an("array"); -}); -``` - -**[6.8] Student 2 Saves Listing 1** - -- POST `{{baseUrl}}/listings/{{listing1Id}}/save` -- Headers: `Authorization: Bearer {{student2AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("saved is true", () => pm.expect(pm.response.json().data.saved).to.be.true); -``` - -**[6.9] Student 2 Views Saved Listings** - -- GET `{{baseUrl}}/listings/me/saved` -- Headers: `Authorization: Bearer {{student2AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("At least 1 saved", () => pm.expect(pm.response.json().data.items.length).to.be.above(0)); -``` - -**[6.10] Student 2 Unsaves Listing 1** - -- DELETE `{{baseUrl}}/listings/{{listing1Id}}/save` -- Headers: `Authorization: Bearer {{student2AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("saved is false", () => pm.expect(pm.response.json().data.saved).to.be.false); -``` - -**[6.11] PG Owner 1 Deactivates Listing 1** - -- PATCH `{{baseUrl}}/listings/{{listing1Id}}/status` -- Headers: `Authorization: Bearer {{pgOwner1AccessToken}}` -- Body: `{ "status": "deactivated" }` -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -``` - -**[6.12] PG Owner 1 Reactivates Listing 1** - -- PATCH `{{baseUrl}}/listings/{{listing1Id}}/status` -- Headers: `Authorization: Bearer {{pgOwner1AccessToken}}` -- Body: `{ "status": "active" }` - ---- - -### 06 - Listings → Error Cases - -**[6.E1] Student tries to create a pg_room listing** - -- POST `{{baseUrl}}/listings` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Body: - `{ "listingType": "pg_room", "propertyId": "{{property1Id}}", "title": "Test", "rentPerMonth": 1000, "depositAmount": 0, "roomType": "single", "totalCapacity": 1, "availableFrom": "2026-05-01", "amenityIds": [] }` -- Scripts → Post-response: - -```javascript -pm.test("Status 403", () => pm.response.to.have.status(403)); -``` - -**[6.E2] Create student_room without city** - -- POST `{{baseUrl}}/listings` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Body: - `{ "listingType": "student_room", "title": "No City Listing", "rentPerMonth": 5000, "depositAmount": 0, "roomType": "single", "totalCapacity": 1, "availableFrom": "2026-05-01", "addressLine": "Some street", "amenityIds": [] }` -- Scripts → Post-response: - -```javascript -pm.test("Status 400", () => pm.response.to.have.status(400)); -``` - -**[6.E3] Invalid status transition (active → filled directly by poster)** - -- PATCH `{{baseUrl}}/listings/{{listing1Id}}/status` -- Headers: `Authorization: Bearer {{pgOwner1AccessToken}}` -- Body: `{ "status": "filled" }` - -> Note: "filled" is a valid status but normally triggered by accepting an interest request that exhausts capacity. The -> service should allow this as a manual override. Test that it returns 200 and then check that all pending interests are -> expired. This tests the explicit fill path. - -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -// Reactivate after this test since the listing is needed for interest flow -``` - -**[6.E4] Reactivate after fill test** - -- Note: A "filled" listing cannot be reactivated. Create a new listing or use listing 2 for the interest flow. - Alternatively skip 6.E3 until after the interest tests. - ---- - -### 07 - Interest Requests → Happy Path - -> Use Listing 1 and Listing 2 for interest tests. Make sure they are active. - -**[7.1] Student 1 Expresses Interest in Listing 1 (PG Owner 1's listing)** - -- POST `{{baseUrl}}/listings/{{listing1Id}}/interests` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Body: - -```json -{ - "message": "Hi, I am Arjun, final year at IIT Bombay. Non-smoker, vegetarian. Very interested in the room. Can we arrange a visit?" -} -``` - -- Scripts → Post-response: - -```javascript -pm.test("Status 201", () => pm.response.to.have.status(201)); -const body = pm.response.json(); -pm.environment.set("interestRequest1Id", body.data.interestRequestId); -pm.test("Status is pending", () => pm.expect(body.data.status).to.eql("pending")); -``` - -**[7.2] Student 2 Expresses Interest in Listing 2 (PG Owner 2's listing)** - -- POST `{{baseUrl}}/listings/{{listing2Id}}/interests` -- Headers: `Authorization: Bearer {{student2AccessToken}}` -- Body: - -```json -{ - "message": "Hi, I am Priya, second year at BITS. Vegetarian, early bird. Very interested in the ladies hostel bed." -} -``` - -- Scripts → Post-response: - -```javascript -const body = pm.response.json(); -pm.environment.set("interestRequest2Id", body.data.interestRequestId); -``` - -**[7.3] PG Owner 1 Views Interests on Listing 1** - -- GET `{{baseUrl}}/listings/{{listing1Id}}/interests` -- Headers: `Authorization: Bearer {{pgOwner1AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("At least 1 request", () => pm.expect(pm.response.json().data.items.length).to.be.above(0)); -``` - -**[7.4] Student 1 Views Their Own Interests** - -- GET `{{baseUrl}}/interests/me` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -``` - -**[7.5] Get Single Interest Request — Student 1** - -- GET `{{baseUrl}}/interests/{{interestRequest1Id}}` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("Status is pending", () => pm.expect(pm.response.json().data.status).to.eql("pending")); -``` - -**[7.6] PG Owner 1 Accepts Interest Request 1** - -- PATCH `{{baseUrl}}/interests/{{interestRequest1Id}}/status` -- Headers: `Authorization: Bearer {{pgOwner1AccessToken}}` -- Body: `{ "status": "accepted" }` -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -const body = pm.response.json(); -pm.test("Status is accepted", () => pm.expect(body.data.status).to.eql("accepted")); -pm.test("connectionId returned", () => pm.expect(body.data.connectionId).to.be.a("string")); -pm.environment.set("connection1Id", body.data.connectionId); -``` - -**[7.7] PG Owner 2 Accepts Interest Request 2** - -- PATCH `{{baseUrl}}/interests/{{interestRequest2Id}}/status` -- Headers: `Authorization: Bearer {{pgOwner2AccessToken}}` -- Body: `{ "status": "accepted" }` -- Scripts → Post-response: - -```javascript -const body = pm.response.json(); -pm.environment.set("connection2Id", body.data.connectionId); -``` - -**[7.8] Student 1 Sends Another Interest and then Withdraws It** - -First, Student 1 sends interest in Listing 3 (Student 2's listing): - -- POST `{{baseUrl}}/listings/{{listing3Id}}/interests` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Body: `{ "message": "Hey, am interested in the roommate spot." }` -- Scripts → Post-response: - -```javascript -const body = pm.response.json(); -pm.environment.set("tempInterestId", body.data.interestRequestId); -``` - -Then withdraw it: - -- PATCH `{{baseUrl}}/interests/{{tempInterestId}}/status` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Body: `{ "status": "withdrawn" }` -- Scripts → Post-response: - -```javascript -pm.test("Status is withdrawn", () => { - pm.expect(pm.response.json().data.status).to.eql("withdrawn"); -}); -``` - ---- - -### 07 - Interest Requests → Error Cases - -**[7.E1] Student tries to send interest in their own listing** - -- POST `{{baseUrl}}/listings/{{listing3Id}}/interests` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Body: `{ "message": "Self interest" }` -- Scripts → Post-response: - -```javascript -pm.test("Status 422", () => pm.response.to.have.status(422)); -``` - -**[7.E2] Send duplicate interest on same listing** - -- POST `{{baseUrl}}/listings/{{listing1Id}}/interests` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Body: `{ "message": "Duplicate" }` -- Scripts → Post-response: - -```javascript -pm.test("Status 409 conflict", () => pm.response.to.have.status(409)); -``` - -**[7.E3] Student tries to accept an interest request (only poster can)** - -- PATCH `{{baseUrl}}/interests/{{interestRequest2Id}}/status` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Body: `{ "status": "accepted" }` -- Scripts → Post-response: - -```javascript -pm.test("Status 403 or 404", () => { - pm.expect([403, 404]).to.include(pm.response.code); -}); -``` - -**[7.E4] PG Owner tries to send an interest request** - -- POST `{{baseUrl}}/listings/{{listing2Id}}/interests` -- Headers: `Authorization: Bearer {{pgOwner1AccessToken}}` -- Body: `{ "message": "Owner interest" }` -- Scripts → Post-response: - -```javascript -pm.test("Status 403", () => pm.response.to.have.status(403)); -``` - ---- - -### 08 - Connections → Happy Path - -**[8.1] Student 1 Gets Connection 1 Detail** - -- GET `{{baseUrl}}/connections/{{connection1Id}}` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("confirmationStatus is pending", () => { - pm.expect(pm.response.json().data.confirmationStatus).to.eql("pending"); -}); -pm.test("initiatorConfirmed is false", () => { - pm.expect(pm.response.json().data.initiatorConfirmed).to.be.false; -}); -``` - -**[8.2] Student 1 Confirms Connection 1** - -- POST `{{baseUrl}}/connections/{{connection1Id}}/confirm` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -const body = pm.response.json(); -pm.test("initiatorConfirmed is now true", () => pm.expect(body.data.initiatorConfirmed).to.be.true); -pm.test("Still pending (only one party confirmed)", () => { - pm.expect(body.data.confirmationStatus).to.eql("pending"); -}); -``` - -**[8.3] PG Owner 1 Confirms Connection 1** - -- POST `{{baseUrl}}/connections/{{connection1Id}}/confirm` -- Headers: `Authorization: Bearer {{pgOwner1AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -const body = pm.response.json(); -pm.test("counterpartConfirmed is now true", () => pm.expect(body.data.counterpartConfirmed).to.be.true); -pm.test("confirmationStatus is now confirmed", () => { - pm.expect(body.data.confirmationStatus).to.eql("confirmed"); -}); -``` - -**[8.4] Student 2 Confirms Connection 2** - -- POST `{{baseUrl}}/connections/{{connection2Id}}/confirm` -- Headers: `Authorization: Bearer {{student2AccessToken}}` - -**[8.5] PG Owner 2 Confirms Connection 2** - -- POST `{{baseUrl}}/connections/{{connection2Id}}/confirm` -- Headers: `Authorization: Bearer {{pgOwner2AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("confirmationStatus is confirmed", () => { - pm.expect(pm.response.json().data.confirmationStatus).to.eql("confirmed"); -}); -``` - -**[8.6] Student 1 Views All Their Connections** - -- GET `{{baseUrl}}/connections/me` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("At least 1 connection", () => pm.expect(pm.response.json().data.items.length).to.be.above(0)); -``` - -**[8.7] Filter Connections by confirmationStatus** - -- GET `{{baseUrl}}/connections/me?confirmationStatus=confirmed` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("All returned connections are confirmed", () => { - pm.response.json().data.items.forEach((item) => { - pm.expect(item.confirmationStatus).to.eql("confirmed"); - }); -}); -``` - ---- - -### 08 - Connections → Error Cases - -**[8.E1] Third party tries to view a connection they are not party to** - -- GET `{{baseUrl}}/connections/{{connection1Id}}` -- Headers: `Authorization: Bearer {{student2AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("Status 404", () => pm.response.to.have.status(404)); -``` - -**[8.E2] Confirm a connection you are not party to** - -- POST `{{baseUrl}}/connections/{{connection1Id}}/confirm` -- Headers: `Authorization: Bearer {{student2AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("Status 404", () => pm.response.to.have.status(404)); -``` - ---- - -### 09 - Ratings → Happy Path - -> Both connections must be confirmed before ratings can be submitted. - -**[9.1] Student 1 Rates PG Owner 1 (user rating)** - -- POST `{{baseUrl}}/ratings` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Body: - -```json -{ - "connectionId": "{{connection1Id}}", - "revieweeType": "user", - "revieweeId": "{{pgOwner1Id}}", - "overallScore": 4, - "cleanlinessScore": 5, - "communicationScore": 4, - "reliabilityScore": 4, - "comment": "Ravi was very responsive and the room was exactly as described. Great experience overall." -} -``` - -- Scripts → Post-response: - -```javascript -pm.test("Status 201", () => pm.response.to.have.status(201)); -const body = pm.response.json(); -pm.environment.set("rating1Id", body.data.ratingId); -``` - -**[9.2] Student 1 Rates Property 1** - -- POST `{{baseUrl}}/ratings` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Body: - -```json -{ - "connectionId": "{{connection1Id}}", - "revieweeType": "property", - "revieweeId": "{{property1Id}}", - "overallScore": 4, - "cleanlinessScore": 5, - "valueScore": 3, - "comment": "Property is clean and well-maintained. Slightly overpriced for the locality." -} -``` - -- Scripts → Post-response: - -```javascript -pm.test("Status 201", () => pm.response.to.have.status(201)); -``` - -**[9.3] PG Owner 1 Rates Student 1** - -- POST `{{baseUrl}}/ratings` -- Headers: `Authorization: Bearer {{pgOwner1AccessToken}}` -- Body: - -```json -{ - "connectionId": "{{connection1Id}}", - "revieweeType": "user", - "revieweeId": "{{student1Id}}", - "overallScore": 5, - "cleanlinessScore": 5, - "reliabilityScore": 5, - "comment": "Arjun was an excellent tenant. Paid on time and kept the room clean." -} -``` - -**[9.4] Student 2 Rates PG Owner 2** - -- POST `{{baseUrl}}/ratings` -- Headers: `Authorization: Bearer {{student2AccessToken}}` -- Body: - -```json -{ - "connectionId": "{{connection2Id}}", - "revieweeType": "user", - "revieweeId": "{{pgOwner2Id}}", - "overallScore": 3, - "communicationScore": 2, - "cleanlinessScore": 4, - "comment": "Room was clean but the owner was hard to reach when there were issues." -} -``` - -**[9.5] Get Ratings for Connection 1 (both parties' view)** - -- GET `{{baseUrl}}/ratings/connection/{{connection1Id}}` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -const body = pm.response.json().data; -pm.test("myRatings is array", () => pm.expect(body.myRatings).to.be.an("array")); -pm.test("theirRatings is array", () => pm.expect(body.theirRatings).to.be.an("array")); -``` - -**[9.6] Get Public Ratings for PG Owner 1 (no auth needed)** - -- GET `{{baseUrl}}/ratings/user/{{pgOwner1Id}}` -- No Authorization header. -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("At least 1 rating", () => pm.expect(pm.response.json().data.items.length).to.be.above(0)); -``` - -**[9.7] Get Public Ratings for Property 1 (no auth needed)** - -- GET `{{baseUrl}}/ratings/property/{{property1Id}}` -- No Authorization header. -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -``` - -**[9.8] Student 1 Views All Ratings They Have Given** - -- GET `{{baseUrl}}/ratings/me/given` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("Has at least 2 given ratings", () => { - pm.expect(pm.response.json().data.items.length).to.be.at.least(2); -}); -``` - ---- - -### 09 - Ratings → Error Cases - -**[9.E1] Submit duplicate rating for same connection + reviewee** - -- POST `{{baseUrl}}/ratings` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Body: same as 9.1 (same connectionId + revieweeId) -- Scripts → Post-response: - -```javascript -pm.test("Status 409", () => pm.response.to.have.status(409)); -``` - -**[9.E2] Rate someone who is not party to the connection** - -- POST `{{baseUrl}}/ratings` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Body: - -```json -{ - "connectionId": "{{connection1Id}}", - "revieweeType": "user", - "revieweeId": "{{student2Id}}", - "overallScore": 5, - "comment": "Rating a non-party should fail" -} -``` - -- Scripts → Post-response: - -```javascript -pm.test("Status 422", () => pm.response.to.have.status(422)); -``` - -**[9.E3] Rate on an unconfirmed connection** - -> Create a new interest request between Student 1 and Listing 2, accept it, but do NOT confirm the connection. Then try -> to rate. - -- POST `{{baseUrl}}/ratings` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Body: - `{ "connectionId": "{{connection2Id}}", "revieweeType": "user", "revieweeId": "{{pgOwner2Id}}", "overallScore": 5, "comment": "Should fail if unconfirmed" }` - -> Note: connection2 IS confirmed in 8.5. For a true negative test, you need a fresh unconfirmed connection. Skip this or -> create a new interest on listing 3 → accept but don't confirm. - -**[9.E4] Self-rating attempt** - -- POST `{{baseUrl}}/ratings` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Body: - -```json -{ - "connectionId": "{{connection1Id}}", - "revieweeType": "user", - "revieweeId": "{{student1Id}}", - "overallScore": 5, - "comment": "Rating myself" -} -``` - -- Scripts → Post-response: - -```javascript -pm.test("Status 422", () => pm.response.to.have.status(422)); -``` - ---- - -### 10 - Reports (Admin) → Happy Path - -**[10.1] PG Owner 1 Reports Student 2's Rating of PG Owner 2** - -> PG Owner 1 is party to connection1. Student 2 rated PG Owner 2 via connection2. PG Owner 1 is NOT party to -> connection2. This should return 404 (testing the party-membership check). Use PG Owner 2 to file the report instead. - -**[10.1] PG Owner 2 Reports Student 2's Rating** - -- POST `{{baseUrl}}/ratings/{{rating1Id}}/report` - -> Wait — rating1Id is PG Owner 1 rated by Student 1. PG Owner 2 is not party to that connection. Use the correct party. -> PG Owner 1 should report a rating that was submitted ON connection1. - -Correct flow: PG Owner 1 reports Student 2's rating if Student 2 submitted a rating on connection1. Since only Student 1 -and PG Owner 1 are parties to connection1, only they can report ratings on it. - -Let's say PG Owner 1 believes rating1 (Student 1's rating of PG Owner 1) is fake: - -- POST `{{baseUrl}}/ratings/{{rating1Id}}/report` -- Headers: `Authorization: Bearer {{pgOwner1AccessToken}}` -- Body: - -```json -{ - "reason": "fake", - "explanation": "This rating appears to be from someone I never actually hosted. Requesting review." -} -``` - -- Scripts → Post-response: - -```javascript -pm.test("Status 201", () => pm.response.to.have.status(201)); -const body = pm.response.json(); -pm.environment.set("report1Id", body.data.reportId); -pm.test("Status is open", () => pm.expect(body.data.status).to.eql("open")); -``` - -**[10.2] Admin Views Report Queue** - -- GET `{{baseUrl}}/admin/report-queue` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("At least 1 report", () => pm.expect(pm.response.json().data.items.length).to.be.above(0)); -``` - -**[10.3] Admin Resolves Report — Kept (rating is legitimate)** - -- PATCH `{{baseUrl}}/admin/reports/{{report1Id}}/resolve` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Body: - -```json -{ - "resolution": "resolved_kept", - "adminNotes": "Reviewed the connection history. Rating appears genuine." -} -``` - -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("resolution is resolved_kept", () => { - pm.expect(pm.response.json().data.resolution).to.eql("resolved_kept"); -}); -``` - -**[10.4] File a New Report and Resolve as Removed** - -File a fresh report (PG Owner 1 reports again — the previous report was resolved so they can report again): - -- POST `{{baseUrl}}/ratings/{{rating1Id}}/report` -- Headers: `Authorization: Bearer {{pgOwner1AccessToken}}` -- Body: - -```json -{ - "reason": "abusive", - "explanation": "The review text is personally attacking and abusive. Please remove." -} -``` - -- Scripts → Post-response: - -```javascript -const body = pm.response.json(); -pm.environment.set("report2Id", body.data.reportId); -``` - -Then resolve as removed: - -- PATCH `{{baseUrl}}/admin/reports/{{report2Id}}/resolve` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Body: - -```json -{ - "resolution": "resolved_removed", - "adminNotes": "Review contains personal attack language. Rating hidden per moderation policy." -} -``` - -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("resolution is resolved_removed", () => { - pm.expect(pm.response.json().data.resolution).to.eql("resolved_removed"); -}); -``` - -Then verify the rating is now invisible in the public feed: - -- GET `{{baseUrl}}/ratings/user/{{pgOwner1Id}}` -- No Authorization header. -- Scripts → Post-response: - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -// The removed rating should not appear -const ids = pm.response.json().data.items.map((r) => r.ratingId); -pm.test("Removed rating not in public feed", () => { - pm.expect(ids).to.not.include(pm.environment.get("rating1Id")); -}); -``` - ---- - -### 10 - Reports → Error Cases - -**[10.E1] Non-party files a report** - -- POST `{{baseUrl}}/ratings/{{rating1Id}}/report` -- Headers: `Authorization: Bearer {{student2AccessToken}}` -- Body: `{ "reason": "fake", "explanation": "I was not part of this connection" }` -- Scripts → Post-response: - -```javascript -pm.test("Status 404", () => pm.response.to.have.status(404)); -``` - -**[10.E2] Resolve a report without adminNotes when resolution is resolved_removed** - -- PATCH `{{baseUrl}}/admin/reports/{{report1Id}}/resolve` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Body: `{ "resolution": "resolved_removed" }` -- Scripts → Post-response: - -```javascript -pm.test("Status 400", () => pm.response.to.have.status(400)); -``` - -**[10.E3] Resolve an already-resolved report** - -- PATCH `{{baseUrl}}/admin/reports/{{report1Id}}/resolve` -- Headers: `Authorization: Bearer {{student1AccessToken}}` -- Body: `{ "resolution": "resolved_kept", "adminNotes": "Trying to resolve twice" }` -- Scripts → Post-response: - -```javascript -pm.test("Status 409", () => pm.response.to.have.status(409)); -``` - ---- - -## Part 5 — Quick Navigation Tips in Postman (New UI, Linux) - -**Running all requests in order:** - -1. Right-click the collection → **Run collection**. -2. The Collection Runner opens. All requests are listed in order. -3. Click **Run Roomies API** to execute them sequentially with all scripts active. -4. Results show pass/fail per test assertion inline. - -**Viewing the raw request/response:** - -- Click the **Console** button in the bottom footer bar (`Ctrl+Alt+C`). -- Every request shows the exact URL, headers, and response body sent/received. - -**Re-running a single request after a failure:** - -- Click the request in the sidebar → it opens in a tab → hit **Send** (`Ctrl+Enter`). - -**Checking current variable values:** - -- Click the **eye icon** in the top-right next to the environment name to see all current variable values at a glance. - -**Adding a variable manually (e.g. pasting an OTP):** - -- Click the environment name → find the variable → edit the value directly. - -**Postman Console for debugging scripts:** - -- In any post-response script, use `console.log(pm.response.json())` to print to the Console tab. - ---- - -## Part 6 — Test Execution Order Summary - -Follow this order to ensure every variable is populated before it is used: - -``` -00 Health -01.1 → 01.5 Register all 5 users -01.6 Login Student 1 (refresh token) -01.10 → 01.11 OTP flow for Student 1 (check Ethereal terminal log) -02.3 → 02.4 Update student profiles -03.2 → 03.4 Update PG owner profiles -[DB SQL] Grant admin role to Student 1 -01.6 Re-login Student 1 (get fresh token with admin role) -04.1 → 04.3 All 3 PG owners submit documents -04.4 Admin views queue -04.5 → 04.6 Approve PG Owner 1 and 2 -04.7 Reject PG Owner 3 -04.8 → 04.9 PG Owner 3 resubmits and is approved -05.1 → 05.2 Create properties -06.1 → 06.4 Create all 4 listings -07.1 → 07.2 Students send interest requests -07.6 → 07.7 Owners accept the requests (connectionIds captured) -08.2 → 08.5 Both parties confirm both connections -09.1 → 09.4 Submit ratings -10.1 File a report -10.2 → 10.3 Admin resolves report -``` - -## That is the complete plan — every request, every script, every error case, in exact folder and execution order. - -Let's get Postman set up properly before touching a single request. - ---- - -## Step 1 — Open Postman and orient yourself - -When Postman opens you will see: - -- **Left sidebar** — Collections, Environments, History icons on the far left edge -- **Top bar** — workspace name, environment dropdown (top-right), your avatar -- **Center** — the workbench / tab area -- **Bottom footer bar** — Console, Runner, Cookies buttons - ---- - -## Step 2 — Create a Workspace (keep things clean) - -1. Click the **Workspaces** dropdown in the top-left header -2. Click **Create Workspace** -3. Choose **Blank workspace** -4. Name it: `Roomies Backend Testing` -5. Set visibility to **Personal** -6. Click **Create** - ---- - -## Step 3 — Create the Environment - -1. Click the **Environments** icon in the left sidebar (looks like a slider/toggle icon) -2. Click **+** to create a new environment -3. Name it exactly: `Roomies Local` -4. Now add the variables one by one using the table below - -For each variable: - -- Type the name in the **Variable** column -- Set the value where shown -- Tick **Sensitive** checkbox for anything marked yes -- Leave the value blank for variables that will be auto-filled by scripts - -| Variable | Value to enter now | Sensitive | -| ------------------------ | ------------------------------ | --------- | -| `baseUrl` | `http://localhost:3000/api/v1` | No | -| `student1AccessToken` | _(blank)_ | Yes | -| `student1RefreshToken` | _(blank)_ | Yes | -| `student1Id` | _(blank)_ | No | -| `student2AccessToken` | _(blank)_ | Yes | -| `student2RefreshToken` | _(blank)_ | Yes | -| `student2Id` | _(blank)_ | No | -| `pgOwner1AccessToken` | _(blank)_ | Yes | -| `pgOwner1RefreshToken` | _(blank)_ | Yes | -| `pgOwner1Id` | _(blank)_ | No | -| `pgOwner2AccessToken` | _(blank)_ | Yes | -| `pgOwner2RefreshToken` | _(blank)_ | Yes | -| `pgOwner2Id` | _(blank)_ | No | -| `pgOwner3AccessToken` | _(blank)_ | Yes | -| `pgOwner3RefreshToken` | _(blank)_ | Yes | -| `pgOwner3Id` | _(blank)_ | No | -| `property1Id` | _(blank)_ | No | -| `property2Id` | _(blank)_ | No | -| `listing1Id` | _(blank)_ | No | -| `listing2Id` | _(blank)_ | No | -| `listing3Id` | _(blank)_ | No | -| `listing4Id` | _(blank)_ | No | -| `interestRequest1Id` | _(blank)_ | No | -| `interestRequest2Id` | _(blank)_ | No | -| `tempInterestId` | _(blank)_ | No | -| `connection1Id` | _(blank)_ | No | -| `connection2Id` | _(blank)_ | No | -| `rating1Id` | _(blank)_ | No | -| `report1Id` | _(blank)_ | No | -| `report2Id` | _(blank)_ | No | -| `verificationRequest1Id` | _(blank)_ | No | -| `verificationRequest2Id` | _(blank)_ | No | -| `verificationRequest3Id` | _(blank)_ | No | - -5. Click **Save** (or it autosaves — you will see a green dot disappear) -6. **Activate the environment** — in the top-right corner of Postman click the environment dropdown that says **No - environment** and select **Roomies Local** - -You should now see `Roomies Local` shown in the top-right. The eye icon next to it lets you peek at all variable values -at any time. - ---- - -## Step 4 — Create the Collection - -1. Click **Collections** icon in the left sidebar -2. Click **+** → **Blank collection** -3. Name it: `Roomies API — Full E2E Test Suite` -4. Click the collection name to open its settings panel on the right - ---- - -## Step 5 — Set the Collection-level Header - -This is the most important step. Adding `X-Client-Transport: bearer` at the collection level means every single request -automatically sends it, so tokens come back in the JSON body and your scripts can capture them. - -1. With the collection selected, click the **Headers** tab in the right panel -2. Click **Add header** -3. Key: `X-Client-Transport` -4. Value: `bearer` - ---- - -## Step 6 — Set the Collection-level Pre-request Script - -1. Still on the collection settings, click the **Scripts** tab -2. In the **Pre-request** section paste: - -```javascript -// Safety net: ensure baseUrl is always set -if (!pm.environment.get("baseUrl")) { - pm.environment.set("baseUrl", "http://localhost:3000/api/v1"); - console.log("baseUrl was missing — set to default"); -} -``` - ---- - -## Step 7 — Create all the Folders - -Right-click the collection name → **Add folder** for each one. Create them in this exact order: - -``` -00 - Health Check -01 - Auth -02 - Student Profiles -03 - PG Owner Profiles -04 - Verification (Admin) -05 - Properties -06 - Listings -07 - Interest Requests -08 - Connections -09 - Ratings -10 - Reports (Admin) -``` - -Then for each numbered folder, right-click it → **Add folder** to create two subfolders inside: - -``` -Happy Path -Error Cases -``` - -When done your sidebar tree looks like this: - -``` -📁 Roomies API — Full E2E Test Suite - 📁 00 - Health Check - 📁 01 - Auth - 📁 Happy Path - 📁 Error Cases - 📁 02 - Student Profiles - 📁 Happy Path - 📁 Error Cases - ... and so on -``` - ---- - -## Step 8 — Verify the setup before adding any requests - -Do this quick sanity check: - -1. Click the **eye icon** next to `Roomies Local` in the top-right -2. Confirm `baseUrl` shows `http://localhost:3000/api/v1` -3. Open the **Console** (bottom footer → Console or `Ctrl+Alt+C`) -4. Keep it open while testing — every request/response will appear there - ---- - -## Step 9 — Quick smoke test to confirm Postman can reach your server - -1. Click the `00 - Health Check` folder -2. Right-click → **Add request** -3. Name it `GET Health` -4. Set method to **GET** -5. URL: `{{baseUrl}}/health` -6. Click **Send** - -You should see in the response: `Working ` - -```json -{ - "status": "ok", - "timestamp": "...", - "services": { - "database": "ok", - "redis": "ok" - } -} -``` - -## Now the postman base setup is done, health route is working. - -Perfect. All three services are up and responding. Let's start building the requests folder by folder. - ---- - -## Folder 01 - Auth → Happy Path - -We are adding 11 requests. For each one: - -- Right-click the **Happy Path** subfolder inside **01 - Auth** → **Add request** -- Set the name, method, URL, body, and script exactly as shown - ---- - -### [1.1] Register Student 1 - -- **Name:** `[1.1] Register Student 1` -- **Method:** POST -- **URL:** `{{baseUrl}}/auth/register` -- **Body tab** → select **raw** → dropdown set to **JSON**: - -```json -{ - "email": "arjun.sharma@student.iitb.ac.in", - "password": "Test1234", - "role": "student", - "fullName": "Arjun Sharma" -} -``` - -- **Scripts tab** → **Post-response** section: - -```javascript -pm.test("Status 201", () => pm.response.to.have.status(201)); -pm.test("Returns access token", () => { - const body = pm.response.json(); - pm.expect(body.data.accessToken).to.be.a("string"); -}); - -const body = pm.response.json(); -pm.environment.set("student1AccessToken", body.data.accessToken); -pm.environment.set("student1RefreshToken", body.data.refreshToken); -pm.environment.set("student1Id", body.data.user.userId); - -console.log("student1Id:", body.data.user.userId); -``` - -Click **Send**. Expected: `201 Created`. - ---- - -### [1.2] Register Student 2 - -- **Name:** `[1.2] Register Student 2` -- **Method:** POST -- **URL:** `{{baseUrl}}/auth/register` -- **Body:** - -```json -{ - "email": "priya.nair@student.bits.ac.in", - "password": "Test1234", - "role": "student", - "fullName": "Priya Nair" -} -``` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 201", () => pm.response.to.have.status(201)); - -const body = pm.response.json(); -pm.environment.set("student2AccessToken", body.data.accessToken); -pm.environment.set("student2RefreshToken", body.data.refreshToken); -pm.environment.set("student2Id", body.data.user.userId); - -console.log("student2Id:", body.data.user.userId); -``` - ---- - -### [1.3] Register PG Owner 1 - -- **Name:** `[1.3] Register PG Owner 1` -- **Method:** POST -- **URL:** `{{baseUrl}}/auth/register` -- **Body:** - -```json -{ - "email": "ravi.mehta@gmail.com", - "password": "Test1234", - "role": "pg_owner", - "fullName": "Ravi Mehta", - "businessName": "Mehta PG House" -} -``` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 201", () => pm.response.to.have.status(201)); - -const body = pm.response.json(); -pm.environment.set("pgOwner1AccessToken", body.data.accessToken); -pm.environment.set("pgOwner1RefreshToken", body.data.refreshToken); -pm.environment.set("pgOwner1Id", body.data.user.userId); - -console.log("pgOwner1Id:", body.data.user.userId); -``` - ---- - -### [1.4] Register PG Owner 2 - -- **Name:** `[1.4] Register PG Owner 2` -- **Method:** POST -- **URL:** `{{baseUrl}}/auth/register` -- **Body:** - -```json -{ - "email": "sunita.kapoor@gmail.com", - "password": "Test1234", - "role": "pg_owner", - "fullName": "Sunita Kapoor", - "businessName": "Kapoor Ladies Hostel" -} -``` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 201", () => pm.response.to.have.status(201)); - -const body = pm.response.json(); -pm.environment.set("pgOwner2AccessToken", body.data.accessToken); -pm.environment.set("pgOwner2RefreshToken", body.data.refreshToken); -pm.environment.set("pgOwner2Id", body.data.user.userId); - -console.log("pgOwner2Id:", body.data.user.userId); -``` - ---- - -### [1.5] Register PG Owner 3 - -- **Name:** `[1.5] Register PG Owner 3` -- **Method:** POST -- **URL:** `{{baseUrl}}/auth/register` -- **Body:** - -```json -{ - "email": "deepak.joshi@gmail.com", - "password": "Test1234", - "role": "pg_owner", - "fullName": "Deepak Joshi", - "businessName": "Joshi Paying Guest" -} -``` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 201", () => pm.response.to.have.status(201)); - -const body = pm.response.json(); -pm.environment.set("pgOwner3AccessToken", body.data.accessToken); -pm.environment.set("pgOwner3RefreshToken", body.data.refreshToken); -pm.environment.set("pgOwner3Id", body.data.user.userId); - -console.log("pgOwner3Id:", body.data.user.userId); -``` - ---- - -### [1.6] Login Student 1 - -- **Name:** `[1.6] Login Student 1` -- **Method:** POST -- **URL:** `{{baseUrl}}/auth/login` -- **Body:** - -```json -{ - "email": "arjun.sharma@student.iitb.ac.in", - "password": "Test1234" -} -``` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); - -const body = pm.response.json(); -pm.environment.set("student1AccessToken", body.data.accessToken); -pm.environment.set("student1RefreshToken", body.data.refreshToken); - -console.log("Student 1 token refreshed via login"); -``` - ---- - -### [1.7] Get Me — Student 1 - -- **Name:** `[1.7] Get Me — Student 1` -- **Method:** GET -- **URL:** `{{baseUrl}}/auth/me` -- **Headers tab** → Add: - - Key: `Authorization` - - Value: `Bearer {{student1AccessToken}}` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("Has userId", () => { - pm.expect(pm.response.json().data.userId).to.be.a("string"); -}); -pm.test("Has student role", () => { - pm.expect(pm.response.json().data.roles).to.include("student"); -}); -``` - ---- - -### [1.8] Refresh Token — Student 1 - -- **Name:** `[1.8] Refresh Token — Student 1` -- **Method:** POST -- **URL:** `{{baseUrl}}/auth/refresh` -- **Body:** - -```json -{ - "refreshToken": "{{student1RefreshToken}}" -} -``` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); - -const body = pm.response.json(); -pm.environment.set("student1AccessToken", body.data.accessToken); -pm.environment.set("student1RefreshToken", body.data.refreshToken); - -console.log("Student 1 tokens rotated via refresh"); -``` - ---- - -### [1.9] List Sessions — Student 1 - -- **Name:** `[1.9] List Sessions — Student 1` -- **Method:** GET -- **URL:** `{{baseUrl}}/auth/sessions` -- **Headers:** `Authorization: Bearer {{student1AccessToken}}` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("Returns array of sessions", () => { - pm.expect(pm.response.json().data).to.be.an("array"); -}); -pm.test("At least 1 session exists", () => { - pm.expect(pm.response.json().data.length).to.be.above(0); -}); -``` - ---- - -### [1.10] Send OTP — Student 1 - -> Student 1 used a non-institution email so they need manual OTP verification. After hitting Send, watch your **server -> terminal** — you will see a line like: `previewUrl: "https://ethereal.email/message/ABC123..."` Open that URL in a -> browser to get the 6-digit OTP code. - -- **Name:** `[1.10] Send OTP — Student 1` -- **Method:** POST -- **URL:** `{{baseUrl}}/auth/otp/send` -- **Headers:** `Authorization: Bearer {{student1AccessToken}}` -- **Body:** none (no body needed) - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("OTP sent message", () => { - pm.expect(pm.response.json().message).to.include("OTP"); -}); -console.log("CHECK YOUR SERVER TERMINAL for the Ethereal Mail preview URL"); -``` - ---- - -### [1.11] Verify OTP — Student 1 - -> Before sending this request, go to your server terminal, copy the Ethereal preview URL, open it in a browser, and copy -> the 6-digit OTP from the email. Then paste it into the body below. - -- **Name:** `[1.11] Verify OTP — Student 1` -- **Method:** POST -- **URL:** `{{baseUrl}}/auth/otp/verify` -- **Headers:** `Authorization: Bearer {{student1AccessToken}}` -- **Body:** - -```json -{ - "otp": "PASTE_6_DIGIT_CODE_HERE" -} -``` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("Email verified message", () => { - pm.expect(pm.response.json().message).to.include("verified"); -}); -console.log("Student 1 email is now verified"); -``` - ---- - -Now add the Error Cases. Right-click the **Error Cases** subfolder inside **01 - Auth** → **Add request** for each: - ---- - -### [1.E1] Duplicate Email Registration - -- **Name:** `[1.E1] Duplicate Email Registration` -- **Method:** POST -- **URL:** `{{baseUrl}}/auth/register` -- **Body:** - -```json -{ - "email": "arjun.sharma@student.iitb.ac.in", - "password": "Test1234", - "role": "student", - "fullName": "Fake Arjun" -} -``` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 409 — duplicate email rejected", () => { - pm.response.to.have.status(409); -}); -``` - ---- - -### [1.E2] Wrong Password Login - -- **Name:** `[1.E2] Wrong Password Login` -- **Method:** POST -- **URL:** `{{baseUrl}}/auth/login` -- **Body:** - -```json -{ - "email": "arjun.sharma@student.iitb.ac.in", - "password": "WrongPassword99" -} -``` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 401 — invalid credentials", () => { - pm.response.to.have.status(401); -}); -``` - ---- - -### [1.E3] PG Owner Register Without businessName - -- **Name:** `[1.E3] PG Owner Without businessName` -- **Method:** POST -- **URL:** `{{baseUrl}}/auth/register` -- **Body:** - -```json -{ - "email": "nobusiness@test.com", - "password": "Test1234", - "role": "pg_owner", - "fullName": "No Business" -} -``` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 400 — businessName required", () => { - pm.response.to.have.status(400); -}); -``` - ---- - -### [1.E4] Access Protected Route Without Token - -- **Name:** `[1.E4] No Token — Protected Route` -- **Method:** GET -- **URL:** `{{baseUrl}}/auth/me` -- **Headers:** make sure there is NO Authorization header (remove it if Postman auto-added one) - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 401 — no token", () => { - pm.response.to.have.status(401); -}); -``` - ---- - -### [1.E5] Invalid Token - -- **Name:** `[1.E5] Invalid Token` -- **Method:** GET -- **URL:** `{{baseUrl}}/auth/me` -- **Headers:** - - `Authorization: Bearer thisisacompletlyfaketokenstring` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 401 — invalid token", () => { - pm.response.to.have.status(401); -}); -``` - ---- - -## Folder 02 - Student Profiles - -Right-click the **Happy Path** subfolder inside **02 - Student Profiles** → **Add request** for each. - ---- - -### [2.1] Get Student 1 Profile (self view) - -- **Name:** `[2.1] Get Student 1 Profile (self)` -- **Method:** GET -- **URL:** `{{baseUrl}}/students/{{student1Id}}/profile` -- **Headers:** `Authorization: Bearer {{student1AccessToken}}` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("Returns correct user", () => { - pm.expect(pm.response.json().data.full_name).to.eql("Arjun Sharma"); -}); -pm.test("Self view includes email", () => { - pm.expect(pm.response.json().data.email).to.be.a("string"); - pm.expect(pm.response.json().data.email).to.include("@"); -}); -``` - ---- - -### [2.2] Get Student 1 Profile (viewed by Student 2) - -- **Name:** `[2.2] Get Student 1 Profile (other user — email hidden)` -- **Method:** GET -- **URL:** `{{baseUrl}}/students/{{student1Id}}/profile` -- **Headers:** `Authorization: Bearer {{student2AccessToken}}` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("Full name still visible", () => { - pm.expect(pm.response.json().data.full_name).to.eql("Arjun Sharma"); -}); -pm.test("Email hidden for non-owner", () => { - pm.expect(pm.response.json().data.email).to.be.null; -}); -``` - ---- - -### [2.3] Update Student 1 Profile - -- **Name:** `[2.3] Update Student 1 Profile` -- **Method:** PUT -- **URL:** `{{baseUrl}}/students/{{student1Id}}/profile` -- **Headers:** `Authorization: Bearer {{student1AccessToken}}` -- **Body:** - -```json -{ - "bio": "Final year CSE student at IIT Bombay. Looking for a clean and quiet room near campus.", - "course": "B.Tech Computer Science", - "yearOfStudy": 4, - "gender": "male" -} -``` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("Bio updated correctly", () => { - pm.expect(pm.response.json().data.bio).to.include("IIT Bombay"); -}); -pm.test("Course updated", () => { - pm.expect(pm.response.json().data.course).to.eql("B.Tech Computer Science"); -}); -pm.test("Year of study updated", () => { - pm.expect(pm.response.json().data.year_of_study).to.eql(4); -}); -pm.test("Gender updated", () => { - pm.expect(pm.response.json().data.gender).to.eql("male"); -}); -``` - ---- - -### [2.4] Update Student 2 Profile - -- **Name:** `[2.4] Update Student 2 Profile` -- **Method:** PUT -- **URL:** `{{baseUrl}}/students/{{student2Id}}/profile` -- **Headers:** `Authorization: Bearer {{student2AccessToken}}` -- **Body:** - -```json -{ - "bio": "Second year ECE at BITS Pilani. Non-smoker, vegetarian, looking for female-only PG.", - "course": "B.E. Electronics", - "yearOfStudy": 2, - "gender": "female" -} -``` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("Bio updated", () => { - pm.expect(pm.response.json().data.bio).to.include("BITS Pilani"); -}); -pm.test("Gender updated", () => { - pm.expect(pm.response.json().data.gender).to.eql("female"); -}); -``` - ---- -Looking at your updated codebase, I can see that `[2.5] Set User Preferences — Student 1` was originally noted in the master plan as a skip (because preferences are set per-listing, not via a standalone user endpoint). But from your updated repo, I can see you now have a full student preferences system: - -- `GET /students/:userId/preferences` and `PUT /students/:userId/preferences` endpoints exist in `src/routes/student.js` -- `getStudentPreferences` and `updateStudentPreferences` services are live in `student.service.js` -- Validators `getStudentPreferencesSchema` and `updateStudentPreferencesSchema` are in `student.validators.js` -- The `PREFERENCE_DEFINITIONS` catalog is defined in `src/config/preferences.js` with 7 keys: `smoking`, `food_habit`, `sleep_schedule`, `alcohol`, `cleanliness_level`, `noise_tolerance`, `guest_policy` - -So `[2.5]` is now fully implementable. Let me also plan `[2.6]` for Student 2's preferences, since the compatibility score system needs both students to have preferences set for meaningful testing later. - -Here is the updated plan for the remaining items in **Folder 02 - Student Profiles**: - ---- - -### [2.5] Set Preferences — Student 1 - -Right-click the **Happy Path** subfolder inside **02 - Student Profiles** → **Add request**. - -- **Name:** `[2.5] Set Preferences — Student 1` -- **Method:** PUT -- **URL:** `{{baseUrl}}/students/{{student1Id}}/preferences` -- **Headers:** `Authorization: Bearer {{student1AccessToken}}` -- **Body:** raw → JSON: - -```json -{ - "preferences": [ - { "preferenceKey": "smoking", "preferenceValue": "non_smoker" }, - { "preferenceKey": "food_habit", "preferenceValue": "vegetarian" }, - { "preferenceKey": "sleep_schedule", "preferenceValue": "early_bird" } - ] -} -``` - -- **Scripts tab → Post-response:** - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("Returns array of preferences", () => { - pm.expect(pm.response.json().data).to.be.an("array"); -}); -pm.test("Correct number of preferences saved", () => { - pm.expect(pm.response.json().data.length).to.eql(3); -}); -pm.test("Smoking preference saved", () => { - const prefs = pm.response.json().data; - const smoking = prefs.find(p => p.preferenceKey === "smoking"); - pm.expect(smoking).to.exist; - pm.expect(smoking.preferenceValue).to.eql("non_smoker"); -}); -console.log("Student 1 preferences set — will power compatibility scoring in listing search"); -``` - -Click **Send**. Expected: `200 OK` with an array of 3 preference objects. - ---- - -### [2.6] Set Preferences — Student 2 - -- **Name:** `[2.6] Set Preferences — Student 2` -- **Method:** PUT -- **URL:** `{{baseUrl}}/students/{{student2Id}}/preferences` -- **Headers:** `Authorization: Bearer {{student2AccessToken}}` -- **Body:** - -```json -{ - "preferences": [ - { "preferenceKey": "food_habit", "preferenceValue": "vegetarian" }, - { "preferenceKey": "alcohol", "preferenceValue": "not_okay" }, - { "preferenceKey": "sleep_schedule", "preferenceValue": "early_bird" } - ] -} -``` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("Returns array of preferences", () => { - pm.expect(pm.response.json().data).to.be.an("array"); -}); -pm.test("Correct number of preferences saved", () => { - pm.expect(pm.response.json().data.length).to.eql(3); -}); -console.log("Student 2 preferences set"); -``` - ---- - -### [2.7] Get Preferences — Student 1 (verify read-back) - -This verifies the GET endpoint works and that ownership is enforced. - -- **Name:** `[2.7] Get Preferences — Student 1` -- **Method:** GET -- **URL:** `{{baseUrl}}/students/{{student1Id}}/preferences` -- **Headers:** `Authorization: Bearer {{student1AccessToken}}` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("Returns same preferences we set", () => { - const data = pm.response.json().data; - pm.expect(data).to.be.an("array"); - pm.expect(data.length).to.eql(3); -}); -``` - ---- - -Now for the **Error Cases** subfolder in Folder 02, add these two new cases alongside the ones you already have: - ---- - -### [2.E3] Student 1 Tries to Read Student 2's Preferences - -The `getStudentPreferences` service throws 403 if `requestingUserId !== targetUserId` — preferences are private, not publicly readable like profiles. - -- **Name:** `[2.E3] Read Another Student's Preferences (forbidden)` -- **Method:** GET -- **URL:** `{{baseUrl}}/students/{{student2Id}}/preferences` -- **Headers:** `Authorization: Bearer {{student1AccessToken}}` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 403 — cannot read another student's preferences", () => { - pm.response.to.have.status(403); -}); -``` - ---- - -### [2.E4] Student 1 Tries to Update Student 2's Preferences - -- **Name:** `[2.E4] Update Another Student's Preferences (forbidden)` -- **Method:** PUT -- **URL:** `{{baseUrl}}/students/{{student2Id}}/preferences` -- **Headers:** `Authorization: Bearer {{student1AccessToken}}` -- **Body:** - -```json -{ - "preferences": [ - { "preferenceKey": "smoking", "preferenceValue": "smoker" } - ] -} -``` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 403 — cannot update another student's preferences", () => { - pm.response.to.have.status(403); -}); -``` - ---- - -Now for **Folder 03 - PG Owner Profiles**, here is the updated plan reflecting your actual repo. Looking at `pgOwner.service.js` and `pgOwner.validators.js`, the profile system is symmetric with student profiles. The contact reveal for PG owners uses `POST` (not `GET`) — that is already handled correctly in `pgOwner.js` route. Here is the complete folder 03 plan: - ---- - -## Folder 03 - PG Owner Profiles → Happy Path - ---- - -### [3.1] Get PG Owner 1 Profile (self) - -- **Name:** `[3.1] Get PG Owner 1 Profile (self)` -- **Method:** GET -- **URL:** `{{baseUrl}}/pg-owners/{{pgOwner1Id}}/profile` -- **Headers:** `Authorization: Bearer {{pgOwner1AccessToken}}` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("Business name correct", () => { - pm.expect(pm.response.json().data.business_name).to.eql("Mehta PG House"); -}); -pm.test("Verification status is unverified", () => { - pm.expect(pm.response.json().data.verification_status).to.eql("unverified"); -}); -pm.test("Self view includes email", () => { - pm.expect(pm.response.json().data.email).to.be.a("string"); - pm.expect(pm.response.json().data.email).to.include("@"); -}); -pm.test("Self view includes business phone field", () => { - pm.expect(pm.response.json().data).to.have.property("business_phone"); -}); -``` - ---- - -### [3.2] Update PG Owner 1 Profile - -- **Name:** `[3.2] Update PG Owner 1 Profile` -- **Method:** PUT -- **URL:** `{{baseUrl}}/pg-owners/{{pgOwner1Id}}/profile` -- **Headers:** `Authorization: Bearer {{pgOwner1AccessToken}}` -- **Body:** - -```json -{ - "businessDescription": "Clean, well-maintained PG in Koramangala with 24hr water and WiFi.", - "businessPhone": "9876543210", - "operatingSince": 2019 -} -``` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("Business phone updated", () => { - pm.expect(pm.response.json().data.business_phone).to.eql("9876543210"); -}); -pm.test("Operating since updated", () => { - pm.expect(pm.response.json().data.operating_since).to.eql(2019); -}); -``` - ---- - -### [3.3] Update PG Owner 2 Profile - -- **Name:** `[3.3] Update PG Owner 2 Profile` -- **Method:** PUT -- **URL:** `{{baseUrl}}/pg-owners/{{pgOwner2Id}}/profile` -- **Headers:** `Authorization: Bearer {{pgOwner2AccessToken}}` -- **Body:** - -```json -{ - "businessDescription": "Ladies-only hostel in Indiranagar with strict curfew and home food.", - "businessPhone": "9123456780", - "operatingSince": 2021 -} -``` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("Description updated", () => { - pm.expect(pm.response.json().data.business_description).to.include("Ladies-only"); -}); -``` - ---- - -### [3.4] Update PG Owner 3 Profile - -- **Name:** `[3.4] Update PG Owner 3 Profile` -- **Method:** PUT -- **URL:** `{{baseUrl}}/pg-owners/{{pgOwner3Id}}/profile` -- **Headers:** `Authorization: Bearer {{pgOwner3AccessToken}}` -- **Body:** - -```json -{ - "businessDescription": "Affordable PG near HSR Layout. Single and double rooms available.", - "businessPhone": "9988776655", - "operatingSince": 2020 -} -``` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -``` - ---- - -### [3.5] Get PG Owner 1 Profile (viewed by Student 1 — sensitive fields hidden) - -- **Name:** `[3.5] Get PG Owner 1 Profile (other user)` -- **Method:** GET -- **URL:** `{{baseUrl}}/pg-owners/{{pgOwner1Id}}/profile` -- **Headers:** `Authorization: Bearer {{student1AccessToken}}` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 200", () => pm.response.to.have.status(200)); -pm.test("Business name visible", () => { - pm.expect(pm.response.json().data.business_name).to.eql("Mehta PG House"); -}); -pm.test("Email hidden for non-owner", () => { - pm.expect(pm.response.json().data.email).to.be.null; -}); -pm.test("Business phone hidden for non-owner", () => { - pm.expect(pm.response.json().data.business_phone).to.be.null; -}); -``` - ---- - -## Folder 03 - PG Owner Profiles → Error Cases - ---- - -### [3.E1] Update Another Owner's Profile (forbidden) - -- **Name:** `[3.E1] Update Another Owner's Profile` -- **Method:** PUT -- **URL:** `{{baseUrl}}/pg-owners/{{pgOwner2Id}}/profile` -- **Headers:** `Authorization: Bearer {{pgOwner1AccessToken}}` -- **Body:** - -```json -{ - "businessName": "Hacked Business Name" -} -``` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 403 — cannot edit another owner's profile", () => { - pm.response.to.have.status(403); -}); -``` - ---- - -### [3.E2] Student Tries to Update a PG Owner Profile - -The route has `authorize("pg_owner")` so a student JWT is blocked at the middleware layer before the service even runs. - -- **Name:** `[3.E2] Student Updates PG Owner Profile (wrong role)` -- **Method:** PUT -- **URL:** `{{baseUrl}}/pg-owners/{{pgOwner1Id}}/profile` -- **Headers:** `Authorization: Bearer {{student1AccessToken}}` -- **Body:** - -```json -{ - "businessDescription": "This should be forbidden" -} -``` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 403 — wrong role", () => { - pm.response.to.have.status(403); -}); -``` - ---- - -### [3.E3] Update With No Valid Fields - -The `updatePgOwnerProfile` service throws 400 when `setClauses` is empty — meaning the body contained no keys that match the `columnMap`. - -- **Name:** `[3.E3] Update With No Valid Fields` -- **Method:** PUT -- **URL:** `{{baseUrl}}/pg-owners/{{pgOwner1Id}}/profile` -- **Headers:** `Authorization: Bearer {{pgOwner1AccessToken}}` -- **Body:** - -```json -{} -``` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 400 — no fields to update", () => { - pm.response.to.have.status(400); -}); -``` - ---- - -### [3.E4] Operating Since in the Future - -The `operatingSince` validator uses `.refine((val) => val <= new Date().getFullYear())` so a future year should return 400. - -- **Name:** `[3.E4] Operating Since in the Future` -- **Method:** PUT -- **URL:** `{{baseUrl}}/pg-owners/{{pgOwner1Id}}/profile` -- **Headers:** `Authorization: Bearer {{pgOwner1AccessToken}}` -- **Body:** - -```json -{ - "operatingSince": 2099 -} -``` - -- **Scripts → Post-response:** - -```javascript -pm.test("Status 400 — future year rejected by validator", () => { - pm.response.to.have.status(400); -}); -``` - ---- - -One important note before you proceed: when you run `[2.5]` and `[2.6]`, the preferences you set here for both students will directly power the `compatibilityScore` field that appears on every listing in the search results later in Folder 06. Student 1's `vegetarian + non_smoker + early_bird` preferences will match against listing preferences you define in `[6.1]` through `[6.4]`, giving you non-zero compatibility scores to verify in the search tests. This is why setting them now, before creating any listings, is the right sequence. -Everything worked as expected. diff --git a/docs/API.md b/docs/API.md deleted file mode 100644 index 6125ea6..0000000 --- a/docs/API.md +++ /dev/null @@ -1,1952 +0,0 @@ -# Roomies API Reference - -`docs/API.md` is the API front door. The detailed endpoint-by-endpoint documentation lives in `docs/api/`. - -## Base URLs - -- Local development: `http://localhost:3000/api/v1` -- Production: `https:///api/v1` - -All endpoints return JSON. File upload endpoints accept `multipart/form-data` requests but still return JSON responses. - -## Transport Modes - -Roomies supports two auth transports at the same time. - -### Cookie Mode - -Intended for browser clients. - -- Auth endpoints set `accessToken` and `refreshToken` as `HttpOnly` cookies. -- Browser responses receive a safe JSON body that includes session metadata and user identity, but not raw token - strings. -- The browser should rely on cookies instead of manually storing bearer tokens. - -Typical auth success body in cookie mode: - -```json -{ - "status": "success", - "data": { - "user": { - "userId": "11111111-1111-4111-8111-111111111111", - "email": "priya@iitb.ac.in", - "roles": ["student"], - "isEmailVerified": true - }, - "sid": "22222222-2222-4222-8222-222222222222" - } -} -``` - -### Bearer Mode - -Intended for Android, mobile, and non-browser API clients. - -- Send `X-Client-Transport: bearer` on auth endpoints. -- Send `Authorization: Bearer ` on protected endpoints. -- Auth responses include raw `accessToken` and `refreshToken` strings in the JSON body. - -Typical auth success body in bearer mode: - -```json -{ - "status": "success", - "data": { - "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - "sid": "22222222-2222-4222-8222-222222222222", - "user": { - "userId": "11111111-1111-4111-8111-111111111111", - "email": "priya@iitb.ac.in", - "roles": ["student"], - "isEmailVerified": true - } - } -} -``` - -## Global Auth Rules - -- Protected endpoints accept cookie auth and bearer auth unless a feature doc says otherwise. -- Silent refresh happens only when the expired access token came from the `accessToken` cookie. -- Expired bearer tokens do not silently refresh. They return `401`. -- Users may have multiple roles. The `roles` array in the auth payload is authoritative. - -## Gate-Driven Route Behavior - -Some routes apply dedicated gate middleware before controller/service logic runs. - -- Contact reveal routes apply quota and eligibility gates. -- Admin routes apply an `admin` role gate at router level. -- Some ownership/party checks intentionally return privacy-preserving `404` responses instead of `403`. - -When documenting or integrating a route with a gate, treat the gate response as a first-class endpoint outcome. - -## Common Response Envelopes - -Success with data: - -```json -{ - "status": "success", - "data": {} -} -``` - -Success with message: - -```json -{ - "status": "success", - "message": "Logged out" -} -``` - -Operational error: - -```json -{ - "status": "error", - "message": "Listing not found" -} -``` - -Validation error: - -```json -{ - "status": "error", - "message": "Validation failed", - "errors": [ - { - "field": "body.email", - "message": "Must be a valid email address" - } - ] -} -``` - -More examples live in [api/conventions.md](./api/conventions.md). - -## Roles and Access Model - -- `student` Can maintain a student profile, create `student_room` listings, send interest requests, save listings, - confirm connections, and submit ratings. -- `pg_owner` Can maintain a PG owner profile, submit verification documents, create properties, create `pg_room` and - `hostel_bed` listings, manage incoming interest requests, confirm connections, and receive ratings. -- `admin` Can review PG owner verification requests and moderate rating reports. -- Mixed-role users A single user can hold multiple roles. Route-level access checks look at the JWT role array, and - service-layer ownership checks still apply. - -## Pagination Conventions - -Most feed-style endpoints use keyset pagination. - -Common query params: - -- `limit` -- `cursorTime` -- `cursorId` - -Typical response shape: - -```json -{ - "status": "success", - "data": { - "items": [], - "nextCursor": { - "cursorTime": "2026-04-11T07:15:00.000Z", - "cursorId": "33333333-3333-4333-8333-333333333333" - } - } -} -``` - -If there is no next page, `nextCursor` is `null`. - -## Background Jobs That Affect API Behavior - -The API process also runs cron jobs. These are not called directly by frontend clients, but they can change what API -endpoints return. - -- Listing expiry cron marks aged listings unavailable/expired. -- Expiry-warning cron enqueues warning notifications before listing expiry. -- Hard-delete cleanup cron permanently removes aged soft-deleted rows. - -Frontend clients should expect list/detail endpoints to reflect these lifecycle updates between two reads, even when no -user action occurred. - -## How To Read The Feature Docs - -Each feature doc includes: - -- request contract details -- concrete JSON request bodies -- concrete success bodies -- validation, auth, conflict, not-found, and business-rule error examples -- scenario tables explaining why different responses happen - -## Feature Docs - -- [Shared Conventions](./api/conventions.md) -- [Auth](./api/auth.md) -- [Profiles and Contact Reveal](./api/profiles-and-contact.md) -- [Properties](./api/properties.md) -- [Listings](./api/listings.api.md) -- [Interests](./api/interests.md) -- [Connections](./api/connections.md) -- [Notifications](./api/notifications.md) -- [Ratings and Reports](./api/ratings-and-reports.md) -- [Preferences](./api/preferences.md) -- [Admin](./api/admin.md) -- [Health](./api/health.md) - -**Response:** - -```json -{ - "status": "success", - "data": { - "userId": "uuid", - "sid": "uuid", - "email": "priya@iitb.ac.in", - "roles": ["student"], - "isEmailVerified": true, - "accountStatus": "active" - } -} -``` - ---- - -### POST /auth/google/callback - -Authenticates via Google. The client obtains a Google ID token via Google One Tap or GoogleSignIn SDK and POSTs it here. -The server verifies the token signature — the frontend never handles the OAuth redirect flow. - -**No auth required.** - -**Request body:** - -```json -{ - "idToken": "google-id-token-string", - "role": "student", - "fullName": "Priya Sharma", - "businessName": "Sunrise PG" -} -``` - -`role`, `fullName`, and `businessName` are only required for first-time registration. Returning users omit them. - -**Response:** `200` with token payload. - -**Three internal paths:** returning user (found by Google ID), account linking (found by email, links Google ID), new -registration. - ---- - -## 3. Student Profiles - -### GET /students/:userId/profile - -**Auth required.** - -Returns the student's profile. `email` is only included when the requesting user is the profile owner. - -**Response:** - -```json -{ - "status": "success", - "data": { - "profile_id": "uuid", - "user_id": "uuid", - "full_name": "Priya Sharma", - "date_of_birth": "2001-08-15", - "gender": "female", - "profile_photo_url": "https://...", - "bio": "CS student at IIT Bombay", - "course": "B.Tech Computer Science", - "year_of_study": 2, - "institution_id": "uuid", - "is_aadhaar_verified": false, - "email": "priya@iitb.ac.in", - "is_email_verified": true, - "average_rating": 4.5, - "rating_count": 3, - "created_at": "2025-01-01T00:00:00.000Z" - } -} -``` - -`email` is `null` for non-owner viewers. - -**Errors:** `404` if profile not found. - ---- - -### PUT /students/:userId/profile - -**Auth required.** Only the profile owner can update. - -**Request body** (all fields optional): - -```json -{ - "fullName": "Priya Sharma", - "bio": "CS student", - "course": "B.Tech Computer Science", - "yearOfStudy": 2, - "gender": "female", - "dateOfBirth": "2001-08-15" -} -``` - -| Field | Type | Constraints | -| ------------- | ------ | ---------------------------------------------- | -| `fullName` | string | Min 2, max 255 chars | -| `bio` | string | Max 500 chars | -| `course` | string | Max 255 chars | -| `yearOfStudy` | number | Integer 1–7 | -| `gender` | string | `male`, `female`, `other`, `prefer_not_to_say` | -| `dateOfBirth` | string | `YYYY-MM-DD` format | - -**Response:** Updated profile object. - -**Errors:** `403` if not the profile owner. `400` if no valid fields provided. - ---- - -## 4. PG Owner Profiles - -### GET /pg-owners/:userId/profile - -**Auth required.** - -`business_phone` and `email` are only included for the profile owner. - -**Response:** - -```json -{ - "status": "success", - "data": { - "profile_id": "uuid", - "user_id": "uuid", - "business_name": "Sunrise PG", - "owner_full_name": "Rajesh Kumar", - "business_description": "Clean rooms near campus", - "business_phone": "+919876543210", - "operating_since": 2018, - "verification_status": "verified", - "verified_at": "2025-01-10T00:00:00.000Z", - "email": "rajesh@sunrise.in", - "is_email_verified": true, - "average_rating": 4.2, - "rating_count": 12, - "created_at": "2025-01-01T00:00:00.000Z" - } -} -``` - -`verification_status` values: `"unverified"`, `"pending"`, `"verified"`, `"rejected"`. - -**Errors:** `404` if profile not found. - ---- - -### PUT /pg-owners/:userId/profile - -**Auth required.** Role: `pg_owner`. Only profile owner can update. - -**Request body** (all fields optional): - -```json -{ - "businessName": "Sunrise PG", - "ownerFullName": "Rajesh Kumar", - "businessDescription": "Clean rooms near campus", - "businessPhone": "+919876543210", - "operatingSince": 2018 -} -``` - -| Field | Type | Constraints | -| ---------------- | ------ | --------------------------------- | -| `businessPhone` | string | 7–15 digits, optional leading `+` | -| `operatingSince` | number | Integer 1900–current year | - -**Response:** Updated profile object. - ---- - -### POST /pg-owners/:userId/documents - -Submits a verification document. Sets `verification_status` to `"pending"`. - -**Auth required.** Role: `pg_owner`. Only profile owner can submit. - -**Request body:** - -```json -{ - "documentType": "property_document", - "documentUrl": "https://storage.example.com/doc.pdf" -} -``` - -`documentType` values: `"property_document"`, `"rental_agreement"`, `"owner_id"`, `"trade_license"`. - -**Response:** `201` with the created verification request record. - -**Errors:** `409` if a pending request already exists. - ---- - -## 5. Contact Reveal - -These endpoints are the only way to see a user's contact details (email and WhatsApp). Standard profile endpoints never -expose this information. - -**Two access tiers:** - -| Caller | Gets | Quota | -| ---------------------------------------- | ---------------------- | ---------------------- | -| Verified user (`isEmailVerified = true`) | Email + WhatsApp phone | Unlimited | -| Guest / unverified user | Email only | 10 reveals per 30 days | - -On the 11th reveal attempt by a guest, the API returns `401` with `code: "CONTACT_REVEAL_LIMIT_REACHED"` and a -`loginRedirect` field. Quota is tracked via Redis (IP + User-Agent fingerprint) with an HttpOnly cookie fallback. - -### GET /students/:userId/contact/reveal - -**Auth optional** (`optionalAuthenticate`). - -**Success response:** - -```json -{ - "status": "success", - "data": { - "user_id": "uuid", - "full_name": "Priya Sharma", - "email": "priya@iitb.ac.in", - "whatsapp_phone": "+919876543210" - } -} -``` - -`whatsapp_phone` is omitted for guests and unverified users. - -**Limit reached response** (`401`): - -```json -{ - "status": "error", - "message": "Free contact reveal limit reached. Please log in or sign up to continue.", - "code": "CONTACT_REVEAL_LIMIT_REACHED", - "loginRedirect": "/login/signup" -} -``` - ---- - -### GET /pg-owners/:userId/contact/reveal - -Same behaviour as student contact reveal. `whatsapp_phone` is the `business_phone` from the PG owner's profile. - -**Success response:** - -```json -{ - "status": "success", - "data": { - "user_id": "uuid", - "owner_full_name": "Rajesh Kumar", - "business_name": "Sunrise PG", - "email": "rajesh@sunrise.in", - "whatsapp_phone": "+919876543210" - } -} -``` - ---- - -## 6. Admin — Verification - -All admin endpoints require authentication and the `admin` role. Auth is enforced at the router level — there is no -unprotected route under `/admin`. - -### GET /admin/verification-queue - -Returns pending PG owner verification requests, oldest first (anti-starvation). - -**Auth required.** Role: `admin`. - -**Query params:** Standard pagination (`cursorTime`, `cursorId`, `limit`). See [Section 19](#19-pagination). - -**Response:** - -```json -{ - "status": "success", - "data": { - "items": [ - { - "request_id": "uuid", - "user_id": "uuid", - "document_type": "property_document", - "document_url": "https://...", - "submitted_at": "2025-01-15T10:00:00.000Z", - "business_name": "Sunrise PG", - "owner_full_name": "Rajesh Kumar", - "verification_status": "pending", - "email": "rajesh@sunrise.in" - } - ], - "nextCursor": { "cursorTime": "...", "cursorId": "..." } - } -} -``` - ---- - -### POST /admin/verification-queue/:requestId/approve - -Approves a verification request. Sets `verification_status = "verified"` on the PG owner's profile. - -**Auth required.** Role: `admin`. - -**Request body:** - -```json -{ "adminNotes": "All documents verified" } -``` - -**Response:** - -```json -{ "status": "success", "data": { "requestId": "uuid", "status": "verified" } } -``` - -**Errors:** `409` if request is already resolved. - ---- - -### POST /admin/verification-queue/:requestId/reject - -Rejects a verification request. - -**Auth required.** Role: `admin`. - -**Request body:** - -```json -{ - "rejectionReason": "Document is expired", - "adminNotes": "Driving license expired in 2023" -} -``` - -`rejectionReason` is required. `adminNotes` is optional. - -**Response:** - -```json -{ "status": "success", "data": { "requestId": "uuid", "status": "rejected" } } -``` - -**Errors:** `409` if already resolved. - ---- - -## 7. Properties - -Properties are physical PG or hostel buildings. Only verified PG owners can create/update/delete them. - -### POST /properties - -**Auth required.** Role: `pg_owner`. Account must have `verification_status = "verified"`. - -**Request body:** - -```json -{ - "propertyName": "Sunrise PG", - "description": "Clean rooms near IIT Bombay", - "propertyType": "pg", - "addressLine": "12 Hostel Road, Powai", - "city": "Mumbai", - "locality": "Powai", - "landmark": "Near IIT Main Gate", - "pincode": "400076", - "latitude": 19.1334, - "longitude": 72.9133, - "houseRules": "No smoking. Gates close at 11 PM.", - "totalRooms": 20, - "amenityIds": ["uuid1", "uuid2"] -} -``` - -| Field | Type | Required | Notes | -| ------------------------ | ------ | --------------- | ---------------------------------------- | -| `propertyName` | string | ✅ | Min 2, max 255 | -| `propertyType` | string | ✅ | `"pg"`, `"hostel"`, `"shared_apartment"` | -| `addressLine` | string | ✅ | Min 5, max 500 | -| `city` | string | ✅ | Min 2, max 100 | -| `latitude` / `longitude` | number | both or neither | Enables proximity search | -| `amenityIds` | uuid[] | ✅ | Can be empty array | - -**Response:** `201` with property object including joined amenity details. - ---- - -### GET /properties - -Lists the authenticated PG owner's own properties. - -**Auth required.** Role: `pg_owner`. - -**Query params:** Standard pagination. Optional `city` filter. - -**Response:** - -```json -{ - "status": "success", - "data": { - "items": [ - { - "property_id": "uuid", - "property_name": "Sunrise PG", - "property_type": "pg", - "city": "Mumbai", - "locality": "Powai", - "status": "active", - "average_rating": 4.2, - "rating_count": 12, - "amenity_count": 8, - "active_listing_count": 5, - "created_at": "..." - } - ], - "nextCursor": null - } -} -``` - ---- - -### GET /properties/:propertyId - -**Auth required.** Readable by any authenticated user (students need this for listing detail pages). - -**Response:** Full property object with amenity array. - -**Errors:** `404` if not found. - ---- - -### PUT /properties/:propertyId - -**Auth required.** Role: `pg_owner`. Only the owner can update. - -All fields are optional. When city, address, or coordinates change, those values are automatically cascaded to all -linked `pg_room` and `hostel_bed` listings in the same transaction. - -**Request body:** Same fields as `POST /properties`, all optional. `amenityIds` replaces the full amenity list when -provided. - -**Response:** Updated property with amenities. - -**Errors:** `404` if not found or not owner. `400` if no valid fields provided. - ---- - -### DELETE /properties/:propertyId - -Soft-deletes the property. - -**Auth required.** Role: `pg_owner`. Only owner can delete. - -**Errors:** `409` if any active listings exist under this property (deactivate them first). - -**Response:** - -```json -{ "status": "success", "data": { "propertyId": "uuid", "deleted": true } } -``` - ---- - -## 8. Listings - -Listings are individual rooms. Both students (creating `student_room`) and verified PG owners (creating `pg_room` or -`hostel_bed`) use these endpoints. - -**The paise rule:** Rent and deposit are stored in paise internally. All API responses return values in rupees (divided -by 100). The API also accepts rupees as input — conversion is handled server-side. - -### POST /listings - -**Auth required.** Students create `student_room`; verified PG owners create `pg_room` or `hostel_bed`. - -**Request body:** - -```json -{ - "listingType": "pg_room", - "propertyId": "uuid", - "title": "Furnished single room with AC", - "description": "Quiet room on 3rd floor", - "rentPerMonth": 8500, - "depositAmount": 17000, - "rentIncludesUtilities": false, - "isNegotiable": true, - "roomType": "single", - "bedType": "single_bed", - "totalCapacity": 1, - "preferredGender": "female", - "availableFrom": "2025-02-01", - "availableUntil": "2025-12-31", - "addressLine": "12 Hostel Road", - "city": "Mumbai", - "locality": "Powai", - "latitude": 19.1334, - "longitude": 72.9133, - "amenityIds": ["uuid1", "uuid2"], - "preferences": [{ "preferenceKey": "smoking", "preferenceValue": "non_smoker" }] -} -``` - -| Field | Type | Required | Notes | -| ----------------------- | -------- | ------------------------------- | ----------------------------------------------------------------------------- | -| `listingType` | string | ✅ | `"student_room"`, `"pg_room"`, `"hostel_bed"` | -| `propertyId` | uuid | ✅ if `pg_room` or `hostel_bed` | Must belong to the caller | -| `title` | string | ✅ | Min 5, max 255 | -| `rentPerMonth` | number | ✅ | Integer rupees, min 0 | -| `depositAmount` | number | | Integer rupees, default 0 | -| `roomType` | string | ✅ | `"single"`, `"double"`, `"triple"`, `"entire_flat"` | -| `bedType` | string | | `"single_bed"`, `"double_bed"`, `"bunk_bed"` | -| `totalCapacity` | number | | Integer 1–20, default 1 | -| `availableFrom` | string | ✅ | `YYYY-MM-DD` format | -| `addressLine`, `city` | string | ✅ if `student_room` | Location for student listings | -| `latitude`, `longitude` | number | both or neither | Only for `student_room`; not accepted for pg/hostel (inherited from property) | -| `amenityIds` | uuid[] | | Array of amenity UUIDs | -| `preferences` | object[] | | `{ preferenceKey, preferenceValue }` pairs | - -**Response:** `201` with full listing detail including amenities, preferences, photos, and property info. - -`expires_at` is set server-side to `NOW() + 60 days` — never sent by the client. - ---- - -### GET /listings - -Search listings. All query params are optional. - -**Auth required.** - -**Query params:** - -| Param | Type | Notes | -| --------------------------------- | ------ | ------------------------------------------------------------------ | -| `city` | string | Case-insensitive prefix match | -| `minRent` | number | Rupees | -| `maxRent` | number | Rupees | -| `roomType` | string | `single`, `double`, `triple`, `entire_flat` | -| `bedType` | string | `single_bed`, `double_bed`, `bunk_bed` | -| `preferredGender` | string | `male`, `female`, `other`, `prefer_not_to_say` | -| `listingType` | string | `student_room`, `pg_room`, `hostel_bed` | -| `availableFrom` | string | `YYYY-MM-DD` — listings available on or before this date | -| `lat`, `lng` | number | Both required together for proximity search | -| `radius` | number | Metres, default 5000, max 50000 | -| `amenityIds` | string | Comma-separated UUIDs — listings must have ALL specified amenities | -| `cursorTime`, `cursorId`, `limit` | | Standard pagination | - -**Response:** - -```json -{ - "status": "success", - "data": { - "items": [ - { - "listing_id": "uuid", - "listing_type": "pg_room", - "title": "Furnished single room with AC", - "city": "Mumbai", - "locality": "Powai", - "rentPerMonth": 8500, - "depositAmount": 17000, - "room_type": "single", - "preferred_gender": "female", - "available_from": "2025-02-01", - "status": "active", - "property_name": "Sunrise PG", - "average_rating": 4.2, - "cover_photo_url": "https://...", - "compatibilityScore": 3, - "created_at": "..." - } - ], - "nextCursor": { "cursorTime": "...", "cursorId": "..." } - } -} -``` - -`compatibilityScore` is the count of matching preferences between the listing and the authenticated user's preferences. -Higher = better match. - ---- - -### GET /listings/:listingId - -Returns full listing detail including amenities, preferences, all photos, and parent property info. - -**Auth required.** Increments `views_count` asynchronously (fire-and-forget). - -**Response:** - -```json -{ - "status": "success", - "data": { - "listing_id": "uuid", - "posted_by": "uuid", - "property_id": "uuid", - "listing_type": "pg_room", - "title": "Furnished single room with AC", - "description": "...", - "rentPerMonth": 8500, - "depositAmount": 17000, - "rent_includes_utilities": false, - "is_negotiable": true, - "room_type": "single", - "bed_type": "single_bed", - "total_capacity": 1, - "current_occupants": 0, - "preferred_gender": "female", - "available_from": "2025-02-01", - "available_until": "2025-12-31", - "city": "Mumbai", - "locality": "Powai", - "status": "active", - "views_count": 47, - "expires_at": "2025-03-15T00:00:00.000Z", - "created_at": "...", - "poster_name": "Rajesh Kumar", - "poster_rating": 4.2, - "poster_rating_count": 12, - "amenities": [{ "amenityId": "uuid", "name": "WiFi", "category": "utility", "iconName": "wifi" }], - "preferences": [{ "preferenceKey": "smoking", "preferenceValue": "non_smoker" }], - "photos": [{ "photoId": "uuid", "photoUrl": "https://...", "isCover": true, "displayOrder": 0 }], - "property": { - "propertyId": "uuid", - "propertyName": "Sunrise PG", - "propertyType": "pg", - "addressLine": "12 Hostel Road", - "city": "Mumbai", - "averageRating": 4.1, - "ratingCount": 8 - } - } -} -``` - -**Errors:** `404` if not found. - ---- - -### PUT /listings/:listingId - -**Auth required.** Only the poster can update. - -All body fields are optional. `amenityIds` and `preferences` replace their full sets when provided. For `pg_room` and -`hostel_bed` listings, location fields (`addressLine`, `city`, `locality`, `landmark`, `pincode`, `latitude`, -`longitude`) are rejected — update the parent property instead. - -**Errors:** `404` if not found or not owner. `422` if forbidden location fields are sent for property-linked listing -types. - ---- - -### PATCH /listings/:listingId/status - -Poster-initiated status transitions. - -**Auth required.** Only the poster can change status. - -**Request body:** - -```json -{ "status": "deactivated" } -``` - -Allowed transitions: - -| From | To | -| ------------- | ----------------------- | -| `active` | `filled`, `deactivated` | -| `deactivated` | `active` | - -`filled` and `expired` are terminal — no transition out of them. `expired` is only set by the cron job. Deactivating or -filling a listing automatically expires all pending interest requests on it. - -**Errors:** `422` if transition is not allowed. `409` if status changed concurrently. - ---- - -### DELETE /listings/:listingId - -Soft-deletes the listing and expires all pending interest requests. - -**Auth required.** Only the poster can delete. - -**Response:** - -```json -{ "status": "success", "data": { "listingId": "uuid", "deleted": true } } -``` - ---- - -## 9. Listing Photos - -Photos are uploaded asynchronously. The upload endpoint returns `202 Accepted` immediately. The actual processing (Sharp -compression → WebP → storage write) happens in a background worker. Poll `GET /listings/:listingId/photos` to check when -processing is complete. - -**Never render a `photoUrl` that starts with `processing:`** — this is a placeholder sentinel for photos still being -processed. - -### POST /listings/:listingId/photos - -Upload a photo. Sends the file as `multipart/form-data` with field name `photo`. - -**Auth required.** Only the listing poster can upload. - -**Accepted file types:** JPEG, PNG, WebP. Max size: 10MB. - -**Response:** `202 Accepted` - -```json -{ - "status": "success", - "data": { "photoId": "uuid", "status": "processing" } -} -``` - ---- - -### GET /listings/:listingId/photos - -Returns all processed (non-placeholder) photos for a listing, ordered by `displayOrder`. - -**Auth required.** - -**Response:** - -```json -{ - "status": "success", - "data": [ - { - "photoId": "uuid", - "photoUrl": "https://...", - "isCover": true, - "displayOrder": 0, - "createdAt": "..." - } - ] -} -``` - ---- - -### DELETE /listings/:listingId/photos/:photoId - -Soft-deletes the photo and deletes it from storage. If the deleted photo was the cover, the next photo by `displayOrder` -is automatically elected as cover. - -**Auth required.** Only the listing poster can delete. - -**Response:** - -```json -{ "status": "success", "data": { "photoId": "uuid", "deleted": true } } -``` - ---- - -### PATCH /listings/:listingId/photos/:photoId/cover - -Sets a specific photo as the listing's cover image. Clears the existing cover in the same operation. - -**Auth required.** Only the listing poster. - -**Response:** - -```json -{ "status": "success", "data": { "photoId": "uuid", "isCover": true } } -``` - ---- - -### PUT /listings/:listingId/photos/reorder - -Reorders all photos. The payload must include every active photo for the listing (no partial reorders). - -**Auth required.** Only the listing poster. - -**Request body:** - -```json -{ - "photos": [ - { "photoId": "uuid-a", "displayOrder": 0 }, - { "photoId": "uuid-b", "displayOrder": 1 }, - { "photoId": "uuid-c", "displayOrder": 2 } - ] -} -``` - -All `photoId` values and all `displayOrder` values must be unique within the payload. - -**Response:** Array of all photos in the new order. - -**Errors:** `422` if payload has duplicate IDs or orders, unknown photo IDs, or doesn't include all photos. - ---- - -## 10. Listing Preferences - -Preferences describe what the poster wants in a roommate/tenant. They are matched against the authenticated user's own -preferences to compute `compatibilityScore` in search results. - -### GET /listings/:listingId/preferences - -**Auth required.** - -**Response:** - -```json -{ - "status": "success", - "data": [ - { "preferenceKey": "smoking", "preferenceValue": "non_smoker" }, - { "preferenceKey": "food_habit", "preferenceValue": "vegetarian" } - ] -} -``` - ---- - -### PUT /listings/:listingId/preferences - -Replaces the full preference set for the listing. - -**Auth required.** Only the listing poster. - -**Request body:** - -```json -{ - "preferences": [ - { "preferenceKey": "smoking", "preferenceValue": "non_smoker" }, - { "preferenceKey": "food_habit", "preferenceValue": "vegetarian" } - ] -} -``` - -An empty array clears all preferences. - -**Response:** Updated preferences array. - ---- - -## 11. Saved Listings - -Students can save listings to review later. - -### POST /listings/:listingId/save - -**Auth required.** Role: `student`. - -Idempotent — re-saving a previously unsaved listing restores it. Only active, non-expired listings can be saved. - -**Response:** - -```json -{ "status": "success", "data": { "listingId": "uuid", "saved": true } } -``` - -**Errors:** `404` if listing not found. `422` if listing is expired or inactive. - ---- - -### DELETE /listings/:listingId/save - -**Auth required.** Role: `student`. - -Idempotent — unsaving a listing that was already unsaved is a no-op. - -**Response:** - -```json -{ "status": "success", "data": { "listingId": "uuid", "saved": false } } -``` - ---- - -### GET /listings/me/saved - -Returns the authenticated student's saved listings. Silently omits soft-deleted and expired listings. - -**Auth required.** Role: `student`. - -**Query params:** Standard pagination (on `saved_at DESC`). - -**Response:** - -```json -{ - "status": "success", - "data": { - "items": [ - { - "listing_id": "uuid", - "listing_type": "pg_room", - "title": "Furnished single room", - "city": "Mumbai", - "rentPerMonth": 8500, - "depositAmount": 17000, - "room_type": "single", - "saved_at": "2025-01-15T10:00:00.000Z", - "cover_photo_url": "https://...", - "average_rating": 4.2 - } - ], - "nextCursor": null - } -} -``` - ---- - -## 12. Interest Requests - -### POST /listings/:listingId/interests - -A student expresses interest in a listing. Only one pending or accepted interest request per student per listing is -allowed. - -**Auth required.** Role: `student`. A student cannot express interest in their own listing. - -**Request body** (optional): - -```json -{ "message": "Hi, I'm looking for a room from February." } -``` - -**Response:** `201` - -```json -{ - "status": "success", - "data": { - "interestRequestId": "uuid", - "studentId": "uuid", - "listingId": "uuid", - "message": "Hi, I'm looking for a room from February.", - "status": "pending", - "createdAt": "..." - } -} -``` - -**Errors:** `404` if listing not found. `409` if already has a pending/accepted request. `422` if listing is expired or -not active. - ---- - -### GET /listings/:listingId/interests - -Returns all interest requests for a listing. Only the listing poster can view this. - -**Auth required.** Only the poster can call this. - -**Query params:** `status` filter (any `request_status_enum` value). Standard pagination. - -**Response:** - -```json -{ - "status": "success", - "data": { - "items": [ - { - "interestRequestId": "uuid", - "studentId": "uuid", - "status": "pending", - "message": "Hi, I'm looking for a room.", - "createdAt": "...", - "updatedAt": "...", - "student": { - "userId": "uuid", - "fullName": "Priya Sharma", - "profilePhotoUrl": "https://...", - "averageRating": 4.5 - } - } - ], - "nextCursor": null - } -} -``` - -**Errors:** `403` if not the listing owner. - ---- - -### GET /interests/me - -Returns the authenticated student's own interest requests across all listings. - -**Auth required.** Role: `student`. - -**Query params:** `status` filter. Standard pagination. - -**Response:** - -```json -{ - "status": "success", - "data": { - "items": [ - { - "interestRequestId": "uuid", - "listingId": "uuid", - "status": "accepted", - "message": "...", - "createdAt": "...", - "listing": { - "listingId": "uuid", - "title": "Furnished single room", - "city": "Mumbai", - "listingType": "pg_room", - "rentPerMonth": 8500 - } - } - ], - "nextCursor": null - } -} -``` - ---- - -### GET /interests/:interestId - -Full detail for one interest request. Both the sender and the listing poster can view it. - -**Auth required.** - -**Response:** - -```json -{ - "status": "success", - "data": { - "interestRequestId": "uuid", - "studentId": "uuid", - "listingId": "uuid", - "message": "...", - "status": "pending", - "createdAt": "...", - "updatedAt": "...", - "listing": { - "listingId": "uuid", - "title": "...", - "city": "Mumbai", - "listingType": "pg_room" - }, - "student": { - "userId": "uuid", - "fullName": "Priya Sharma", - "profilePhotoUrl": "https://..." - } - } -} -``` - -**Errors:** `404` if not found or caller is not a party. - ---- - -### PATCH /interests/:interestId/status - -Triggers a status transition on an interest request. - -**Auth required.** Both parties use this endpoint — the service determines the allowed action based on the caller's -role. - -**Request body:** - -```json -{ "status": "accepted" } -``` - -**Allowed values:** `"accepted"` (poster only), `"declined"` (poster only), `"withdrawn"` (sender only). - -**Response for `accepted`:** - -```json -{ - "status": "success", - "data": { - "interestRequestId": "uuid", - "studentId": "uuid", - "listingId": "uuid", - "status": "accepted", - "connectionId": "uuid", - "whatsappLink": "https://wa.me/919876543210?text=...", - "listingFilled": false - } -} -``` - -`whatsappLink` is `null` if the poster has no phone number on file. `listingFilled: true` when this acceptance exhausted -the listing's capacity (useful for showing "Room is now full" messaging). - -**Response for `declined` or `withdrawn`:** - -```json -{ - "status": "success", - "data": { - "interestRequestId": "uuid", - "studentId": "uuid", - "listingId": "uuid", - "status": "declined" - } -} -``` - -**Errors:** `404` if not found or not a party. `409` if request is not in `pending` status. `422` if transition is -invalid. - ---- - -## 13. Connections - -A connection is created automatically when a poster accepts an interest request. There is no `POST /connections` -endpoint. - -### POST /connections/:connectionId/confirm - -Either party calls this to record that the real-world interaction (e.g. the student actually moved in) happened from -their side. Once both parties confirm, `confirmation_status` becomes `"confirmed"` and ratings become submittable. - -**Auth required.** Both parties can call this. - -**Response:** - -```json -{ - "status": "success", - "data": { - "connectionId": "uuid", - "initiatorConfirmed": true, - "counterpartConfirmed": false, - "confirmationStatus": "pending", - "updatedAt": "..." - } -} -``` - -When both flags become `true`, `confirmationStatus` becomes `"confirmed"` in the same response. - -**Errors:** `404` if not found or caller is not a party. - ---- - -### GET /connections/me - -Returns all connections for the authenticated user, newest first. - -**Auth required.** - -**Query params:** - -| Param | Type | Notes | -| -------------------- | ------ | ---------------------------------------------------------- | -| `confirmationStatus` | string | `pending`, `confirmed`, `denied`, `expired` | -| `connectionType` | string | `student_roommate`, `pg_stay`, `hostel_stay`, `visit_only` | -| Standard pagination | | | - -**Response:** - -```json -{ - "status": "success", - "data": { - "items": [ - { - "connectionId": "uuid", - "connectionType": "pg_stay", - "confirmationStatus": "confirmed", - "initiatorConfirmed": true, - "counterpartConfirmed": true, - "startDate": null, - "endDate": null, - "createdAt": "...", - "listing": { - "listingId": "uuid", - "title": "Furnished single room", - "city": "Mumbai", - "rentPerMonth": 8500, - "listingType": "pg_room" - }, - "otherParty": { - "userId": "uuid", - "fullName": "Rajesh Kumar", - "profilePhotoUrl": "https://...", - "averageRating": 4.2 - } - } - ], - "nextCursor": null - } -} -``` - ---- - -### GET /connections/:connectionId - -Full detail for one connection. Both parties can see each other's confirmation status. - -**Auth required.** Only parties to the connection can view it. - -**Response:** - -```json -{ - "status": "success", - "data": { - "connectionId": "uuid", - "connectionType": "pg_stay", - "confirmationStatus": "confirmed", - "initiatorConfirmed": true, - "counterpartConfirmed": true, - "interestRequestId": "uuid", - "createdAt": "...", - "listing": { ... }, - "otherParty": { - "userId": "uuid", - "fullName": "Rajesh Kumar", - "profilePhotoUrl": "https://...", - "averageRating": 4.20, - "ratingCount": 5 - } - } -} -``` - -**Errors:** `404` if not found or caller is not a party. - ---- - -## 14. Notifications - -### GET /notifications - -Returns the notification feed for the authenticated user, newest first. - -**Auth required.** - -**Query params:** - -| Param | Type | Notes | -| ------------------- | ------- | ---------------------------------------- | -| `isRead` | boolean | `true` or `false` — filter by read state | -| Standard pagination | | | - -**Response:** - -```json -{ - "status": "success", - "data": { - "items": [ - { - "notificationId": "uuid", - "actorId": "uuid", - "type": "interest_request_received", - "entityType": "interest_request", - "entityId": "uuid", - "message": "Someone expressed interest in your listing", - "isRead": false, - "createdAt": "..." - } - ], - "nextCursor": null - } -} -``` - -**Notification types:** - -| Type | Trigger | -| ---------------------------- | ------------------------------------------------------------ | -| `interest_request_received` | A student sends an interest request | -| `interest_request_accepted` | Poster accepts the request | -| `interest_request_declined` | Poster declines the request | -| `interest_request_withdrawn` | Student withdraws the request | -| `connection_confirmed` | Both parties have confirmed | -| `rating_received` | A rating was submitted for you | -| `listing_expiring` | Your listing will expire within 7 days (or has just expired) | -| `listing_filled` | Your listing's last slot was just filled | -| `verification_approved` | Admin approved your verification (planned) | -| `verification_rejected` | Admin rejected your verification (planned) | -| `new_message` | Reserved for future messaging phase | - -Use `entityType` and `entityId` to navigate the user to the relevant page. - ---- - -### GET /notifications/unread-count - -Returns the unread count for the bell badge. Fast — hits a partial index. - -**Auth required.** - -**Response:** - -```json -{ "status": "success", "data": { "count": 5 } } -``` - ---- - -### POST /notifications/mark-read - -Marks notifications as read. Idempotent — marking already-read notifications is a no-op. - -**Auth required.** - -**Request body — mark all:** - -```json -{ "all": true } -``` - -**Request body — mark specific:** - -```json -{ "notificationIds": ["uuid1", "uuid2"] } -``` - -`all` and `notificationIds` are mutually exclusive. `all` can only be `true` (sending `false` is a validation error). - -**Response:** - -```json -{ "status": "success", "data": { "updated": 5 } } -``` - ---- - -## 15. Ratings - -Ratings can only be submitted after a connection's `confirmationStatus = "confirmed"`. A reviewer can rate their -counterparty (as a `user`) and/or the property where the stay happened (as a `property`). - -### POST /ratings - -**Auth required.** - -**Request body:** - -```json -{ - "connectionId": "uuid", - "revieweeType": "user", - "revieweeId": "uuid", - "overallScore": 4, - "cleanlinessScore": 5, - "communicationScore": 4, - "reliabilityScore": 4, - "valueScore": 3, - "comment": "Great host, very responsive." -} -``` - -| Field | Type | Required | Notes | -| -------------------- | ------ | -------- | ------------------------------------------------------ | -| `connectionId` | uuid | ✅ | Must be a confirmed connection where caller is a party | -| `revieweeType` | string | ✅ | `"user"` or `"property"` | -| `revieweeId` | uuid | ✅ | The user or property being reviewed | -| `overallScore` | number | ✅ | Integer 1–5 | -| `cleanlinessScore` | number | | Integer 1–5 | -| `communicationScore` | number | | Integer 1–5 | -| `reliabilityScore` | number | | Integer 1–5 | -| `valueScore` | number | | Integer 1–5 | -| `comment` | string | | Max 2000 chars | - -**Response:** `201` - -```json -{ "status": "success", "data": { "ratingId": "uuid", "createdAt": "..." } } -``` - -**Errors:** `404` if connection not found or caller not a party. `409` if already rated this reviewee on this -connection. `422` if connection not confirmed, or reviewee is not a valid party to the connection. - ---- - -### GET /ratings/user/:userId - -Public rating history for any user. **No auth required.** - -**Query params:** Standard pagination. - -**Response:** - -```json -{ - "status": "success", - "data": { - "items": [ - { - "ratingId": "uuid", - "overallScore": 4, - "cleanlinessScore": 5, - "communicationScore": 4, - "reliabilityScore": 4, - "valueScore": 3, - "comment": "Great host.", - "createdAt": "...", - "reviewer": { - "fullName": "Priya Sharma", - "profilePhotoUrl": "https://..." - } - } - ], - "nextCursor": null - } -} -``` - ---- - -### GET /ratings/property/:propertyId - -Public rating history for a property. **No auth required.** - -Same response shape as `GET /ratings/user/:userId`. - -**Errors:** `404` if property not found. - ---- - -### GET /ratings/connection/:connectionId - -Both ratings for a connection from the caller's perspective. Only the two parties can access this. - -**Auth required.** - -**Response:** - -```json -{ - "status": "success", - "data": { - "myRatings": [ { ...ratingObject } ], - "theirRatings": [ { ...ratingObject } ] - } -} -``` - -**Errors:** `404` if connection not found or caller not a party. - ---- - -### GET /ratings/me/given - -The authenticated user's full history of ratings they have submitted. - -**Auth required.** - -**Response:** Paginated list. Each item includes `isVisible` (whether the rating is visible to the public) and -`reviewee` details. - ---- - -## 16. Reports - -Any party to a connection can report a rating on that connection. - -### POST /ratings/:ratingId/report - -**Auth required.** - -**Request body:** - -```json -{ - "reason": "fake", - "explanation": "I never stayed at this PG." -} -``` - -`reason` values: `"fake"`, `"abusive"`, `"conflict_of_interest"`, `"other"`. - -**Response:** `201` - -```json -{ - "status": "success", - "data": { - "reportId": "uuid", - "reporterId": "uuid", - "ratingId": "uuid", - "reason": "fake", - "status": "open", - "createdAt": "..." - } -} -``` - -**Errors:** `404` if rating not found or caller is not a party to the connection. `409` if an open report from this -reporter against this rating already exists. - ---- - -## 17. Admin — Moderation - -### GET /admin/report-queue - -Returns open reports oldest-first. Each item includes the full rating content, the reporter's public profile, and the -reviewee's profile so the admin can decide without an extra request. - -**Auth required.** Role: `admin`. - -**Query params:** Standard pagination. - -**Response:** - -```json -{ - "status": "success", - "data": { - "items": [ - { - "reportId": "uuid", - "reporterId": "uuid", - "ratingId": "uuid", - "reason": "abusive", - "explanation": "The review contains threats.", - "status": "open", - "submittedAt": "...", - "rating": { - "overallScore": 1, - "comment": "...", - "revieweeType": "user", - "revieweeId": "uuid", - "isVisible": true, - "createdAt": "...", - "reviewer": { "fullName": "...", "profilePhotoUrl": "..." }, - "reviewee": { "fullName": "...", "profilePhotoUrl": "..." } - }, - "reporter": { "fullName": "...", "profilePhotoUrl": "..." } - } - ], - "nextCursor": null - } -} -``` - ---- - -### PATCH /admin/reports/:reportId/resolve - -Closes a report. Two outcomes only. - -**Auth required.** Role: `admin`. - -**Request body:** - -```json -{ - "resolution": "resolved_removed", - "adminNotes": "Review contains abusive language. Removing." -} -``` - -| `resolution` | Effect | -| -------------------- | ----------------------------------------------------------------------------------------- | -| `"resolved_removed"` | Sets `ratings.is_visible = false`; DB trigger recalculates `average_rating` automatically | -| `"resolved_kept"` | Closes the report; rating stays visible | - -`adminNotes` is required when `resolution = "resolved_removed"`. - -**Response:** - -```json -{ - "status": "success", - "data": { - "reportId": "uuid", - "resolution": "resolved_removed", - "ratingId": "uuid" - } -} -``` - -**Errors:** `409` if report is already resolved or not found. - ---- - -## 18. Health Check - -### GET /health - -**No auth required.** Used by load balancers and monitoring tools. - -**Success response** (`200`): - -```json -{ - "status": "ok", - "timestamp": "2025-01-15T10:00:00.000Z", - "services": { - "database": "ok", - "redis": "ok" - } -} -``` - -**Degraded response** (`503`): - -```json -{ - "status": "degraded", - "timestamp": "...", - "services": { - "database": "unhealthy", - "redis": "timeout" - } -} -``` - -Service statuses: `"ok"`, `"unhealthy"`, `"timeout"`. Each probe has a 3-second timeout. Full internal error details are -logged server-side and never exposed in this response. - ---- - -## 19. Pagination - -All paginated endpoints use **keyset (cursor-based) pagination**. There is no `page=2` style offset — use the cursor -from the previous response to fetch the next page. - -**Standard query params:** - -| Param | Type | Default | Notes | -| ------------ | ----------------- | ------- | -------------------------- | -| `cursorTime` | ISO 8601 datetime | — | Required with `cursorId` | -| `cursorId` | UUID | — | Required with `cursorTime` | -| `limit` | integer | 20 | Min 1, max 100 | - -Both `cursorTime` and `cursorId` must be provided together or both omitted. Providing one without the other returns a -`400` validation error. - -**Reading the next page:** - -When `nextCursor` in the response is not `null`, pass `cursorTime` and `cursorId` from it as query params on the next -request. - -```json -{ - "items": [ ... ], - "nextCursor": { - "cursorTime": "2025-01-10T08:00:00.000Z", - "cursorId": "uuid-of-last-item" - } -} -``` - -When `nextCursor` is `null`, you have reached the last page. - -**Why keyset?** Offset pagination (`LIMIT x OFFSET y`) produces inconsistent results when new rows are inserted between -page requests. Keyset pagination on `(created_at DESC, entity_id ASC)` gives stable, gap-free results even under -concurrent writes. - ---- - -## 20. Error Reference - -### Standard error shape - -```json -{ - "status": "error", - "message": "Human-readable description" -} -``` - -### Validation errors (400) - -```json -{ - "status": "error", - "message": "Validation failed", - "errors": [ - { "field": "body.email", "message": "Must be a valid email address" }, - { "field": "body.password", "message": "Password must be at least 8 characters" } - ] -} -``` - -### HTTP status codes used - -| Code | Meaning | -| ----- | --------------------------------------------------------------------------------------------- | -| `200` | Success | -| `201` | Created | -| `202` | Accepted (async processing started — e.g. photo upload) | -| `400` | Bad request — validation failed or malformed body | -| `401` | Unauthenticated — missing, invalid, or expired token | -| `403` | Forbidden — authenticated but not authorised for this action | -| `404` | Not found — resource doesn't exist or caller is not a party (never 403 for party checks) | -| `409` | Conflict — duplicate resource, constraint violation, or concurrent state change | -| `413` | Payload too large — file exceeds 10MB | -| `422` | Unprocessable — request is well-formed but semantically invalid (e.g. transition not allowed) | -| `429` | Too many requests — rate limit or OTP attempt limit exceeded | -| `500` | Internal server error — unexpected failure | -| `503` | Service unavailable — dependency unhealthy | - -### Common error messages - -| Scenario | Status | -| ----------------------------------- | ------------------------------------------------- | -| No token provided | `401` | -| Token expired (Bearer header) | `401` | -| Account suspended/banned | `401` | -| Email already registered | `409` | -| Pending verification request exists | `409` | -| Interest request already pending | `409` | -| Already rated this connection | `409` | -| Open report already exists | `409` | -| Listing not active/expired | `422` | -| Connection not yet confirmed | `422` | -| Status transition not allowed | `422` | -| OTP wrong (with remaining count) | `400` | -| OTP attempts exhausted | `429` | -| File type not accepted | `400` | -| File too large | `413` | -| Contact reveal limit reached | `401` with `code: "CONTACT_REVEAL_LIMIT_REACHED"` | diff --git a/docs/Deployment.md b/docs/Deployment.md index 0fecfcb..8d01195 100644 --- a/docs/Deployment.md +++ b/docs/Deployment.md @@ -1,15 +1,16 @@ # Roomies — Deployment Guide (April 2026 Edition) -> **Status:** Complete replacement of the previous `docs/Deployment.md`. -> **Philosophy:** Free providers first, Azure student credits as the last resort. -> **Written for:** ≤50 users/day, API-only backend, idle-on-no-traffic is acceptable. -> **Last researched:** April 2026. +> **Status:** Complete replacement of the previous `docs/Deployment.md`. **Philosophy:** Free providers first, Azure +> student credits as the last resort. **Written for:** ≤50 users/day, API-only backend, idle-on-no-traffic is +> acceptable. **Last researched:** April 2026. --- ## The Core Strategy -Azure student credits are a finite resource (₹9,480 total). The goal is to stretch them as far as possible by exhausting truly free external providers first. Azure is not avoided — it is reserved for cases where no free alternative exists or when free limits are hit. +Azure student credits are a finite resource (₹9,480 total). The goal is to stretch them as far as possible by exhausting +truly free external providers first. Azure is not avoided — it is reserved for cases where no free alternative exists or +when free limits are hit. ``` Tier 0 — Completely Free (start here) @@ -31,12 +32,15 @@ Tier 2 — Azure student credits (last resort, or when external paid cost └── (Storage and email stay on free providers indefinitely) ``` -**Why Render free instead of Azure App Service F1?** -Azure's free F1 tier has no Always On, killing BullMQ workers on idle. Render's free tier is also a sleeping process — but that is explicitly acceptable here since idle means no users, which means no jobs are needed either. Render's 750 free hours per month equals a full calendar month of runtime. +**Why Render free instead of Azure App Service F1?** Azure's free F1 tier has no Always On, killing BullMQ workers on +idle. Render's free tier is also a sleeping process — but that is explicitly acceptable here since idle means no users, +which means no jobs are needed either. Render's 750 free hours per month equals a full calendar month of runtime. **Why idle is actually fine for this app:** + - BullMQ workers sleep with the process → zero Redis polling → Upstash free tier lasts much longer. -- node-cron (listing expiry, etc.) is idempotent — a missed midnight run at 2 AM is caught the next time the server wakes. +- node-cron (listing expiry, etc.) is idempotent — a missed midnight run at 2 AM is caught the next time the server + wakes. - Cold starts are ~1–2 seconds on Render free tier. Acceptable for ≤50 users/day. --- @@ -45,11 +49,13 @@ Azure's free F1 tier has no Always On, killing BullMQ workers on idle. Render's ### Decision 1: No Docker Required -Render detects Node.js automatically. Connect your GitHub repo, set a start command, and you're done. No Dockerfile needed. +Render detects Node.js automatically. Connect your GitHub repo, set a start command, and you're done. No Dockerfile +needed. ### Decision 2: Upstash Free Tier Works If Idle Is Allowed -The previous Deployment.md concluded BullMQ exhausts the 500K free commands in ~10 days. That calculation assumed 24/7 uptime. With Render's free tier sleeping after 15 minutes of inactivity: +The previous Deployment.md concluded BullMQ exhausts the 500K free commands in ~10 days. That calculation assumed 24/7 +uptime. With Render's free tier sleeping after 15 minutes of inactivity: ``` Estimate for a low-traffic app (server awake ~3 hours/day): @@ -61,24 +67,28 @@ Estimate for a low-traffic app (server awake ~3 hours/day): ### Decision 3: Single Process (No Worker Separation) -Express + BullMQ workers + node-cron all run in one Render web service. This is the same single-process architecture described in the previous guide — it is correct for this scale. +Express + BullMQ workers + node-cron all run in one Render web service. This is the same single-process architecture +described in the previous guide — it is correct for this scale. ### Decision 4: Azure Blob Storage Stays -The existing `AzureBlobAdapter` is already written and tested. Azure Blob's 5 GB always-free tier does not consume student credits. Keep it. Writing a new Cloudflare R2 adapter would add code complexity for no real benefit at this stage. +The existing `AzureBlobAdapter` is already written and tested. Azure Blob's 5 GB always-free tier does not consume +student credits. Keep it. Writing a new Cloudflare R2 adapter would add code complexity for no real benefit at this +stage. ### Decision 5: No Key Vault on Render -Render has built-in environment variable management with secret values. No Key Vault needed for Tier 0 or Tier 1. Key Vault only comes into play if you migrate to Azure App Service (Tier 2). +Render has built-in environment variable management with secret values. No Key Vault needed for Tier 0 or Tier 1. Key +Vault only comes into play if you migrate to Azure App Service (Tier 2). ### Decision 6: Revised Realistic Budget -| Tier | Monthly Cost | Credits Spent | Est. Runway | -|---|---|---|---| -| Tier 0 (all free) | ₹0 | ₹0 | Indefinite | -| Tier 1 (Upstash Fixed added) | ~₹840 | ₹0 | Indefinite | -| Tier 2 partial (Azure DB added) | ~₹1,923 | ~₹1,923/month | ~5 months | -| Tier 2 full (all Azure) | ~₹3,302 | ~₹3,302/month | ~2.9 months | +| Tier | Monthly Cost | Credits Spent | Est. Runway | +| ------------------------------- | ------------ | ------------- | ----------- | +| Tier 0 (all free) | ₹0 | ₹0 | Indefinite | +| Tier 1 (Upstash Fixed added) | ~₹840 | ₹0 | Indefinite | +| Tier 2 partial (Azure DB added) | ~₹1,923 | ~₹1,923/month | ~5 months | +| Tier 2 full (all Azure) | ~₹3,302 | ~₹3,302/month | ~2.9 months | --- @@ -145,9 +155,9 @@ node -e "console.log(require('crypto').randomBytes(48).toString('base64'))" 1. Go to [console.neon.tech](https://console.neon.tech) 2. Click **Create Project** 3. Fill in: - - Name: `roomies` - - PostgreSQL version: **16** - - Region: **AWS Asia Pacific (Singapore)** — closest to India + - Name: `roomies` + - PostgreSQL version: **16** + - Region: **AWS Asia Pacific (Singapore)** — closest to India 4. Click **Create Project** Neon creates a default `neondb` database. Note the connection string from the dashboard — it looks like: @@ -192,7 +202,9 @@ After configuring `.env.neon` (see Phase 5): npm run seed:amenities ``` -**Neon cold-start note:** The first query after Neon compute scales to zero takes ~300–500ms. Your BullMQ workers connecting every few seconds during active use will keep Neon warm during those sessions. When idle, both Neon and Render sleep — and wake together on the next request. +**Neon cold-start note:** The first query after Neon compute scales to zero takes ~300–500ms. Your BullMQ workers +connecting every few seconds during active use will keep Neon warm during those sessions. When idle, both Neon and +Render sleep — and wake together on the next request. --- @@ -203,11 +215,11 @@ npm run seed:amenities 1. Go to [console.upstash.com](https://console.upstash.com) 2. Click **Create database** 3. Fill in: - - Name: `roomies-redis` - - Type: **Regional** - - Region: **AWS ap-southeast-1 (Singapore)** - - Plan: **Free** ← start here; upgrade to Fixed $10/month if you exceed 500K commands - - TLS: **Enabled** (leave on) + - Name: `roomies-redis` + - Type: **Regional** + - Region: **AWS ap-southeast-1 (Singapore)** + - Plan: **Free** ← start here; upgrade to Fixed $10/month if you exceed 500K commands + - TLS: **Enabled** (leave on) 4. Click **Create** #### 2.2 Get the Connection String @@ -220,16 +232,19 @@ From the database overview, copy: Your `REDIS_URL`: ``` -rediss://default:YOUR_PASSWORD@SOMETHING.upstash.io:6380 +rediss://default:YOUR_PASSWORD@SOMETHING.upstash.io:6379 ``` -Format: `rediss://default:PASSWORD@ENDPOINT:6380` +Format: `rediss://default:PASSWORD@ENDPOINT:6379` -The existing `bullConnection.js` and `cache/client.js` both handle `rediss://` TLS URLs correctly. **No code changes needed.** +The existing `bullConnection.js` and `cache/client.js` both handle `rediss://` TLS URLs correctly. **No code changes +needed.** #### 2.3 Monitor Command Usage -Upstash dashboard shows real-time command usage. Check it weekly for the first month. If you approach 400K commands, switch to the **Fixed 250MB plan ($10/month)** before hitting the cap — a hard rate-limit at 500K will cause BullMQ to stop processing jobs. +Upstash dashboard shows real-time command usage. Check it weekly for the first month. If you approach 400K commands, +switch to the **Fixed 250MB plan ($10/month)** before hitting the cap — a hard rate-limit at 500K will cause BullMQ to +stop processing jobs. Migration trigger: Upstash dashboard shows > 400K commands used in a month. @@ -237,21 +252,22 @@ Migration trigger: Upstash dashboard shows > 400K commands used in a month. ### Phase 3: Azure Blob Storage (Always-Free, Existing Adapter) -This is unchanged from the previous Deployment.md. You need to do this once even for Tier 0 because it is the only storage option with an existing adapter in the codebase. +This is unchanged from the previous Deployment.md. You need to do this once even for Tier 0 because it is the only +storage option with an existing adapter in the codebase. #### 3.1 Create Storage Account (if not already done) 1. Go to [portal.azure.com](https://portal.azure.com) 2. Search **Storage accounts** → **+ Create** 3. **Basics:** - - Resource group: `roomies-rg` (create if it doesn't exist) - - Storage account name: `roomiesblob` (must be globally unique, lowercase) - - Region: **Central India** - - Performance: **Standard** - - Redundancy: **Locally redundant storage (LRS)** + - Resource group: `roomies-rg` (create if it doesn't exist) + - Storage account name: `roomiesblob` (must be globally unique, lowercase) + - Region: **Central India** + - Performance: **Standard** + - Redundancy: **Locally redundant storage (LRS)** 4. **Advanced tab:** - - Allow blob anonymous access: **Enabled** - - Minimum TLS: **TLS 1.2** + - Allow blob anonymous access: **Enabled** + - Minimum TLS: **TLS 1.2** 5. **Review + create** → **Create** #### 3.2 Create the Blob Container @@ -297,9 +313,10 @@ ENV_FILE=.env.render # Neon PostgreSQL DATABASE_URL=postgresql://neondb_owner:PASSWORD@ep-ENDPOINT.ap-southeast-1.aws.neon.tech/neondb?sslmode=require +DB_POOL_MAX=5 # Upstash Redis -REDIS_URL=rediss://default:YOUR_UPSTASH_PASSWORD@YOUR-ENDPOINT.upstash.io:6380 +REDIS_URL=rediss://default:REDACTED@upward-mule-75729.upstash.io:6379 # JWT JWT_SECRET=YOUR_GENERATED_JWT_SECRET @@ -312,22 +329,25 @@ STORAGE_ADAPTER=azure AZURE_STORAGE_CONNECTION_STRING=DefaultEndpointsProtocol=https;AccountName=roomiesblob;AccountKey=XXXX;EndpointSuffix=core.windows.net AZURE_STORAGE_CONTAINER=roomies-uploads -# Email -EMAIL_PROVIDER=brevo -BREVO_SMTP_LOGIN=your-login@smtp-brevo.com -BREVO_SMTP_KEY=xsmtpsib-xxxxxx -BREVO_SMTP_FROM=noreply@yourdomain.com +# Email via Brevo REST API +EMAIL_PROVIDER=brevo-api +BREVO_API_KEY=REDACTED +BREVO_SMTP_FROM=sumity1642@gmail.com -# CORS — allow everything for local testing -ALLOWED_ORIGINS=http://localhost:5173 +# CORS +ALLOWED_ORIGINS=https://roomies-lilac.vercel.app -# Google OAuth (optional, skip if not ready) -GOOGLE_CLIENT_ID=YOUR_CLIENT_ID -GOOGLE_CLIENT_SECRET=YOUR_CLIENT_SECRET +# Google OAuth +GOOGLE_CLIENT_ID=535680244018-qbhv8r4iufvlh4qrlcro2g1n4et5uvh2.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=REDACTED -TRUST_PROXY=false +TRUST_PROXY=1 ``` +If any production credential is pasted into chat, logs, tickets, screenshots, or docs, rotate it before relying on +production security. Do not commit full database URLs, storage keys, API keys, OAuth client secrets, JWT secrets, or +Redis passwords. + Test locally: ```bash @@ -349,13 +369,13 @@ curl http://localhost:3000/api/v1/health 2. Click **New** → **Web Service** 3. Connect your GitHub account and select your repo 4. Fill in: - - **Name:** `roomies-api` - - **Region:** `Singapore` (closest to India) - - **Branch:** `main` - - **Runtime:** `Node` - - **Build Command:** `npm install` - - **Start Command:** `node src/server.js` - - **Plan:** `Free` + - **Name:** `roomies-api` + - **Region:** `Singapore` (closest to India) + - **Branch:** `main` + - **Runtime:** `Node` + - **Build Command:** `npm install` + - **Start Command:** `node src/server.js` + - **Plan:** `Free` 5. Click **Create Web Service** @@ -365,27 +385,30 @@ Render assigns a URL like `https://roomies-api.onrender.com`. In the Render dashboard → your web service → **Environment** tab → **Add Environment Variable** for each: -| Key | Value | -|---|---| -| `NODE_ENV` | `production` | -| `PORT` | `10000` (Render injects its own PORT; set this as fallback) | -| `DATABASE_URL` | your Neon connection string | -| `REDIS_URL` | `rediss://default:PASSWORD@ENDPOINT.upstash.io:6380` | -| `JWT_SECRET` | your generated secret | -| `JWT_REFRESH_SECRET` | your second generated secret | -| `JWT_EXPIRES_IN` | `15m` | -| `JWT_REFRESH_EXPIRES_IN` | `7d` | -| `STORAGE_ADAPTER` | `azure` | -| `AZURE_STORAGE_CONNECTION_STRING` | your full connection string | -| `AZURE_STORAGE_CONTAINER` | `roomies-uploads` | -| `EMAIL_PROVIDER` | `brevo` | -| `BREVO_SMTP_LOGIN` | your Brevo SMTP login | -| `BREVO_SMTP_KEY` | your `xsmtpsib-...` key | -| `BREVO_SMTP_FROM` | your verified sender address | -| `ALLOWED_ORIGINS` | `*` (tighten when frontend is deployed) | -| `TRUST_PROXY` | `1` | - -**Render tip:** Use the **Secret** checkbox on any sensitive value (JWT secrets, SMTP keys, DB password). These are stored encrypted and never shown in logs. +| Key | Value | +| --------------------------------- | ----------------------------------------------------------- | +| `NODE_ENV` | `production` | +| `PORT` | `10000` (Render injects its own PORT; set this as fallback) | +| `DATABASE_URL` | your Neon connection string | +| `DB_POOL_MAX` | `5` | +| `REDIS_URL` | `rediss://default:REDACTED@upward-mule-75729.upstash.io:6379` | +| `JWT_SECRET` | your generated secret | +| `JWT_REFRESH_SECRET` | your second generated secret | +| `JWT_EXPIRES_IN` | `15m` | +| `JWT_REFRESH_EXPIRES_IN` | `7d` | +| `STORAGE_ADAPTER` | `azure` | +| `AZURE_STORAGE_CONNECTION_STRING` | your full connection string | +| `AZURE_STORAGE_CONTAINER` | `roomies-uploads` | +| `EMAIL_PROVIDER` | `brevo-api` | +| `BREVO_API_KEY` | your Brevo API key | +| `BREVO_SMTP_FROM` | your verified sender address | +| `GOOGLE_CLIENT_ID` | your Google client ID | +| `GOOGLE_CLIENT_SECRET` | your Google client secret | +| `ALLOWED_ORIGINS` | `https://roomies-lilac.vercel.app` | +| `TRUST_PROXY` | `1` | + +**Render tip:** Use the **Secret** checkbox on any sensitive value (JWT secrets, API keys, OAuth client secrets, Redis +password, DB password, storage connection string). These are stored encrypted and never shown in logs. #### 6.3 Deploy @@ -422,7 +445,8 @@ curl https://roomies-api.onrender.com/api/v1/health # Expected: {"status":"ok","services":{"database":"ok","redis":"ok"}} ``` -Note: the first request after the service spins down will take 5–10 seconds (cold start + Neon compute cold start). Subsequent requests within the same session are fast. +Note: the first request after the service spins down will take 5–10 seconds (cold start + Neon compute cold start). +Subsequent requests within the same session are fast. ### 4.2 Test Registration @@ -460,13 +484,13 @@ Expected: `201` with a `data.user` object and `sid`. ### 5.1 What to Monitor -| Metric | Where to Check | Migration Trigger | -|---|---|---| -| Redis commands/month | Upstash Console → Usage | > 400K → upgrade to Upstash Fixed $10/mo | -| Neon storage | Neon Console → Project → Storage | > 0.4 GB → plan migration to Azure PostgreSQL | -| Render free hours | Render Dashboard → Billing | Near 750h → evaluate paid Render or Azure App Service | -| Blob storage | Azure Portal → Storage Account | > 4 GB → still cheap at ~₹1.5/GB | -| Brevo sends | Brevo Dashboard → Statistics | > 200/day avg → consider higher Brevo plan | +| Metric | Where to Check | Migration Trigger | +| -------------------- | -------------------------------- | ----------------------------------------------------- | +| Redis commands/month | Upstash Console → Usage | > 400K → upgrade to Upstash Fixed $10/mo | +| Neon storage | Neon Console → Project → Storage | > 0.4 GB → plan migration to Azure PostgreSQL | +| Render free hours | Render Dashboard → Billing | Near 750h → evaluate paid Render or Azure App Service | +| Blob storage | Azure Portal → Storage Account | > 4 GB → still cheap at ~₹1.5/GB | +| Brevo sends | Brevo Dashboard → Statistics | > 200/day avg → consider higher Brevo plan | ### 5.2 Migration Checklist — Redis @@ -525,24 +549,26 @@ Render auto-deploys on push to `main` once the repo is connected. No extra setup name: Deploy to Render on: - push: - branches: [main] + push: + branches: [main] jobs: - deploy: - runs-on: ubuntu-latest - steps: - - name: Trigger Render Deploy - run: curl -X POST "${{ secrets.RENDER_DEPLOY_HOOK }}" + deploy: + runs-on: ubuntu-latest + steps: + - name: Trigger Render Deploy + run: curl -X POST "${{ secrets.RENDER_DEPLOY_HOOK }}" ``` --- ## Part 7 — Tier 2: Full Azure Deployment (Last Resort) -Use this section only when free/cheap external providers are no longer sufficient. The Azure infrastructure below is fully production-ready and has been tested. +Use this section only when free/cheap external providers are no longer sufficient. The Azure infrastructure below is +fully production-ready and has been tested. -> **When to migrate here:** Student credits are available and monthly spend on external providers (Upstash + Neon paid) exceeds ~₹1,500/month, OR you need features not available on free tiers (always-on, larger RAM, SLAs). +> **When to migrate here:** Student credits are available and monthly spend on external providers (Upstash + Neon paid) +> exceeds ~₹1,500/month, OR you need features not available on free tiers (always-on, larger RAM, SLAs). ### Resource Group @@ -559,11 +585,11 @@ az group create --name roomies-rg --location centralindia 1. Search **"Azure Database for PostgreSQL flexible server"** → **+ Create** → **Flexible server** 2. **Basics:** - - Resource group: `roomies-rg` - - Server name: `roomies-db` - - Region: `Central India` - - PostgreSQL version: **16** - - Workload: **Development** → Compute: **Standard_B1ms**, Storage: **32 GiB** + - Resource group: `roomies-rg` + - Server name: `roomies-db` + - Region: `Central India` + - PostgreSQL version: **16** + - Workload: **Development** → Compute: **Standard_B1ms**, Storage: **32 GiB** 3. **Authentication:** PostgreSQL only. Admin: `roomiesadmin`. Strong password. 4. **Networking:** Public access. Add your IP. Allow Azure services: **Yes**. 5. **Backups:** 7 days, locally redundant. @@ -601,7 +627,9 @@ az postgres flexible-server start --resource-group roomies-rg --name roomies-db ### Upstash Redis Fixed 250MB (Still Cheaper Than Azure Redis) -Azure Cache for Redis C0 costs ~₹1,050/month. Upstash Fixed 250MB is $10/month (~₹840). **Even at Tier 2, prefer Upstash Fixed over Azure Redis unless you have a specific reason to switch.** Only migrate to Azure Redis if Upstash causes operational issues. +Azure Cache for Redis C0 costs ~₹1,050/month. Upstash Fixed 250MB is $10/month (~₹840). **Even at Tier 2, prefer Upstash +Fixed over Azure Redis unless you have a specific reason to switch.** Only migrate to Azure Redis if Upstash causes +operational issues. If you do want Azure Redis: @@ -623,14 +651,14 @@ If you do want Azure Redis: **Application settings to add directly (not via Key Vault):** -| Name | Value | -|---|---| -| `NODE_ENV` | `production` | -| `PORT` | `8080` | -| `STORAGE_ADAPTER` | `azure` | -| `JWT_EXPIRES_IN` | `15m` | -| `JWT_REFRESH_EXPIRES_IN` | `7d` | -| `TRUST_PROXY` | `1` | +| Name | Value | +| ------------------------ | ------------ | +| `NODE_ENV` | `production` | +| `PORT` | `8080` | +| `STORAGE_ADAPTER` | `azure` | +| `JWT_EXPIRES_IN` | `15m` | +| `JWT_REFRESH_EXPIRES_IN` | `7d` | +| `TRUST_PROXY` | `1` | **General settings:** @@ -641,19 +669,19 @@ If you do want Azure Redis: **Key Vault secret names to create:** -| Secret Name | Value | -|---|---| -| `DATABASE-URL` | `postgresql://roomiesadmin:PASSWORD@roomies-db.postgres.database.azure.com:5432/roomies_db?sslmode=require` | -| `REDIS-URL` | `rediss://default:PASSWORD@ENDPOINT.upstash.io:6380` (or Azure Redis URL) | -| `JWT-SECRET` | your generated secret | -| `JWT-REFRESH-SECRET` | your second generated secret | -| `AZURE-STORAGE-CONNECTION-STRING` | your blob connection string | -| `AZURE-STORAGE-CONTAINER` | `roomies-uploads` | -| `EMAIL-PROVIDER` | `brevo` | -| `BREVO-SMTP-LOGIN` | your login | -| `BREVO-SMTP-KEY` | your `xsmtpsib-` key | -| `BREVO-SMTP-FROM` | your verified sender | -| `ALLOWED-ORIGINS` | `https://your-frontend.vercel.app` | +| Secret Name | Value | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `DATABASE-URL` | `postgresql://roomiesadmin:PASSWORD@roomies-db.postgres.database.azure.com:5432/roomies_db?sslmode=require` | +| `REDIS-URL` | `rediss://default:PASSWORD@ENDPOINT.upstash.io:6379` (or Azure Redis URL) | +| `JWT-SECRET` | your generated secret | +| `JWT-REFRESH-SECRET` | your second generated secret | +| `AZURE-STORAGE-CONNECTION-STRING` | your blob connection string | +| `AZURE-STORAGE-CONTAINER` | `roomies-uploads` | +| `EMAIL-PROVIDER` | `brevo` | +| `BREVO-SMTP-LOGIN` | your login | +| `BREVO-SMTP-KEY` | your `xsmtpsib-` key | +| `BREVO-SMTP-FROM` | your verified sender | +| `ALLOWED-ORIGINS` | `https://your-frontend.vercel.app` | ### Deploy to Azure App Service (from Render) @@ -677,15 +705,15 @@ Update your frontend's API base URL from `https://roomies-api.onrender.com` to ` ### Tier 2 Budget -| Service | Tier | Cost/month | -|---|---|---| -| Azure App Service B1 | Basic | ~₹1,092 | -| Azure PostgreSQL Flexible B1ms | Burstable | ~₹1,043 + storage | -| PostgreSQL storage (32 GB) | Provisioned SSD | ~₹280 | -| Upstash Redis Fixed 250MB | Fixed | ~₹840 | -| Azure Blob Storage | Always-free 5 GB | ₹0 | -| Brevo Email | Free 300/day | ₹0 | -| **Total** | | **~₹3,255/month** | +| Service | Tier | Cost/month | +| ------------------------------ | ---------------- | ----------------- | +| Azure App Service B1 | Basic | ~₹1,092 | +| Azure PostgreSQL Flexible B1ms | Burstable | ~₹1,043 + storage | +| PostgreSQL storage (32 GB) | Provisioned SSD | ~₹280 | +| Upstash Redis Fixed 250MB | Fixed | ~₹840 | +| Azure Blob Storage | Always-free 5 GB | ₹0 | +| Brevo Email | Free 300/day | ₹0 | +| **Total** | | **~₹3,255/month** | With ₹9,480 credits: **~2.9 months** at full Tier 2. By then the project should have real users. @@ -693,30 +721,31 @@ With ₹9,480 credits: **~2.9 months** at full Tier 2. By then the project shoul ## Part 8 — Troubleshooting -| Symptom | Cause | Fix | -|---|---|---| -| First request takes 10+ seconds | Render cold start + Neon cold start | Expected; subsequent requests fast | -| `redis: "unhealthy"` on health | Wrong `REDIS_URL` format | Must be `rediss://` (double-s), port 6380 | -| `database: "unhealthy"` | Neon compute cold start or wrong URL | Check Neon Console for endpoint status | -| Photos stuck on `processing:` | BullMQ media worker not started | Check logs for `Media processing worker started` | -| OTP emails not arriving | Brevo sender not verified | Verify `BREVO_SMTP_FROM` in Brevo → Senders | -| Cron jobs missed | Server was sleeping at cron time | Expected; crons are idempotent — next wake catches up | -| 500K Upstash commands hit | Server kept awake (UptimeRobot?) | Remove keep-alive pings; or upgrade to Fixed plan | -| Build fails on Render | Node version mismatch | Ensure `package.json` has `"engines": {"node": ">=22.0.0"}` | -| `ENV_FILE` not found locally | Missing `.env.render` file | Create from the template in Part 3, Phase 5 | +| Symptom | Cause | Fix | +| ------------------------------- | ------------------------------------ | ----------------------------------------------------------- | +| First request takes 10+ seconds | Render cold start + Neon cold start | Expected; subsequent requests fast | +| `redis: "unhealthy"` on health | Wrong `REDIS_URL` format | Must be `rediss://` (double-s), port 6379 | +| `database: "unhealthy"` | Neon compute cold start or wrong URL | Check Neon Console for endpoint status | +| Photos stuck on `processing:` | BullMQ media worker not started | Check logs for `Media processing worker started` | +| OTP emails not arriving | Brevo sender not verified | Verify `BREVO_SMTP_FROM` in Brevo → Senders | +| Cron jobs missed | Server was sleeping at cron time | Expected; crons are idempotent — next wake catches up | +| 500K Upstash commands hit | Server kept awake (UptimeRobot?) | Remove keep-alive pings; or upgrade to Fixed plan | +| Build fails on Render | Node version mismatch | Ensure `package.json` has `"engines": {"node": ">=22.0.0"}` | +| `ENV_FILE` not found locally | Missing `.env.render` file | Create from the template in Part 3, Phase 5 | --- ## Part 9 — All Resource Names (Tier 0) -| Resource | Name | URL/Endpoint | -|---|---|---| -| Render Web Service | `roomies-api` | `https://roomies-api.onrender.com` | -| Neon Project | `roomies` | `ep-XXXXX.ap-southeast-1.aws.neon.tech` | -| Upstash Redis | `roomies-redis` | `XXXXX.upstash.io:6380` | -| Azure Storage Account | `roomiesblob` | `roomiesblob.blob.core.windows.net` | -| Blob Container | `roomies-uploads` | `https://roomiesblob.blob.core.windows.net/roomies-uploads/` | -| Email Provider | Brevo SMTP | `smtp-relay.brevo.com:587` | +| Resource | Name | URL/Endpoint | +| --------------------- | ----------------- | ------------------------------------------------------------ | +| Render Web Service | `roomies-api` | `https://roomies-api.onrender.com` | +| Neon Project | `roomies` | `ep-XXXXX.ap-southeast-1.aws.neon.tech` | +| Upstash Redis | `roomies-redis` | `upward-mule-75729.upstash.io:6379` | +| Azure Resource Group | `roomies-rg` | Azure Portal resource group | +| Azure Storage Account | `roomiesblob` | `roomiesblob.blob.core.windows.net` | +| Blob Container | `roomies-uploads` | `https://roomiesblob.blob.core.windows.net/roomies-uploads/` | +| Email Provider | Brevo API | HTTPS API | --- @@ -746,29 +775,30 @@ curl https://roomies-api.onrender.com/api/v1/health ## Appendix — Provider Comparison -| Dimension | Render Free | Azure App Service F1 | Azure App Service B1 | -|---|---|---|---| -| Always-on | No (sleeps 15 min idle) | No (no Always On) | Yes | -| RAM | 512 MB | 1 GB | 1.75 GB | +| Dimension | Render Free | Azure App Service F1 | Azure App Service B1 | +| -------------- | ----------------------- | ----------------------- | -------------------- | +| Always-on | No (sleeps 15 min idle) | No (no Always On) | Yes | +| RAM | 512 MB | 1 GB | 1.75 GB | | BullMQ workers | Yes (sleep with server) | Yes (sleep with server) | Yes (always running) | -| Monthly cost | Free | Free | ~₹1,092 | -| Best for | Tier 0 (free, idle OK) | Not usable | Tier 2 (production) | - -| Dimension | Neon Free | Azure PostgreSQL B1ms | -|---|---|---| -| Storage | 0.5 GB | 32 GB provisioned | -| PostGIS | Yes | Yes | -| Scale-to-zero | Yes (cold starts) | No (always running) | -| Monthly cost | Free | ~₹1,323 | -| Best for | Tier 0 and Tier 1 | Tier 2 | - -| Dimension | Upstash Free | Upstash Fixed | Azure Redis C0 | -|---|---|---|---| -| Commands | 500K/month | Unlimited | Unlimited | -| RAM | 256 MB | 250 MB | 250 MB | -| Monthly cost | Free | $10 (~₹840) | ~₹1,050 | -| Best for | Tier 0 (monitor usage) | Tier 1 | Tier 2 (only if needed) | +| Monthly cost | Free | Free | ~₹1,092 | +| Best for | Tier 0 (free, idle OK) | Not usable | Tier 2 (production) | + +| Dimension | Neon Free | Azure PostgreSQL B1ms | +| ------------- | ----------------- | --------------------- | +| Storage | 0.5 GB | 32 GB provisioned | +| PostGIS | Yes | Yes | +| Scale-to-zero | Yes (cold starts) | No (always running) | +| Monthly cost | Free | ~₹1,323 | +| Best for | Tier 0 and Tier 1 | Tier 2 | + +| Dimension | Upstash Free | Upstash Fixed | Azure Redis C0 | +| ------------ | ---------------------- | ------------- | ----------------------- | +| Commands | 500K/month | Unlimited | Unlimited | +| RAM | 256 MB | 250 MB | 250 MB | +| Monthly cost | Free | $10 (~₹840) | ~₹1,050 | +| Best for | Tier 0 (monitor usage) | Tier 1 | Tier 2 (only if needed) | --- -_Guide version: April 2026. Tier 0: Render Singapore + Neon Singapore + Upstash Singapore + Azure Blob Central India. Node.js 22 LTS, PostgreSQL 16 + PostGIS._ \ No newline at end of file +_Guide version: April 2026. Tier 0: Render Singapore + Neon Singapore + Upstash Singapore + Azure Blob Central India. +Node.js 22 LTS, PostgreSQL 16 + PostGIS._ diff --git a/docs/ImplementationPlan.md b/docs/ImplementationPlan.md deleted file mode 100644 index 28538c5..0000000 --- a/docs/ImplementationPlan.md +++ /dev/null @@ -1,624 +0,0 @@ -# Roomies — Implementation Plan - -**Derived from the Project Plan. Each phase delivers a working, testable slice. No phase begins until the previous one -is stable and every endpoint is manually verified with real HTTP requests.** - ---- - -## Stack - -| Item | Detail | -| ------------- | ------------------------------------------------------------------------ | -| Runtime | Node.js + Express, JavaScript ES modules (`"type": "module"`) | -| Database | PostgreSQL 16 + PostGIS — raw SQL via `pg`, no ORM | -| Cache / Queue | Redis (`redis` v5) + BullMQ | -| Auth | JWT + bcryptjs + Google OAuth via `google-auth-library` | -| Validation | Zod v4 — request bodies, query params, route params, env vars at startup | -| Logging | Pino + pino-http + pino-pretty | -| Target cloud | Microsoft Azure | -| Local dev | PostgreSQL 16 + Redis installed directly on host — no Docker | - -## Documentation Layout - -- `docs/API.md` is the API entrypoint and shared transport/response overview. -- Detailed request, response, and scenario documentation is split by feature under `docs/api/`. -- When route behavior changes, update the corresponding feature doc instead of expanding `docs/API.md` back into a - single monolithic reference. -- Changes in `student`, `report`, gate middleware, or cron jobs must be reflected in docs during the same delivery cycle: - - student/report endpoint contract changes → update feature docs in `docs/api/` - - gate short-circuit behavior or shared error envelopes → update `docs/api/conventions.md` - - cron behavior that impacts user-visible API states → update `docs/API.md` and operational docs - ---- - -## Branch Status - -``` -main - └── Phase1 ✅ COMPLETE - ├── phase1/foundation ✅ MERGED - ├── phase1/auth ✅ MERGED - ├── phase1/institutions ✅ MERGED - ├── phase1/auth-transport ✅ MERGED - └── phase1/verification ✅ MERGED - └── Phase2 ✅ COMPLETE - ├── phase2/amenities ✅ MERGED - ├── phase2/listings ✅ MERGED - ├── phase2/media ✅ MERGED - └── phase2/saved ✅ MERGED - └── Phase3 ✅ COMPLETE - ├── phase3/interests ✅ MERGED - ├── phase3/connections ✅ MERGED - ├── phase3/notifications ✅ MERGED - └── phase3/ratings ✅ MERGED - └── Phase4 ✅ COMPLETE - └── phase4/moderation ✅ MERGED - └── Phase5 🔄 PARTIAL - ├── phase5/cron ✅ MERGED - └── phase5/admin ⏳ NOT STARTED - └── Phase6 ⏳ DEFERRED -``` - ---- - -## Established Conventions - -These decisions were made during `phase1/foundation` and apply to every file written from this point forward. - -**File path comment:** Every file must have its relative path as the first comment line: `// src/routes/auth.js` - -**Environment files:** `.env.local` for local dev, `.env.azure` for Azure connectivity check only. `ENV_FILE` -environment variable controls which file is loaded. All env vars are Zod-validated at startup — process exits on any -missing or wrong-typed variable. Never read `process.env` directly in application code; always import `config` from -`src/config/env.js`. - -**npm scripts:** - -```bash -npm run dev # no ENV_FILE override; env.js falls back to .env.local/.env -npm run dev:azure # ENV_FILE=.env.azure -npm start # no ENV_FILE override; production-safe default -npm run start:azure # ENV_FILE=.env.azure -``` - -**Validation middleware:** `validate(schema)` from `src/middleware/validate.js`. After successful parse, `result.data` -is written back to `req.body`, `req.query`, and `req.params`. Downstream handlers always see coerced types and Zod -defaults. - -**Error handling:** Throw `new AppError(message, statusCode)` for all known errors. Pass unknown errors to `next(err)`. -Global handler covers ZodError, AppError, PostgreSQL constraint codes (`23505`, `23503`, `23514`), and JWT errors. Never -expose stack traces. - -**Zod v4 API:** - -| Feature | Correct (v4) | Deprecated (v3) | -| ------------------- | ------------------ | -------------------- | -| Email | `z.email()` | `z.string().email()` | -| URL | `z.url()` | `z.string().url()` | -| UUID | `z.uuid()` | `z.string().uuid()` | -| Error list | `error.issues` | `error.errors` | -| Error message param | `{ error: '...' }` | `{ message: '...' }` | - -**Health check:** `GET /api/v1/health` — returns 200 with both services `ok`, or 503 with `degraded` status. Each probe -has a 3-second timeout via `Promise.race` with `clearTimeout` in `.finally()`. - -**Graceful shutdown:** `server.js` handles `SIGINT` and `SIGTERM`. Shutdown order: HTTP → cron → workers → queues → -PostgreSQL → Redis. Force-exits after 10 seconds. Redis uses `redis.close()` (not `redis.quit()` — deprecated in -node-redis v5). - -**Database queries:** Import `pool` from `src/db/client.js` for one-shot queries. For transactions: `pool.connect()` → -`BEGIN / COMMIT / ROLLBACK` manually, always `client.release()` in `finally`. Stable reused queries go in -`src/db/utils/` — one file per concern. - -**Logging:** Import `logger` from `src/logger/index.js`. Structured: `logger.info({ userId }, 'message')`. Never -`console.log`. - -**Auth transport:** Every auth response sets `accessToken` and `refreshToken` as HttpOnly cookies AND includes both -tokens in JSON body. Browser clients use cookies; Android clients use the body. Cookie options: `httpOnly: true`, -`secure: config.NODE_ENV === 'production'`, `sameSite: 'strict'`, `maxAge: parseTtlSeconds(...) * 1000`. Options must be -identical between set and clear calls. - -**Silent refresh:** `authenticate` middleware attempts silent refresh exclusively when the expired token came from -`req.cookies.accessToken`. An expired Bearer header token always returns 401 immediately. - -**Notifications:** Always post-commit, never inside a transaction. Enqueue to `notification-delivery` queue via BullMQ -`enqueueNotification()` helper in `src/workers/notificationQueue.js`. A notification failure never rolls back a state -transition. - -**Pagination:** Keyset cursor. Fetch `limit + 1` rows to detect next page. Compound cursor `(created_at, entity_id)` -provides stable ordering under concurrent inserts. Both cursor fields must be present or both absent (validated at Zod -layer). - -**The paise rule:** `rent_per_month` and `deposit_amount` are stored in paise (1 rupee = 100 paise) in the database. -Conversion happens only in `src/services/listing.service.js` — never in validators or controllers. - -**Security decisions (permanent):** - -- `DUMMY_HASH` in `auth.service.js` is a pre-computed bcrypt hash of `"dummy"` at 10 rounds. Never remove. Never replace - with a runtime `bcrypt.hash()` call. Used for timing equalisation on unknown email logins. -- `INACTIVE_STATUSES` is module-scope, allocated once. -- OTP verify applies dual throttles: IP-level Redis counter (`ipAttempts:{ip}`, 50/15min, fail-closed) and service-layer - per-user counter (`otpAttempts:{userId}`, max 5). -- `404` not `403` for party-membership checks — never confirm resource existence to non-parties. Applies to connections, - ratings, interest requests. -- Property rating guard: WHERE EXISTS JOIN traverses `connections → listings → properties` — prevents rating an - arbitrary property using an unrelated connection. - ---- - -## Complete Source File Inventory - -``` -src/ - server.js ✅ Starts media + notification workers; registers cron jobs; graceful shutdown - app.js ✅ Middleware order fixed, CORS guard at startup - config/ - env.js ✅ Zod env validation — add new vars here before use - constants.js ✅ MAX_UPLOAD_SIZE_BYTES, UPLOAD_FIELD_NAME - db/ - client.js ✅ pg.Pool singleton; fatalCodes for ECONNREFUSED/ENOTFOUND - seeds/ - amenities.js ✅ Idempotent ON CONFLICT DO NOTHING seed (19 amenities) - utils/ - auth.js ✅ findUserById, findUserByEmail, findUserByGoogleId - institutions.js ✅ findInstitutionByDomain(domain, client?) - pgOwner.js ✅ assertPgOwnerVerified(userId, client?) - spatial.js ✅ (proximity search moved inline to listing.service.js) - compatibility.js ✅ scoreListingsForUser — JOIN on (preference_key, value) - cache/client.js ✅ Exponential backoff, MAX_RETRY_ATTEMPTS=10 - logger/index.js ✅ Pino — structured logging - middleware/ - authenticate.js ✅ extractToken (cookie→header priority), attemptSilentRefresh (cookie-only), INACTIVE_STATUSES - authorize.js ✅ Call-time role validation throws Error at route registration - optionalAuthenticate.js ✅ Tries to resolve user context if valid token exists; never blocks - contactRevealGate.js ✅ Two-tier quota enforcement: verified=unlimited, guest=10 email-only reveals - errorHandler.js ✅ AppError + ZodError + PG constraint codes + JWT errors - rateLimiter.js ✅ authLimiter (10/15min), otpLimiter (5/15min) — Redis-backed - validate.js ✅ Writes result.data back to req - upload.js ✅ Multer — MIME type + extension cross-check, 10MB limit - services/ - auth.service.js ✅ register, login, logout (current + all), refresh, sendOtp, verifyOtp, - googleOAuth, listSessions, revokeSession — all paths end at buildTokenResponse - Per-session refresh tokens with CAS rotation; legacy token migration - email.service.js ✅ maskEmail guard, OTP format guard, transport at module level - student.service.js ✅ Dynamic SET clause, ownership check, getStudentContactReveal - pgOwner.service.js ✅ Same pattern as student, getPgOwnerContactReveal - verification.service.js ✅ submitDocument, getVerificationQueue (keyset), approveRequest, rejectRequest - property.service.js ✅ Full CRUD; location cascade to linked listings on update; - soft-delete blocked if active listings exist; FOR UPDATE row lock on delete - listing.service.js ✅ Full CRUD + search (dynamic WHERE + LOWER(city) index + proximity + - compatibility) + preferences + save/unsave; - rent stored in paise, divided by 100 on read; - occupancy tracking on accept; expirePendingRequests wired - listingLifecycle.js ✅ EXPIRED_LISTING_MESSAGE, UNAVAILABLE_LISTING_MESSAGE constants - photo.service.js ✅ enqueuePhotoUpload (202 pattern, server-side display_order, FOR UPDATE lock), - getListingPhotos, deletePhoto, setCoverPhoto, reorderPhotos - interest.service.js ✅ createInterestRequest, transitionInterestRequest, - _acceptInterestRequest (atomic: accept + connection + occupancy + fill/expire), - getInterestRequest, getInterestRequestsForListing, - getMyInterestRequests, expirePendingRequestsForListing - connection.service.js ✅ confirmConnection (single atomic UPDATE with CASE WHEN + FOR UPDATE pre-read), - getConnection, getMyConnections - notification.service.js ✅ getFeed, getUnreadCount, markRead (bulk + selective) - rating.service.js ✅ submitRating (WHERE EXISTS gate + ON CONFLICT; self-rating prevention; - property owner_id captured in CTE RETURNING), - getRatingsForConnection (returns arrays), getPublicRatings, - getMyGivenRatings, getPublicPropertyRatings - report.service.js ✅ submitReport (atomic INSERT...SELECT), getReportQueue (LEFT JOINs), - resolveReport (handles soft-deleted rating edge case) - controllers/ ✅ All thin wrappers — no business logic - auth.controller.js ✅ register, login, logout, logoutAll, refresh, sendOtp, verifyOtp, - me, listSessions, revokeSession, googleCallback - student.controller.js ✅ getProfile, updateProfile, revealContact - pgOwner.controller.js ✅ getProfile, updateProfile, revealContact - verification.controller.js ✅ submitDocument, getVerificationQueue, approveRequest, rejectRequest - property.controller.js ✅ createProperty, getProperty, listProperties, updateProperty, deleteProperty - listing.controller.js ✅ createListing, getListing, searchListings, updateListing, deleteListing, - updateListingStatus, getListingPreferences, updateListingPreferences, - saveListing, unsaveListing, getSavedListings - photo.controller.js ✅ uploadPhoto, getPhotos, deletePhoto, setCoverPhoto, reorderPhotos - interest.controller.js ✅ createInterestRequest, getInterestRequest, updateInterestStatus, - getListingInterests, getMyInterestRequests - connection.controller.js ✅ confirmConnection, getConnection, getMyConnections - notification.controller.js ✅ getFeed, getUnreadCount, markRead - rating.controller.js ✅ submitRating, getRatingsForConnection, getPublicRatings, - getMyGivenRatings, getPublicPropertyRatings - report.controller.js ✅ submitReport, getReportQueue, resolveReport - routes/ - index.js ✅ Mounts all routers at /api/v1 - health.js ✅ GET /health — 3s timeout probes, sanitised error responses - auth.js ✅ 10 auth endpoints including google/callback, sessions - student.js ✅ GET + PUT /:userId/profile; GET /:userId/contact/reveal - pgOwner.js ✅ GET + PUT /:userId/profile; GET /:userId/contact/reveal; POST /:userId/documents - admin.js ✅ authenticate + authorize('admin') at router level; - verification queue + report queue management - property.js ✅ Full CRUD — pg_owner only for writes - listing.js ✅ Full CRUD + status + preferences + save/unsave + photos + interest sub-routes - interest.js ✅ /me + /:interestId + /:interestId/status - connection.js ✅ /me + /:connectionId + /:connectionId/confirm - notification.js ✅ / + /unread-count + /mark-read - rating.js ✅ POST / + /me/given + /user/:userId + /property/:propertyId - + /connection/:connectionId + /:ratingId/report - validators/ - auth.validators.js ✅ registerSchema, loginSchema, refreshSchema, logoutCurrentSchema, - listSessionsSchema, revokeSessionSchema, otpVerifySchema, googleCallbackSchema - student.validators.js ✅ getStudentParamsSchema, updateStudentSchema - pgOwner.validators.js ✅ getPgOwnerParamsSchema, updatePgOwnerSchema - verification.validators.js ✅ submitDocumentSchema, getQueueSchema, approveRequestSchema, rejectRequestSchema - property.validators.js ✅ propertyParamsSchema, listPropertiesSchema, createPropertySchema, updatePropertySchema - listing.validators.js ✅ Full suite including searchListingsSchema (cross-field: rent range, cursor, proximity), - createListingSchema, updateListingSchema, updateListingStatusSchema, - updatePreferencesSchema, saveListingSchema, savedListingsSchema - photo.validators.js ✅ uploadPhotoSchema, deletePhotoSchema, reorderPhotosSchema, setCoverSchema - interest.validators.js ✅ createInterestSchema, interestParamsSchema, updateInterestStatusSchema, - getListingInterestsSchema, getMyInterestsSchema - connection.validators.js ✅ connectionParamsSchema, getMyConnectionsSchema - notification.validators.js ✅ getFeedSchema (isRead coercion fixed), markReadSchema (all: z.literal(true)) - pagination.validators.js ✅ buildKeysetPaginationQuerySchema, keysetPaginationQuerySchema - rating.validators.js ✅ submitRatingSchema, getRatingsForConnectionSchema, getPublicRatingsSchema, - getMyGivenRatingsSchema, getPublicPropertyRatingsSchema - report.validators.js ✅ submitReportSchema, getReportQueueSchema, resolveReportSchema - storage/ - index.js ✅ Selects adapter from config.STORAGE_ADAPTER at startup (reads Zod config, not process.env) - adapters/ - localDisk.js ✅ Dev adapter — writes WebP to /uploads/listings/{listingId}/ - azureBlob.js ✅ Production — uploadData with 30s AbortController timeout; - delete strips query-string before extracting blob path - cron/ - listingExpiry.js ✅ Daily 02:00 — expires active listings past expires_at + expires pending requests - expiryWarning.js ✅ Daily 01:00 — enqueues listing_expiring notifications for listings expiring in 7 days; - idempotent (checks 24h notification dedup) - hardDeleteCleanup.js ✅ Weekly Sunday 04:00 — hard-deletes rows with deleted_at > 90 days; - correct dependency-order deletion - workers/ - queue.js ✅ Named singleton registry — prevents duplicate Redis connections - notificationQueue.js ✅ Shared enqueueNotification() helper — fire-and-forget, catches Redis errors - mediaProcessor.js ✅ Sharp resize→WebP, storage write, DB update, cover election, staging cleanup; concurrency 1 - notificationWorker.js ✅ Notification INSERT with idempotency_key ON CONFLICT DO NOTHING; concurrency 10 -``` - ---- - -## Phase 1 — Foundation & Identity ✅ COMPLETE - -**Goal:** Register as student or PG owner, log in, receive OTP, server correctly identifies on every subsequent request. -Institution auto-verification works. PG owner document upload creates a `verification_requests` row. Admin can view the -verification queue and approve/reject. - -### `phase1/foundation` ✅ - -Server, app, config, database pool, Redis client, logger, error handler, validate middleware, health route. - -### `phase1/auth` ✅ - -Full auth pipeline: register (with institution domain lookup in same transaction), login (DUMMY_HASH timing -equalisation), logout, refresh (account_status enforced), OTP send/verify (dual throttle: IP + per-user), -`GET /auth/me`. Student and PG owner profile GET/PUT. `authenticate` and `authorize` middleware. Redis-backed rate -limiters. - -**Hardening baked in permanently:** `authorize` throws at route registration (not per-request), concurrent registration -race guarded with inner try/catch on `23505`, OTP exhaustion returns 429 on the 5th wrong attempt, `crypto.randomInt` -upper bound `1000000` (exclusive) gives full `100000–999999` range, `otpVerifySchema` uses digit-only regex. - -### `phase1/institutions` ✅ - -`findInstitutionByDomain` queries with `deleted_at IS NULL` to hit the partial unique index. Registration transaction -extended: domain match triggers institution_id link + `is_email_verified = TRUE` in the same `BEGIN/COMMIT`. -`effectivelyVerified` tracks the post-UPDATE state — the RETURNING value from INSERT is always FALSE. - -PG owner verification pipeline: `submitDocument` (ownership + profile check + INSERT into `verification_requests`), -`getVerificationQueue` (keyset pagination, oldest-first anti-starvation), `approveRequest` / `rejectRequest` (both with -`AND status = 'pending'` concurrency guard). Admin router has `authenticate + authorize('admin')` at router level. - -### `phase1/auth-transport` ✅ - -`setAuthCookies` / `clearAuthCookies` in auth controller. `ACCESS_COOKIE_OPTIONS` / `REFRESH_COOKIE_OPTIONS` computed -once at module scope. Silent refresh in `authenticate.js`: cookie-source expiry only — loads fresh roles and email from -DB, signs new access token, CAS-rotates refresh token in Redis. - -### `phase1/verification` ✅ - -`findUserByGoogleId` completed. `googleOAuth` service covers three paths: returning OAuth user, account linking -(`AND google_id IS NULL` concurrency guard on UPDATE), new registration (same institution domain transaction, -`password_hash = NULL`, `is_email_verified = TRUE`). Per-session refresh tokens introduced -(`refreshToken:{userId}:{sid}`). `listSessions` and `revokeSession` added. `casRefreshToken` Lua script prevents -concurrent rotation races. - -**Full route surface after Phase 1:** - -``` -POST /api/v1/auth/register -POST /api/v1/auth/login -POST /api/v1/auth/logout -POST /api/v1/auth/logout/current -POST /api/v1/auth/logout/all -GET /api/v1/auth/sessions -DELETE /api/v1/auth/sessions/:sid -POST /api/v1/auth/refresh -POST /api/v1/auth/otp/send -POST /api/v1/auth/otp/verify -GET /api/v1/auth/me -POST /api/v1/auth/google/callback -GET /api/v1/students/:userId/profile -PUT /api/v1/students/:userId/profile -GET /api/v1/pg-owners/:userId/profile -PUT /api/v1/pg-owners/:userId/profile -POST /api/v1/pg-owners/:userId/documents -GET /api/v1/admin/verification-queue -POST /api/v1/admin/verification-queue/:requestId/approve -POST /api/v1/admin/verification-queue/:requestId/reject -``` - ---- - -## Phase 2 — Listings & Search ✅ COMPLETE - -**Goal:** Verified PG owner can create a property and attach listings. Student can search by city, rent range, room -type, amenities, and proximity. Photos upload and compress asynchronously. Compatibility scores appear on search -results. Student can save listings. - -### `phase2/amenities` ✅ - -Idempotent amenity seed (19 amenities across utility/safety/comfort categories, `ON CONFLICT DO NOTHING`). Property CRUD -with `assertPgOwnerVerified` guard before any write. Amenity junction rows managed in same transaction as property -INSERT. Soft-delete blocked if active listings exist (with `SELECT ... FOR UPDATE` row lock on property + all its -listings to close the TOCTOU race). - -**Location cascade:** When a property's city/address/coordinates change in `updateProperty`, the same values are -propagated to all linked `pg_room` and `hostel_bed` listing rows in the same transaction, keeping proximity search -consistent. - -### `phase2/listings` ✅ - -**The paise rule** enforced: rent and deposit stored in paise, divided by 100 before returning to caller. -`expires_at = NOW() + INTERVAL '60 days'` set server-side at creation. - -Search: dynamic parameterised WHERE clause. City search uses `LOWER(l.city) LIKE LOWER($n)` against the -`idx_listings_city_lower` functional index (fixes the ILIKE sequential scan issue). Proximity via `ST_DWithin` with -geography cast inline in the query. Compatibility via `scoreListingsForUser` (JOIN on preference_key + -preference_value). Score computed fresh per search, never stored. - -`updateListingStatus` handles poster-initiated transitions: `active → filled`, `active → deactivated`, -`deactivated → active`. Terminal state: `filled → *` not allowed. `expirePendingRequestsForListing` wired into the -status-update path inside the same transaction. - -### `phase2/media` ✅ - -`StorageService` interface with `LocalDiskAdapter` (dev) and `AzureBlobAdapter` (production, 30s upload timeout via -AbortController, strips query-string before delete). `STORAGE_ADAPTER` env var selects adapter at startup from -Zod-validated config. Upload returns `202 Accepted` with `{ photoId, status: 'processing' }`. BullMQ `media-processing` -queue worker: Sharp resize → WebP quality 80 → strip EXIF → storage write → DB URL update → cover election (`NOT EXISTS` -guard in single UPDATE) → staging cleanup. Concurrency 1 (CPU-bound). `display_order` allocated server-side with -`SELECT MAX(...) FOR UPDATE` on parent listing row. - -**Placeholder sentinel:** `photo_url = 'processing:{photoId}'` — never render URLs starting with `processing:`. - -### `phase2/saved` ✅ - -`saveListing`: `ON CONFLICT DO UPDATE SET deleted_at = NULL, saved_at = NOW()` — idempotent re-save restores a -previously soft-deleted row. `unsaveListing`: soft-delete via `SET deleted_at = NOW()`. `getSavedListings`: silently -omits soft-deleted and expired listings, keyset paginated on `saved_at DESC`. - -**Contact reveal:** `GET /students/:userId/contact/reveal` and `GET /pg-owners/:userId/contact/reveal` added. -`optionalAuthenticate → validate → contactRevealGate → controller` chain. Gate enforces quota; controller delegates -response shaping (`emailOnly` flag) to service. Both endpoints registered in student and pgOwner routes respectively. - -**Full route surface after Phase 2:** - -``` -POST /api/v1/properties -GET /api/v1/properties -GET /api/v1/properties/:propertyId -PUT /api/v1/properties/:propertyId -DELETE /api/v1/properties/:propertyId -POST /api/v1/listings -GET /api/v1/listings -GET /api/v1/listings/me/saved -GET /api/v1/listings/:listingId -PUT /api/v1/listings/:listingId -PATCH /api/v1/listings/:listingId/status -DELETE /api/v1/listings/:listingId -GET /api/v1/listings/:listingId/preferences -PUT /api/v1/listings/:listingId/preferences -POST /api/v1/listings/:listingId/save -DELETE /api/v1/listings/:listingId/save -GET /api/v1/listings/:listingId/photos -POST /api/v1/listings/:listingId/photos -DELETE /api/v1/listings/:listingId/photos/:photoId -PATCH /api/v1/listings/:listingId/photos/:photoId/cover -PUT /api/v1/listings/:listingId/photos/reorder -GET /api/v1/students/:userId/contact/reveal -GET /api/v1/pg-owners/:userId/contact/reveal -``` - ---- - -## Phase 3 — Interaction Pipeline ✅ COMPLETE - -**Goal:** Student can express interest. PG owner can accept or decline. On acceptance, connection created atomically and -student receives WhatsApp deep-link. Both parties can confirm the real-world interaction. Notification feed keeps both -parties informed. Once confirmed, either party can submit ratings. - -### `phase3/interests` ✅ - -State machine: `pending → accepted | declined | withdrawn | expired`. - -**The critical atomic block:** `accepted` transition runs `UPDATE interest_requests SET status = 'accepted'` + -`INSERT INTO connections` + occupancy increment + conditional listing fill + `expirePendingRequestsForListing` all -inside one `BEGIN/COMMIT` with `FOR UPDATE` locks on both the interest request row and the parent listing row. - -**Occupancy model:** Multi-slot listings (`total_capacity > 1`) can accept multiple interest requests. Each acceptance -increments `current_occupants`. When `current_occupants >= total_capacity`, listing transitions to `filled` and all -remaining pending requests are expired. Single-slot listings behave the same way — capacity 1 means the first acceptance -fills it. - -`connection_type` derived from `listing.listing_type`: `student_room → student_roommate`, `pg_room → pg_stay`, -`hostel_bed → hostel_stay`. WhatsApp deep-link (`https://wa.me/{phone}?text=...`) returned in the accepted response -body. `null` if poster has no phone number — not an error. - -Notifications fire post-commit: `new_interest_request` → poster, `interest_request_accepted` → sender, -`interest_request_declined` → sender, `interest_request_withdrawn` → poster, `listing_filled` → poster when capacity -exhausted. - -### `phase3/connections` ✅ - -Two-sided confirmation via a single atomic UPDATE with `FOR UPDATE` pre-read to prevent race conditions. -`rowCount === 0` → 404 (never 403). `connection_confirmed` notifications fire post-commit to both parties when -`confirmed` is reached. - -### `phase3/notifications` ✅ - -BullMQ worker with 5-attempt exponential backoff, concurrency 10. `idempotency_key` = BullMQ job ID, stored with -`ON CONFLICT DO NOTHING`. `getUnreadCount` hits the partial index `WHERE is_read = FALSE`. `markRead` with -`AND is_read = FALSE` guard makes all modes idempotent. `AND recipient_id = $1` on selective mark-read prevents -cross-user manipulation. Message text stored at insert time from `NOTIFICATION_MESSAGES` map — not assembled at read -time. - -All services use the shared `enqueueNotification()` helper from `src/workers/notificationQueue.js` instead of creating -their own Queue instances. - -### `phase3/ratings` ✅ - -**Anti-fake-review guarantee:** `submitRating` uses a single `INSERT ... SELECT ... WHERE EXISTS` that atomically -verifies: (1) connection exists and is not deleted, (2) `confirmation_status = 'confirmed'`, (3) reviewer is a party, -(4) reviewer and reviewee are different parties (self-rating prevented). -`ON CONFLICT (reviewer_id, connection_id, reviewee_id) DO NOTHING` handles duplicates. - -`rowCount === 0` disambiguation: follow-up connection query determines 404 (not found) vs 422 (not confirmed) vs 409 -(duplicate). - -Property rating: WHERE EXISTS join traverses `connections → listings → properties`. Owner_id captured in CTE RETURNING -clause — no second query needed for notification. - -`getRatingsForConnection` returns `{ myRatings: Rating[], theirRatings: Rating[] }` as arrays (not single objects — -multiple ratings per connection are possible). - -Public rating endpoints require no auth. - -**Full route surface after Phase 3:** - -``` -POST /api/v1/listings/:listingId/interests -GET /api/v1/listings/:listingId/interests -GET /api/v1/interests/me -GET /api/v1/interests/:interestId -PATCH /api/v1/interests/:interestId/status -POST /api/v1/connections/:connectionId/confirm -GET /api/v1/connections/me -GET /api/v1/connections/:connectionId -GET /api/v1/notifications -GET /api/v1/notifications/unread-count -POST /api/v1/notifications/mark-read -GET /api/v1/ratings/me/given -GET /api/v1/ratings/user/:userId -GET /api/v1/ratings/property/:propertyId -GET /api/v1/ratings/connection/:connectionId -POST /api/v1/ratings -``` - ---- - -## Phase 4 — Reputation Moderation ✅ COMPLETE - -**Goal:** Parties to a connection can report ratings on that connection. Admin reviews the report queue with full -context and resolves reports, optionally hiding the rating and triggering automatic aggregate recalculation. - -### `phase4/moderation` ✅ - -**Files:** `src/validators/report.validators.js`, `src/services/report.service.js`, -`src/controllers/report.controller.js`. Modifications to `src/routes/rating.js` (added `POST /:ratingId/report`) and -`src/routes/admin.js` (added report queue + resolve routes). - -`submitReport`: atomic `INSERT ... SELECT ... FROM ratings JOIN connections WHERE (reporter is a party)`. If sub-query -returns nothing, INSERT produces zero rows → 404. Partial unique index `WHERE status = 'open'` prevents duplicate open -reports; PostgreSQL raises 23505 → global handler converts to 409. - -`getReportQueue`: LEFT JOINs (not INNER) on ratings, reporter user, reviewer user — ensures open reports remain visible -even if related rows are soft-deleted. Oldest-first pagination (anti-starvation). - -`resolveReport`: transaction updates both `rating_reports.status` and (for `resolved_removed`) -`ratings.is_visible = FALSE`. The `update_rating_aggregates` DB trigger fires automatically — no application code needed -for aggregate recalculation. Handles edge case where target rating is already soft-deleted: treats as success -equivalent. `adminNotes` required when `resolution = 'resolved_removed'` (enforced at both Zod and service layers). - -**Full route surface after Phase 4:** - -``` -POST /api/v1/ratings/:ratingId/report -GET /api/v1/admin/report-queue -PATCH /api/v1/admin/reports/:reportId/resolve -``` - ---- - -## Phase 5 — Operations & Admin 🔄 PARTIAL - -### `phase5/cron` ✅ MERGED - -**Files:** `src/cron/listingExpiry.js`, `src/cron/expiryWarning.js`, `src/cron/hardDeleteCleanup.js`. All registered in -`src/server.js` alongside BullMQ workers. node-cron tasks returned from register functions so `server.js` can call -`task.stop()` during graceful shutdown. - -| Job | Schedule | Action | -| ------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| `listingExpiry` | Daily 02:00 | `UPDATE listings SET status='expired'` where `expires_at < NOW() AND status='active'`; bulk-expires pending requests in same transaction | -| `expiryWarning` | Daily 01:00 | Enqueues `listing_expiring` notifications for listings expiring within 7 days; idempotent via 24h notification dedup subquery | -| `hardDeleteCleanup` | Weekly Sunday 04:00 | Hard-deletes rows where `deleted_at < NOW() - INTERVAL '90 days'`; correct dependency-order deletion within one transaction | - -Schedules overridable via env vars: `CRON_LISTING_EXPIRY`, `CRON_EXPIRY_WARNING`, `CRON_HARD_DELETE`. Retention period -overridable via `SOFT_DELETE_RETENTION_DAYS`. - -**`SOFT_DELETE_RETENTION_DAYS` format requirement:** must be a plain decimal integer with no prefix, suffix, or sign — -e.g. `"90"`, not `"90days"`, `"-30"`, or `"1e2"`. Values that fail this check emit a `warn` log and fall back to the -default of `90` days. The strict parse (`/^[0-9]+$/` before any numeric conversion) is intentional: `parseInt()` -silently accepts a leading numeric portion and would use a completely different retention period than the operator -intended without any warning. - -### `phase5/admin` ⏳ NOT STARTED - -Files to create: extensions to `src/routes/admin.js` and new service/controller files for user management, rating -visibility management, email worker, and analytics. - -**What needs building:** - -- `email-queue` BullMQ worker (`startEmailWorker()`) — same factory pattern as notification worker, 5-attempt - exponential backoff -- `GET /admin/users` — paginated list filterable by role + status -- `GET /admin/users/:userId` — full profile + roles + status detail -- `PATCH /admin/users/:userId/status` — suspend | ban | reactivate -- `GET /admin/ratings` — paginated, filterable by `is_visible` -- `PATCH /admin/ratings/:ratingId/visibility` — direct visibility toggle -- `GET /admin/analytics/platform` — DAU, new signups, active listings count, connections formed (computed fresh, no - caching) - ---- - -## Phase 6 — Real-Time ⏳ DEFERRED - -**Entry condition:** Phase 3 polling confirmed working in production with real users. - -Files to create: `src/ws/server.js`, `src/ws/connectionMap.js`, `src/ws/pubsub.js`. WebSocket attached to same HTTP -server instance. Auth via JWT from `?token=` query param on WS handshake. Redis pub/sub channel `notifications:{userId}` -for cross-instance fanout — required for Azure App Service multi-instance deployments. - ---- - -## DB Trigger Reference - -| Trigger | Table | Fires on | Action | -| -------------------------- | ---------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| `update_rating_aggregates` | `ratings` | INSERT, or UPDATE of `overall_score`, `is_visible`, `deleted_at` | Recalculates `average_rating` + `rating_count` on `users` or `properties` for the affected `reviewee_id` | -| `set_updated_at` | All tables with `updated_at` | BEFORE UPDATE | Sets `NEW.updated_at = NOW()` | -| `sync_location_geometry` | `properties`, `listings` | BEFORE INSERT OR UPDATE OF `latitude`, `longitude` | Computes PostGIS `GEOMETRY(POINT, 4326)` from lat/lng decimals | - -Application code never manually updates `average_rating`, `rating_count`, `location`, or `updated_at`. - ---- - -## Future Upgrade Notes - -**View counting on listings:** Current `getListing` increments `views_count` with a detached fire-and-forget -`pool.query` — failures are logged and suppressed. This is correct for reliability but is a direct DB write per view. -When traffic grows, replace with a Redis buffer → batch flush to Postgres pattern. If `views_count` later affects -ranking or monetisation, define approximate vs strongly consistent requirement before redesigning. Entry point: -`src/services/listing.service.js` → `getListing`. diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index f15dd71..0000000 --- a/docs/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Roomies Documentation - -This folder contains the maintained backend documentation for the Roomies API and runtime architecture. - -## Canonical Top-Level Docs - -- `roomies_project_plan.md` — product goals, persona model, and phase status -- `ImplementationPlan.md` — implementation inventory, conventions, route surface, and codebase status -- `API.md` — API entrypoint, transport rules, shared response conventions, and links to feature docs -- `TechStack.md` — runtime, database, queue, storage, validation, and operational decisions -- `Deployment.md` — Azure-focused deployment and rollout guidance - -## Detailed API Docs - -Feature-level API documentation lives in `docs/api/`. - -- `api/conventions.md` — shared response envelopes, auth failures, pagination, and example conventions -- `api/auth.md` — registration, login, refresh, OTP, sessions, logout, and Google OAuth flows -- `api/profiles-and-contact.md` — student and PG owner profiles, contact reveal, and verification document submission -- `api/properties.md` — property CRUD and ownership rules -- `api/listings.api.md` — listing search, CRUD, lifecycle changes, saved listings, preferences, photo flows, and - listing-scoped interests -- `api/interests.md` — interest request creation, dashboards, and transition state machine -- `api/connections.md` — connection dashboard, detail, and two-sided confirmation -- `api/notifications.md` — notification feed, unread count, and mark-read modes -- `api/ratings-and-reports.md` — ratings, public reputation views, and rating report submission -- `api/admin.md` — verification queue, report moderation, user lifecycle actions, rating visibility controls, and - platform analytics -- `api/health.md` — dependency health probe behavior - -## Maintenance Rules - -- Treat the route files in `src/routes/` as the authoritative endpoint list. -- Treat the validators in `src/validators/` as the authoritative contract for params, query, and body shape. -- Treat service-layer `AppError` branches as the authoritative source for documented business-rule failures. -- Keep `API.md` as the front door and keep the feature-level detail inside `docs/api/`. diff --git a/docs/TechStack.md b/docs/TechStack.md deleted file mode 100644 index e848f10..0000000 --- a/docs/TechStack.md +++ /dev/null @@ -1,291 +0,0 @@ -# Roomies — Tech Stack Reference - -**Runtime:** Node.js + Express, JavaScript ES modules (`"type": "module"`). No TypeScript. - ---- - -## Core Dependencies - -| Category | Package | Version | Role | -| ---------------- | --------------------- | -------- | -------------------------------------------------------------------------- | -| HTTP | `express` | ^5.2.1 | Server, routing, middleware pipeline | -| DB driver | `pg` (node-postgres) | ^8.20.0 | Direct PostgreSQL connection pool — all queries are raw parameterised SQL | -| Validation | `zod` | ^4.3.6 | HTTP boundary + env var validation at startup | -| Auth | `jsonwebtoken` | ^9.0.3 | JWT issuance and verification | -| Auth | `bcryptjs` | ^3.0.3 | Password hashing (10 rounds) | -| Auth | `google-auth-library` | ^10.6.2 | Google OAuth ID token verification via `OAuth2Client.verifyIdToken()` | -| Cache | `redis` | ^5.11.0 | Official node-redis v5 — used for sessions, OTP TTL, rate limiting, BullMQ | -| Queue | `bullmq` | ^5.71.0 | Job queues backed by Redis | -| File upload | `multer` | ^2.1.1 | Multipart parsing, MIME type + extension cross-check, 10MB limit | -| Image processing | `sharp` | ^0.34.5 | Resize to 1200px max, WebP quality 80, strip EXIF | -| Email | `nodemailer` | ^8.0.3 | SMTP transport — Ethereal in local dev, Brevo relay in production | -| Scheduling | `node-cron` | ^4.2.1 | Time-triggered cron jobs within the main Express process | -| Logging | `pino` | ^10.3.1 | Structured async JSON logging | -| Logging | `pino-http` | ^11.0.0 | Request/response logging middleware | -| Logging | `pino-pretty` | ^13.1.3 | Human-readable dev output (not used in production) | -| Security | `helmet` | ^8.1.0 | Security headers | -| Security | `cors` | ^2.8.6 | CORS with `credentials: true` | -| Rate limiting | `express-rate-limit` | ^8.3.1 | Per-route rate limiters | -| Rate limiting | `rate-limit-redis` | ^4.3.1 | Redis-backed store for distributed rate limiting | -| Cookies | `cookie-parser` | ^1.4.7 | Parses `req.cookies` | -| Cloud storage | `@azure/storage-blob` | ^12.31.0 | Azure Blob Storage adapter | -| Dev | `nodemon` | ^3.1.14 | Hot reload, env-file aware | -| Dev | `dotenv` | ^17.3.1 | `.env` file loading | - ---- - -## Database — PostgreSQL 16 + PostGIS - -**Connection:** Single `pg.Pool` singleton in `src/db/client.js`. Max 20 connections, idle timeout 30s, connect timeout -5s. Exported as `pool` and `query`. Pool errors on fatal codes (`ECONNREFUSED`, `ENOTFOUND`) call `process.exit(1)`; -recoverable idle-client errors are logged and the pool self-heals. - -**Query organisation:** Reusable cross-service queries live in `src/db/utils/` — one file per domain (`auth.js`, -`institutions.js`, `pgOwner.js`, `compatibility.js`). One-off or evolving queries live inline in service files. All -named utility functions accept an optional `client` parameter (defaulting to `pool`) so they can participate in -transactions without special wiring. - -**Transactions:** Manual `BEGIN / COMMIT / ROLLBACK` using a checked-out `PoolClient`. Always `client.release()` in -`finally`. Services that need atomicity check out their own client — never pass a pool reference where a transaction -client is needed. - -**PostGIS:** `GEOMETRY(POINT, 4326)` columns on `properties` and `listings`. GiST indexes on both for spatial queries. -The `sync_location_geometry` trigger auto-computes the geometry column from `latitude`/`longitude` on every INSERT or -UPDATE of those columns — application code only writes lat/lng decimals. Proximity search uses `ST_DWithin` with -geography cast (meters, handles earth curvature) inline in `listing.service.js`. - -**Triggers (all defined in `roomies_db_setup.sql`):** - -- `set_updated_at` — BEFORE UPDATE on every table with `updated_at`; sets `NEW.updated_at = NOW()` -- `sync_location_geometry` — BEFORE INSERT OR UPDATE OF `latitude`, `longitude` on `properties` and `listings`; computes - PostGIS geometry from `ST_MakePoint(longitude, latitude)` with SRID 4326 -- `update_rating_aggregates` — AFTER INSERT OR UPDATE OF `overall_score`, `is_visible`, `deleted_at` on `ratings`; - recalculates `AVG(overall_score)` and `COUNT(*)` for the affected reviewee and writes back to `users.average_rating` / - `properties.average_rating` - -**Extensions required:** `postgis` (spatial types and functions), `pgcrypto` (`gen_random_uuid()` as primary key default -on every table). - -**Key indexes:** - -- `idx_listings_city_lower` — functional index on `LOWER(city)` WHERE `status='active' AND deleted_at IS NULL`; used by - `LOWER(l.city) LIKE LOWER($n)` in search -- `idx_notifications_recipient_unread` — partial index on `(recipient_id, created_at DESC)` WHERE `is_read = FALSE`; - keeps unread count queries O(log N) -- `idx_connections_interest_request_id` — unique partial index on `interest_request_id` WHERE - `interest_request_id IS NOT NULL AND deleted_at IS NULL`; prevents duplicate connections per interest request -- `idx_interest_requests_no_duplicates` — unique partial index on `(sender_id, listing_id)` WHERE - `status IN ('pending','accepted') AND deleted_at IS NULL`; prevents duplicate concurrent interest requests -- `idx_listing_photos_one_cover` — unique partial index on `listing_id` WHERE `is_cover = TRUE AND deleted_at IS NULL`; - enforces at most one cover photo per listing - ---- - -## Redis — `redis` v5 - -**Client:** Singleton in `src/cache/client.js`. Exponential backoff reconnect strategy, max 10 attempts, max 3s delay. -Connection established via `connectRedis()` called from `server.js`. Shutdown via `redis.close()` (not `redis.quit()` — -deprecated in v5). - -**Usage by subsystem:** - -| Key pattern | TTL | Used by | -| --------------------------------- | ------------------------------------- | ----------------------------------------------------------------- | -| `refreshToken:{userId}:{sid}` | `JWT_REFRESH_EXPIRES_IN` (default 7d) | `auth.service.js` — per-session refresh token storage | -| `userSessions:{userId}` | — | Redis sorted set; score = expiry timestamp; members = sid strings | -| `otp:{userId}` | 600s | `auth.service.js` — hashed OTP storage | -| `otpAttempts:{userId}` | 600s | `auth.service.js` — per-user OTP attempt counter | -| `ipAttempts:{ip}` | 900s | `auth.service.js` — IP-level OTP rate limiter | -| `rl:auth:{ip}` | 900s | `rateLimiter.js` — auth endpoint rate limiter | -| `rl:otp:{ip}` | 900s | `rateLimiter.js` — OTP send rate limiter | -| `contactRevealAnon:{fingerprint}` | 30d | `contactRevealGate.js` — guest reveal quota counter | -| BullMQ internal keys | — | Queue metadata, job payloads, worker locking | - -**Per-session refresh token rotation:** `casRefreshToken()` in `auth.service.js` uses a Lua script for atomic -compare-and-swap — reads current token, compares with expected old token, updates to new token only if match. Prevents -two concurrent refresh requests from both succeeding with the same token. - -**Dedicated rate-limit client:** `rateLimiter.js` creates its own `redis` client (not the shared singleton) with a -separate, modest reconnect strategy. `passOnStoreError: true` on all limiters — Redis unavailability degrades to no rate -limiting rather than blocking all requests. - ---- - -## BullMQ — Job Queues - -**Connection:** Each queue and worker uses a connection config parsed from `config.REDIS_URL` (supports `rediss://` for -Azure Cache for Redis with TLS). Queue instances are managed by the singleton registry in `src/workers/queue.js` — -`getQueue(name)` returns a cached instance, preventing duplicate Redis connections. - -**Redis DB path validation:** The DB index is extracted from the URL pathname (e.g. `/1` for DB 1). The segment after -stripping the leading `/` must be a plain decimal integer matching `/^[0-9]+$/`. A malformed segment like `/1abc` would -previously have been silently parsed as DB `1` via `parseInt()`; it now emits a `warn` log (with credentials redacted) -and falls back to DB `0`. An empty or root-only pathname (`/` or absent) is treated as DB `0` without any warning, which -is the normal case for most deployments. - -**Queues and workers:** - -| Queue name | Worker file | Concurrency | Used for | -| ----------------------- | ----------------------- | ------------- | --------------------------------------------------------------------- | -| `media-processing` | `mediaProcessor.js` | 1 (CPU-bound) | Sharp image compression, storage write, DB URL update, cover election | -| `notification-delivery` | `notificationWorker.js` | 10 (pure I/O) | Notification INSERT with idempotency_key ON CONFLICT DO NOTHING | - -**Job options (notification jobs):** `attempts: 5`, `backoff: { type: 'exponential', delay: 2000 }`, -`removeOnComplete: 100`, `removeOnFail: 200`. - -**Enqueueing:** All notification enqueues go through `enqueueNotification()` in `src/workers/notificationQueue.js`. This -is a fire-and-forget wrapper — it catches Redis errors and logs them without throwing, so a notification queue failure -never crashes a service call. - -**Startup/shutdown:** Both workers are started in `server.js` after Redis connects. On shutdown, `mediaWorker.close()` -and `notificationWorker.close()` are called before `closeAllQueues()` — workers drain in-flight jobs before the queue -connections are torn down. - ---- - -## Authentication — JWT + bcryptjs + Google OAuth - -**Access tokens:** Signed with `config.JWT_SECRET` (min 32 chars). Payload: `{ userId, email, roles, sid }`. TTL from -`config.JWT_EXPIRES_IN` (default `15m`). `parseTtlSeconds()` normalises string TTLs (`"15m"`, `"900"`, `900`) to numeric -seconds before passing to `jwt.sign` — avoids the jsonwebtoken quirk where digit-only strings are interpreted as -milliseconds. - -**Refresh tokens:** Signed with `config.JWT_REFRESH_SECRET`. Payload: `{ userId, sid }`. TTL from -`config.JWT_REFRESH_EXPIRES_IN` (default `7d`). Stored in Redis at `refreshToken:{userId}:{sid}` keyed by session ID. -Session IDs tracked in a sorted set `userSessions:{userId}` with expiry timestamp as score. - -**Token delivery:** Every auth response (register, login, refresh, OAuth) sets both tokens as HttpOnly cookies AND -includes them in the JSON body. `X-Client-Transport: bearer` header from Android clients signals that body tokens should -be included; browser clients receive a safe body (no raw tokens) and use cookies. - -**Silent refresh:** In `authenticate.js`, if the access token is expired AND it came from a cookie (not a Bearer -header), `attemptSilentRefresh()` is called. It validates the refresh token cookie, checks Redis, loads fresh user state -from DB, signs a new access token, CAS-rotates the refresh token, and sets new cookies — all transparently, without the -request failing. - -**Google OAuth:** `googleOAuthClient.verifyIdToken()` validates the ID token signature, expiry, and audience against -`config.GOOGLE_CLIENT_ID`. Three branching paths in `googleOAuth()`: returning user (found by `google_id`), account -linking (found by email, `google_id IS NULL`), new registration (institution domain transaction, -`password_hash = NULL`). - -**Password hashing:** bcryptjs, 10 rounds. `DUMMY_HASH` = pre-computed hash of `"dummy"` at 10 rounds, hardcoded in -`auth.service.js`. Used for constant-time comparison on unknown email login attempts to prevent user enumeration via -timing. - ---- - -## Validation — Zod v4 - -**HTTP boundary:** `validate(schema)` middleware in `src/middleware/validate.js`. Parses `{ body, query, params }` via -`schema.safeParse()`. On success, writes `result.data` back to `req.body`, `req.query`, `req.params` — downstream -handlers always see coerced types and defaults, not raw strings. On failure, passes `ZodError` to `next(err)` — global -error handler formats it as `{ status: 'error', errors: [{ field, message }] }` using `error.issues`. - -**Env validation:** `src/config/env.js` calls `envSchema.safeParse(process.env)` at import time. Any missing or -wrong-typed variable causes `process.exit(1)` with a clear per-variable error message. `ALLOWED_ORIGINS` is pre-split -into an array at startup. - -**Shared pagination schema:** `src/validators/pagination.validators.js` exports -`buildKeysetPaginationQuerySchema(extraFields)` and `keysetPaginationQuerySchema`. All paginated endpoints compose from -these to keep cursor validation consistent. - ---- - -## Storage — LocalDiskAdapter / AzureBlobAdapter - -**Interface:** Both adapters implement `upload(buffer, listingId, filename) → url` and `delete(url)`. Selected by -`config.STORAGE_ADAPTER` (`'local'` or `'azure'`). Singleton created in `src/storage/index.js` and imported by -`photo.service.js` and `mediaProcessor.js`. - -**LocalDiskAdapter** (`src/storage/adapters/localDisk.js`): Writes WebP buffers to -`uploads/listings/{listingId}/{filename}`. Returns root-relative URL `/uploads/...`. `fs.mkdir({ recursive: true })` on -every upload (idempotent). `fs.unlink` on delete — swallows `ENOENT`. Express serves `/uploads` statically in dev via -`app.use('/uploads', express.static('uploads'))`. - -**AzureBlobAdapter** (`src/storage/adapters/azureBlob.js`): Uses `@azure/storage-blob` -`BlobServiceClient.fromConnectionString()`. Upload uses `AbortController` with 30s timeout passed as `abortSignal` to -`uploadData()` — cancels the in-flight HTTP request, not just the JS promise. Delete strips query-string and fragment -from the stored URL before extracting blob path (handles SAS tokens). `blobContentType: 'image/webp'`, -`blobCacheControl: 'public, max-age=31536000, immutable'` set on upload. - ---- - -## Logging — Pino - -**Config:** `src/logger/index.js`. Level `debug` in development, `info` in production. `pino-pretty` transport only in -non-production. `pino-http` middleware added in `app.js` for request/response logging. - -**Convention:** `logger.info({ userId, listingId }, 'message')` — structured object first, message string second. Never -`console.log`. All service files and workers import `logger` directly. - ---- - -## Rate Limiting — express-rate-limit + rate-limit-redis - -Two limiters defined in `src/middleware/rateLimiter.js`: - -- `authLimiter` — 10 requests / 15 min window, applied to `/auth/register`, `/auth/login`, `/auth/refresh`, - `/auth/google/callback` -- `otpLimiter` — 5 requests / 15 min window, applied to `/auth/otp/send` - -Both use `RedisStore` from `rate-limit-redis` with a dedicated Redis client (separate from the session client). -`passOnStoreError: true` on both — Redis unavailability degrades to no rate limiting, not a 500. -`standardHeaders: true`, `legacyHeaders: false`. Key defaults to `req.ip` (accurate behind proxy because -`app.set('trust proxy', 1)` in production). - ---- - -## Cron Jobs — node-cron - -All jobs registered in `server.js` after Redis + DB are confirmed healthy. Each `registerXxxCron()` function returns a -`ScheduledTask` stored in a `cronTasks` array for clean `task.stop()` on shutdown. - -| File | Default schedule | Env override | -| ------------------------------- | ---------------- | --------------------- | -| `src/cron/listingExpiry.js` | `0 2 * * *` | `CRON_LISTING_EXPIRY` | -| `src/cron/expiryWarning.js` | `0 1 * * *` | `CRON_EXPIRY_WARNING` | -| `src/cron/hardDeleteCleanup.js` | `0 4 * * 0` | `CRON_HARD_DELETE` | - -Retention period for `hardDeleteCleanup` overridable via `SOFT_DELETE_RETENTION_DAYS` (default `90`). - -**`SOFT_DELETE_RETENTION_DAYS` format requirement:** must be a plain decimal integer with no prefix, suffix, or sign — -e.g. `"90"`, not `"90days"`, `"-30"`, or `"1e2"`. Values that fail this check emit a `warn` log and fall back to the -default of `90` days. The strict parse (`/^[0-9]+$/` before any numeric conversion) is intentional: `parseInt()` -silently accepts a leading numeric portion and would use a completely different retention period than the operator -intended without any warning. - ---- - -## File Upload — Multer - -Config in `src/middleware/upload.js`. `multer.diskStorage` writes to `uploads/staging/` with a UUID filename. -`fileFilter` validates `file.mimetype` against an allowed set (`image/jpeg`, `image/png`, `image/webp`) and cross-checks -the file extension against the expected extensions for that MIME type. `limits.fileSize = 10MB`, `limits.files = 1`. -`MAX_UPLOAD_SIZE_BYTES` and `UPLOAD_FIELD_NAME` are exported from `src/config/constants.js`. - -**Upload flow:** Multer writes the staged file → `enqueuePhotoUpload()` creates the DB row with placeholder URL and -enqueues BullMQ job → response sent → worker picks up job → Sharp processes → storage write → DB URL updated. - ---- - -## Infrastructure — Microsoft Azure (Production) - -| Service | Usage | -| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| Azure App Service | Node.js hosting — potentially multi-instance (requires Redis pub/sub for Phase 6 WebSocket) | -| Azure Database for PostgreSQL Flexible Server | Managed PostgreSQL 16 with native PostGIS support | -| Azure Cache for Redis | Managed Redis — connection via `rediss://` (TLS), parsed from `REDIS_URL` | -| Azure Blob Storage | Photo storage — `AZURE_STORAGE_CONNECTION_STRING` + `AZURE_STORAGE_CONTAINER` | -| Brevo SMTP Relay | Production OTP email delivery — `EMAIL_PROVIDER=brevo` + `BREVO_SMTP_LOGIN` + `BREVO_SMTP_KEY` + `BREVO_SMTP_FROM` | - ---- - -## Local Dev - -| Service | Setup | -| ----------------------- | ---------------------------------------------------------------------------------------------- | -| PostgreSQL 16 + PostGIS | Host-installed — no Docker | -| Redis | Host-installed — no Docker | -| SMTP | Ethereal Mail (fake SMTP, `EMAIL_PROVIDER=ethereal`) — preview URL logged to console | -| Storage | Local disk (`STORAGE_ADAPTER=local`) — files written to `uploads/listings/`, served by Express | -| Email | Nodemailer with provider switch: Ethereal for testing, Brevo for real delivery | diff --git a/docs/api/auth.md b/docs/api/auth.md deleted file mode 100644 index fec1aa5..0000000 --- a/docs/api/auth.md +++ /dev/null @@ -1,959 +0,0 @@ -# Auth API - -This document covers account creation, login, token refresh, OTP verification, session management, logout, and Google OAuth. - -Shared response and error conventions live in [conventions.md](./conventions.md). - -## Transport Summary - -- Browser clients should use cookie mode. -- Mobile and API clients should send `X-Client-Transport: bearer` to receive tokens in the JSON body. -- Protected routes accept cookie auth and bearer auth unless otherwise noted. -- Silent refresh only happens when the expired access token came from a cookie. - -## `POST /auth/register` - -Creates a new student or PG owner account and immediately starts a signed-in session. - -### Request Contract - -- Auth required: No -- Rate limited: Yes, via the auth limiter -- Headers: - - Optional: `X-Client-Transport: bearer` -- Body: - -```json -{ - "email": "priya@iitb.ac.in", - "password": "Pass1234", - "role": "student", - "fullName": "Priya Sharma" -} -``` - -PG owner registration adds `businessName`: - -```json -{ - "email": "owner@sunrisepg.in", - "password": "Owner1234", - "role": "pg_owner", - "fullName": "Rohan Mehta", - "businessName": "Sunrise PG" -} -``` - -### Scenario: Student registers with institution email and is auto-verified - -Request: - -```json -{ - "email": "priya@iitb.ac.in", - "password": "Pass1234", - "role": "student", - "fullName": "Priya Sharma" -} -``` - -Status: `201` - -Cookie-mode response body: - -```json -{ - "status": "success", - "data": { - "user": { - "userId": "11111111-1111-4111-8111-111111111111", - "email": "priya@iitb.ac.in", - "roles": ["student"], - "isEmailVerified": true - }, - "sid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" - } -} -``` - -Explanation: the service matches the email domain to a known institution and marks the user verified inside the registration transaction. - -### Scenario: Student registers with non-institution email and must verify later - -Request: - -```json -{ - "email": "arjun@roomies-test.in", - "password": "Pass1234", - "role": "student", - "fullName": "Arjun Rao" -} -``` - -Status: `201` - -Bearer-mode response body: - -```json -{ - "status": "success", - "data": { - "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access", - "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh", - "sid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "user": { - "userId": "11111111-1111-4111-8111-111111111111", - "email": "arjun@roomies-test.in", - "roles": ["student"], - "isEmailVerified": false - } - } -} -``` - -Explanation: the account is created and signed in, but the user must later call the OTP endpoints to verify their email. - -### Scenario: PG owner registers successfully - -Request: - -```json -{ - "email": "owner@sunrisepg.in", - "password": "Owner1234", - "role": "pg_owner", - "fullName": "Rohan Mehta", - "businessName": "Sunrise PG" -} -``` - -Status: `201` - -```json -{ - "status": "success", - "data": { - "user": { - "userId": "22222222-2222-4222-8222-222222222222", - "email": "owner@sunrisepg.in", - "roles": ["pg_owner"], - "isEmailVerified": false - }, - "sid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" - } -} -``` - -### Scenario: PG owner registration fails because `businessName` is missing - -Status: `400` - -```json -{ - "status": "error", - "message": "Business name is required for PG owner registration" -} -``` - -### Scenario: validation failure - -Request: - -```json -{ - "email": "not-an-email", - "password": "short", - "role": "student", - "fullName": "P" -} -``` - -Status: `400` - -```json -{ - "status": "error", - "message": "Validation failed", - "errors": [ - { - "field": "body.email", - "message": "Must be a valid email address" - }, - { - "field": "body.password", - "message": "Password must be at least 8 characters" - }, - { - "field": "body.fullName", - "message": "Full name must be at least 2 characters" - } - ] -} -``` - -### Scenario: email already exists - -Status: `409` - -```json -{ - "status": "error", - "message": "An account with this email already exists" -} -``` - -## `POST /auth/login` - -Authenticates an existing email/password account and creates a new session. - -### Request - -```json -{ - "email": "priya@iitb.ac.in", - "password": "Pass1234" -} -``` - -### Scenario: browser login success - -Status: `200` - -```json -{ - "status": "success", - "data": { - "user": { - "userId": "11111111-1111-4111-8111-111111111111", - "email": "priya@iitb.ac.in", - "roles": ["student"], - "isEmailVerified": true - }, - "sid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" - } -} -``` - -Also sets `Set-Cookie` headers for `accessToken` and `refreshToken`. - -### Scenario: bearer-client login success - -Headers: - -```http -X-Client-Transport: bearer -``` - -Status: `200` - -```json -{ - "status": "success", - "data": { - "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access", - "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh", - "sid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "user": { - "userId": "11111111-1111-4111-8111-111111111111", - "email": "priya@iitb.ac.in", - "roles": ["student"], - "isEmailVerified": true - } - } -} -``` - -### Scenario: wrong password - -Status: `401` - -```json -{ - "status": "error", - "message": "Invalid credentials" -} -``` - -### Scenario: account inactive - -Status: `401` - -```json -{ - "status": "error", - "message": "Account is suspended" -} -``` - -The same branch pattern also applies to `banned` and `deactivated`. - -## `POST /auth/refresh` - -Rotates the refresh token and issues a fresh access token. - -### Request Contract - -- Auth required: No -- Rate limited: Yes -- Body: optional when using the refresh token cookie - -Bearer/mobile request example: - -```json -{ - "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh" -} -``` - -### Scenario: refresh succeeds in cookie mode - -Status: `200` - -```json -{ - "status": "success", - "data": { - "user": { - "userId": "11111111-1111-4111-8111-111111111111", - "email": "priya@iitb.ac.in", - "roles": ["student"], - "isEmailVerified": true - }, - "sid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" - } -} -``` - -Explanation: the controller reads the refresh token from the cookie and the response rotates the cookies as well. - -### Scenario: refresh succeeds for bearer client - -Headers: - -```http -X-Client-Transport: bearer -``` - -Request: - -```json -{ - "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh" -} -``` - -Status: `200` - -```json -{ - "status": "success", - "data": { - "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.new-access", - "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.new-refresh", - "sid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "user": { - "userId": "11111111-1111-4111-8111-111111111111", - "email": "priya@iitb.ac.in", - "roles": ["student"], - "isEmailVerified": true - } - } -} -``` - -### Scenario: refresh token missing - -Status: `401` - -```json -{ - "status": "error", - "message": "Refresh token is required" -} -``` - -### Scenario: refresh token revoked, invalid, or superseded - -Status: `401` - -```json -{ - "status": "error", - "message": "Refresh token is invalid or has been revoked" -} -``` - -### Scenario: account is no longer active during refresh - -Status: `401` - -```json -{ - "status": "error", - "message": "Account inactive" -} -``` - -## `POST /auth/logout` - -Revokes a session using the refresh token from the body or cookie. - -### Request - -Cookie-based browser request can send no body. - -Bearer/mobile request example: - -```json -{ - "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh" -} -``` - -### Scenario: logout succeeds - -Status: `200` - -```json -{ - "status": "success", - "message": "Logged out" -} -``` - -### Scenario: refresh token missing - -Status: `401` - -```json -{ - "status": "error", - "message": "Refresh token is required" -} -``` - -### Scenario: session already revoked - -Status: `401` - -```json -{ - "status": "error", - "message": "Session not found or already revoked" -} -``` - -## `POST /auth/logout/current` - -Revokes the currently authenticated session only. - -### Scenario: current-session logout succeeds - -Status: `200` - -```json -{ - "status": "success", - "message": "Logged out" -} -``` - -### Scenario: refresh token does not belong to current user - -Status: `403` - -```json -{ - "status": "error", - "message": "Refresh token does not belong to current user" -} -``` - -### Scenario: token session does not match authenticated session - -Status: `403` - -```json -{ - "status": "error", - "message": "Refresh token session does not match the authenticated session" -} -``` - -## `POST /auth/logout/all` - -Revokes all sessions for the authenticated user. - -### Scenario: success - -Status: `200` - -```json -{ - "status": "success", - "message": "Logged out from all sessions" -} -``` - -## `GET /auth/sessions` - -Lists active sessions for the authenticated user. - -### Scenario: success - -Status: `200` - -```json -{ - "status": "success", - "data": [ - { - "sid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "isCurrent": true, - "expiresAt": "2026-04-18T09:45:00.000Z", - "issuedAt": "2026-04-11T09:45:00.000Z" - }, - { - "sid": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", - "isCurrent": false, - "expiresAt": "2026-04-17T18:00:00.000Z", - "issuedAt": "2026-04-10T18:00:00.000Z" - } - ] -} -``` - -## `DELETE /auth/sessions/:sid` - -Revokes a specific session ID. - -### Request Contract - -- Auth required: Yes -- Path param: - - `sid` must be a UUID - -### Scenario: revoke another device session - -Status: `200` - -```json -{ - "status": "success", - "message": "Session revoked" -} -``` - -### Scenario: revoke current session - -Status: `200` - -```json -{ - "status": "success", - "message": "Session revoked" -} -``` - -The controller also clears auth cookies when the revoked `sid` matches the current session. - -### Scenario: validation failure for malformed sid - -Status: `400` - -```json -{ - "status": "error", - "message": "Validation failed", - "errors": [ - { - "field": "params.sid", - "message": "sid must be a valid UUID" - } - ] -} -``` - -### Scenario: session not found - -Status: `404` - -```json -{ - "status": "error", - "message": "Session not found" -} -``` - -## `POST /auth/otp/send` - -Sends an email OTP to the authenticated user. - -### Request Contract - -- Auth required: Yes -- Rate limited: Yes, stricter OTP limiter -- Body: none - -### Scenario: OTP send succeeds - -Status: `200` - -```json -{ - "status": "success", - "message": "OTP sent to your email" -} -``` - -### Scenario: email already verified - -Status: `409` - -```json -{ - "status": "error", - "message": "Email is already verified" -} -``` - -### Scenario: email delivery fails downstream - -Status: `502` - -```json -{ - "status": "error", - "message": "Failed to send OTP email — try again shortly" -} -``` - -## `POST /auth/otp/verify` - -Verifies the latest OTP for the authenticated user and marks the account email as verified. - -### Request - -```json -{ - "otp": "123456" -} -``` - -### Scenario: verification succeeds - -Status: `200` - -```json -{ - "status": "success", - "message": "Email verified successfully" -} -``` - -### Scenario: wrong OTP with attempts remaining - -Status: `400` - -```json -{ - "status": "error", - "message": "Incorrect OTP — 4 attempts remaining" -} -``` - -### Scenario: too many incorrect attempts - -Status: `429` - -```json -{ - "status": "error", - "message": "Too many incorrect attempts — request a new OTP" -} -``` - -### Scenario: OTP expired or never sent - -Status: `400` - -```json -{ - "status": "error", - "message": "OTP has expired or was never sent — request a new one" -} -``` - -### Scenario: IP throttle trips - -Status: `429` - -```json -{ - "status": "error", - "message": "Too many OTP verification attempts from this IP — please wait 15 minutes" -} -``` - -### Scenario: infrastructure fail-closed on IP throttle - -Status: `429` - -```json -{ - "status": "error", - "message": "OTP verification is temporarily unavailable" -} -``` - -## `GET /auth/me` - -Returns the authenticated identity currently attached to the access token. - -### Scenario: success - -Status: `200` - -```json -{ - "status": "success", - "data": { - "userId": "11111111-1111-4111-8111-111111111111", - "email": "priya@iitb.ac.in", - "roles": ["student"], - "sid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "isEmailVerified": true - } -} -``` - -## `POST /auth/google/callback` - -Accepts a Google ID token issued on the client, verifies it server-side, then either signs in an existing user, links an existing email/password account, or creates a new account. - -### Request Contract - -- Auth required: No -- Rate limited: Yes -- Body: - -```json -{ - "idToken": "google-id-token-from-client", - "role": "student", - "fullName": "Priya Sharma", - "businessName": "Sunrise PG" -} -``` - -`role`, `fullName`, and `businessName` are only required for brand-new registrations. - -### Scenario: returning linked Google user - -Status: `200` - -```json -{ - "status": "success", - "data": { - "user": { - "userId": "11111111-1111-4111-8111-111111111111", - "email": "priya@iitb.ac.in", - "roles": ["student"], - "isEmailVerified": true - }, - "sid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" - } -} -``` - -### Scenario: account linking by email - -Status: `200` - -```json -{ - "status": "success", - "data": { - "user": { - "userId": "11111111-1111-4111-8111-111111111111", - "email": "priya@iitb.ac.in", - "roles": ["student"], - "isEmailVerified": true - }, - "sid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" - } -} -``` - -Explanation: the email/password account already existed, had no `google_id`, and is now linked. - -### Scenario: brand-new student registration via Google - -Request: - -```json -{ - "idToken": "google-id-token-from-client", - "role": "student", - "fullName": "Priya Sharma" -} -``` - -Status: `200` - -```json -{ - "status": "success", - "data": { - "user": { - "userId": "11111111-1111-4111-8111-111111111111", - "email": "priya@iitb.ac.in", - "roles": ["student"], - "isEmailVerified": true - }, - "sid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" - } -} -``` - -### Scenario: brand-new PG owner registration via Google - -Request: - -```json -{ - "idToken": "google-id-token-from-client", - "role": "pg_owner", - "fullName": "Rohan Mehta", - "businessName": "Sunrise PG" -} -``` - -Status: `200` - -```json -{ - "status": "success", - "data": { - "user": { - "userId": "22222222-2222-4222-8222-222222222222", - "email": "owner@sunrisepg.in", - "roles": ["pg_owner"], - "isEmailVerified": true - }, - "sid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" - } -} -``` - -### Scenario: role missing for new Google registration - -Status: `400` - -```json -{ - "status": "error", - "message": "Role is required for new account registration via Google" -} -``` - -### Scenario: full name missing for new Google registration - -Status: `400` - -```json -{ - "status": "error", - "message": "Full name is required for new account registration" -} -``` - -### Scenario: business name missing for new PG owner registration - -Status: `400` - -```json -{ - "status": "error", - "message": "Business name is required for PG owner registration" -} -``` - -### Scenario: Google OAuth not configured on server - -Status: `503` - -```json -{ - "status": "error", - "message": "Google OAuth is not configured on this server" -} -``` - -### Scenario: invalid or expired Google token - -Status: `401` - -```json -{ - "status": "error", - "message": "Invalid or expired Google token" -} -``` - -### Scenario: Google email is not verified - -Status: `400` - -```json -{ - "status": "error", - "message": "Google account does not have a verified email address" -} -``` - -### Scenario: Google account already linked elsewhere - -Status: `409` - -```json -{ - "status": "error", - "message": "This Google account is already linked to another user" -} -``` - -### Scenario: local account already linked to a different Google account - -Status: `409` - -```json -{ - "status": "error", - "message": "This account is already linked to a different Google account" -} -``` - -## Auth Scenario Matrix - -| Scenario | Client sends | Service branch | Status | Response pattern | -| --- | --- | --- | --- | --- | -| Student institution signup | register body | institution domain match | `201` | session created, `isEmailVerified: true` | -| Student non-institution signup | register body | no institution match | `201` | session created, `isEmailVerified: false` | -| PG owner signup missing business name | register body | service cross-field rule | `400` | operational error | -| Browser login | login body | cookie mode | `200` | safe body + cookies | -| Mobile login | login body + bearer transport header | bearer mode | `200` | raw tokens in body | -| Refresh missing token | no body and no cookie | controller guard | `401` | operational error | -| OTP wrong but not exhausted | `otp` body | compare fails | `400` | attempts remaining message | -| OTP exhausted | repeated wrong OTP | attempt ceiling hit | `429` | request new OTP | -| Google return user | `idToken` only | find by `google_id` | `200` | session created | -| Google first-time user | `idToken` plus onboarding fields | new account path | `200` | session created | - -## Integrator Notes - -- Treat the auth response body shape as transport-dependent. -- Do not expect silent refresh for bearer tokens. -- Logout is refresh-token based; it is intentionally available even when the access token is already expired. -- Google callback is not an OAuth redirect endpoint. The client obtains the Google ID token and POSTs it here. diff --git a/docs/api/connections.md b/docs/api/connections.md deleted file mode 100644 index d2c36f6..0000000 --- a/docs/api/connections.md +++ /dev/null @@ -1,173 +0,0 @@ -# Connections API - -Connections are created internally when an interest request is accepted. API consumers never create connections directly. - -## `GET /connections/me` - -Returns the authenticated user's connection dashboard. - -### Request Contract - -- Auth required: Yes -- Query params: - - `confirmationStatus` - - `connectionType` - - `limit` - - `cursorTime` - - `cursorId` - -### Scenario: fetch my connections with filters - -Request: - -```http -GET /api/v1/connections/me?confirmationStatus=pending&connectionType=pg_stay -``` - -Status: `200` - -```json -{ - "status": "success", - "data": { - "items": [ - { - "connectionId": "77777777-7777-4777-8777-777777777777", - "connectionType": "pg_stay", - "confirmationStatus": "pending", - "initiatorConfirmed": true, - "counterpartConfirmed": false, - "startDate": null, - "endDate": null, - "createdAt": "2026-04-11T11:40:00.000Z", - "updatedAt": "2026-04-11T11:40:00.000Z", - "listing": { - "listingId": "55555555-5555-4555-8555-555555555555", - "title": "Single room in verified PG", - "city": "Pune", - "rentPerMonth": 9500, - "listingType": "pg_room" - }, - "otherParty": { - "userId": "22222222-2222-4222-8222-222222222222", - "fullName": "Rohan Mehta", - "profilePhotoUrl": null, - "averageRating": 4.5 - } - } - ], - "nextCursor": null - } -} -``` - -## `GET /connections/:connectionId` - -Returns detail for a single connection. - -### Scenario: one of the parties fetches connection detail - -Status: `200` - -```json -{ - "status": "success", - "data": { - "connectionId": "77777777-7777-4777-8777-777777777777", - "connectionType": "pg_stay", - "confirmationStatus": "pending", - "initiatorConfirmed": true, - "counterpartConfirmed": false, - "startDate": null, - "endDate": null, - "interestRequestId": "66666666-6666-4666-8666-666666666666", - "createdAt": "2026-04-11T11:40:00.000Z", - "updatedAt": "2026-04-11T11:40:00.000Z", - "listing": { - "listingId": "55555555-5555-4555-8555-555555555555", - "title": "Single room in verified PG", - "city": "Pune", - "rentPerMonth": 9500, - "roomType": "single", - "listingType": "pg_room" - }, - "otherParty": { - "userId": "22222222-2222-4222-8222-222222222222", - "fullName": "Rohan Mehta", - "profilePhotoUrl": null, - "averageRating": 4.5, - "ratingCount": 12 - } - } -} -``` - -### Scenario: outsider tries to fetch connection - -Status: `404` - -```json -{ - "status": "error", - "message": "Connection not found" -} -``` - -This is privacy-preserving. The API does not reveal whether the connection exists. - -## `POST /connections/:connectionId/confirm` - -Marks the caller's side of the real-world interaction as confirmed. - -### Scenario: initiator confirms first - -Status: `200` - -```json -{ - "status": "success", - "data": { - "connectionId": "77777777-7777-4777-8777-777777777777", - "initiatorConfirmed": true, - "counterpartConfirmed": false, - "confirmationStatus": "pending", - "updatedAt": "2026-04-11T12:00:00.000Z" - } -} -``` - -### Scenario: counterpart confirms later and connection becomes fully confirmed - -Status: `200` - -```json -{ - "status": "success", - "data": { - "connectionId": "77777777-7777-4777-8777-777777777777", - "initiatorConfirmed": true, - "counterpartConfirmed": true, - "confirmationStatus": "confirmed", - "updatedAt": "2026-04-12T09:00:00.000Z" - } -} -``` - -Explanation: the connection moves to `confirmed` only when both confirmation flags are true in the same atomic update. - -### Scenario: connection not found - -Status: `404` - -```json -{ - "status": "error", - "message": "Connection not found" -} -``` - -## Integrator Notes - -- There is intentionally no `POST /connections` endpoint. -- Ratings depend on a connection being `confirmed`, not merely existing. -- When a confirmation causes the connection to become fully confirmed, notifications are enqueued after commit for both parties. diff --git a/docs/api/conventions.md b/docs/api/conventions.md deleted file mode 100644 index ce4d1f7..0000000 --- a/docs/api/conventions.md +++ /dev/null @@ -1,330 +0,0 @@ -# Roomies API Conventions - -This document centralizes behavior shared across the API so the feature docs can stay focused on feature-specific contracts and scenarios. - -## Success Envelopes - -Most successful endpoints return one of these shapes. - -Success with data: - -```json -{ - "status": "success", - "data": {} -} -``` - -Success with message: - -```json -{ - "status": "success", - "message": "OTP sent to your email" -} -``` - -Accepted-for-processing response: - -```json -{ - "status": "success", - "data": { - "photoId": "6fd7cf0b-d6a7-4f31-bd0a-fdbcf5a5ef2e", - "status": "processing" - } -} -``` - -## Error Envelopes - -Simple operational error: - -```json -{ - "status": "error", - "message": "Listing not found" -} -``` - -Validation error: - -```json -{ - "status": "error", - "message": "Validation failed", - "errors": [ - { - "field": "body.password", - "message": "Password must contain at least one letter and one number" - } - ] -} -``` - -Validation errors come from Zod and always use the same top-level `message`. - -## Authentication Failures - -These are the common auth-related error bodies you will see across protected endpoints. - -No token provided: - -```json -{ - "status": "error", - "message": "No token provided" -} -``` - -Invalid token: - -```json -{ - "status": "error", - "message": "Invalid token" -} -``` - -Expired bearer token: - -```json -{ - "status": "error", - "message": "Token has expired" -} -``` - -Expired session during cookie auth or silent-refresh failure: - -```json -{ - "status": "error", - "message": "Session expired" -} -``` - -User no longer exists: - -```json -{ - "status": "error", - "message": "User not found" -} -``` - -Inactive account: - -```json -{ - "status": "error", - "message": "Account is suspended" -} -``` - -Some auth flows return a slightly different message: - -```json -{ - "status": "error", - "message": "Account inactive" -} -``` - -That difference is service-specific and documented in the auth feature doc. - -## Authorization and Privacy Conventions - -Roomies intentionally uses two different patterns depending on the sensitivity of the resource. - -Standard authorization failure: - -```json -{ - "status": "error", - "message": "Forbidden" -} -``` - -Privacy-preserving not found: - -```json -{ - "status": "error", - "message": "Connection not found" -} -``` - -Some endpoints return `404` instead of `403` when the caller is not a party to the resource. This prevents the API from confirming whether the resource exists at all. - -This pattern is used for: - -- some connection lookups -- some interest lookups -- some rating/report lookups - -## Gate Middleware Conventions - -Some routes are guarded by middleware that can short-circuit before the controller runs. - -- Contact reveal gate may return quota errors (`429`) with product-specific code fields. -- Admin role gate returns `403` with `"Forbidden"` for authenticated non-admin users. -- Route-level header middleware may still apply on short-circuit responses (for example `Cache-Control: no-store` on contact reveal routes). - -When integrating a gated endpoint, handle gate responses exactly like normal endpoint responses. - -## Rate Limit and Anti-Abuse Responses - -Auth route rate limiter example: - -```json -{ - "status": "error", - "message": "Too many requests, please try again later." -} -``` - -OTP verify IP throttle: - -```json -{ - "status": "error", - "message": "Too many OTP verification attempts from this IP — please wait 15 minutes" -} -``` - -Guest contact reveal quota exhausted: - -```json -{ - "status": "error", - "message": "Free contact reveal limit reached. Please log in or sign up to continue.", - "code": "CONTACT_REVEAL_LIMIT_REACHED", - "loginRedirect": "/login/signup" -} -``` - -## Upload Error Responses - -Unsupported file type: - -```json -{ - "status": "error", - "message": "Unsupported file type: image/gif. Accepted types: JPEG, PNG, WebP" -} -``` - -Unexpected multipart field: - -```json -{ - "status": "error", - "message": "Unexpected file field. Use field name 'photo'" -} -``` - -Missing file: - -```json -{ - "status": "error", - "message": "No file uploaded — send the image under the field name 'photo'" -} -``` - -File too large: - -```json -{ - "status": "error", - "message": "File is too large. Maximum allowed size is 10MB" -} -``` - -Queue temporarily unavailable after the file is uploaded to staging: - -```json -{ - "status": "error", - "message": "Photo processing queue is temporarily unavailable. Please retry." -} -``` - -## Pagination Pattern - -Most feed endpoints return: - -```json -{ - "status": "success", - "data": { - "items": [ - { - "id": "example" - } - ], - "nextCursor": { - "cursorTime": "2026-04-11T09:30:00.000Z", - "cursorId": "0ab4fca0-86a5-4c85-b668-1b6dfb4bd0f8" - } - } -} -``` - -No next page: - -```json -{ - "status": "success", - "data": { - "items": [], - "nextCursor": null - } -} -``` - -If only one cursor field is supplied, validation fails: - -```json -{ - "status": "error", - "message": "Validation failed", - "errors": [ - { - "field": "query.cursorTime", - "message": "cursorTime and cursorId must be provided together" - } - ] -} -``` - -## Example Value Conventions - -The feature docs use consistent sample values. - -- Student user: `11111111-1111-4111-8111-111111111111` -- PG owner user: `22222222-2222-4222-8222-222222222222` -- Admin user: `33333333-3333-4333-8333-333333333333` -- Property: `44444444-4444-4444-8444-444444444444` -- Listing: `55555555-5555-4555-8555-555555555555` -- Interest request: `66666666-6666-4666-8666-666666666666` -- Connection: `77777777-7777-4777-8777-777777777777` -- Rating: `88888888-8888-4888-8888-888888888888` -- Report: `99999999-9999-4999-8999-999999999999` -- Session ID: `aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa` - -Representative emails and locations: - -- `priya@iitb.ac.in` -- `arjun@roomies-test.in` -- `owner@sunrisepg.in` -- `Mumbai` -- `Pune` -- `560001` -- `400076` - -Representative phone numbers: - -- `+919876543210` -- `9876543210` diff --git a/docs/api/health.md b/docs/api/health.md deleted file mode 100644 index 78068a6..0000000 --- a/docs/api/health.md +++ /dev/null @@ -1,112 +0,0 @@ -# Health API - -## Endpoint - -### `GET /health` - -Checks API dependency readiness for: - -- PostgreSQL -- Redis - -No authentication is required. - -## Response Shape - -The health endpoint does not use the standard `{ status, data }` wrapper. It returns a direct health object. - -Healthy response: - -```json -{ - "status": "ok", - "timestamp": "2026-04-11T09:40:00.000Z", - "services": { - "database": "ok", - "redis": "ok" - } -} -``` - -## Scenarios - -### All services healthy - -Status: `200` - -```json -{ - "status": "ok", - "timestamp": "2026-04-11T09:40:00.000Z", - "services": { - "database": "ok", - "redis": "ok" - } -} -``` - -### Database degraded - -Status: `503` - -```json -{ - "status": "degraded", - "timestamp": "2026-04-11T09:40:00.000Z", - "services": { - "database": "unhealthy", - "redis": "ok" - } -} -``` - -### Database timed out - -Status: `503` - -```json -{ - "status": "degraded", - "timestamp": "2026-04-11T09:40:00.000Z", - "services": { - "database": "timeout", - "redis": "ok" - } -} -``` - -### Redis degraded - -Status: `503` - -```json -{ - "status": "degraded", - "timestamp": "2026-04-11T09:40:00.000Z", - "services": { - "database": "ok", - "redis": "unhealthy" - } -} -``` - -### Both dependencies degraded - -Status: `503` - -```json -{ - "status": "degraded", - "timestamp": "2026-04-11T09:40:00.000Z", - "services": { - "database": "timeout", - "redis": "unhealthy" - } -} -``` - -## Notes - -- The endpoint runs a timed probe against both dependencies. -- Internal connection details are never exposed in the response. -- Detailed failure information is logged server-side only. diff --git a/docs/api/interests.md b/docs/api/interests.md deleted file mode 100644 index 7fb4b29..0000000 --- a/docs/api/interests.md +++ /dev/null @@ -1,465 +0,0 @@ -# Interest Requests API - -Interest requests are the first step of the trust pipeline. Students send them. Listing posters review them. Accepting one can create a connection and may fill the listing. - -## `POST /listings/:listingId/interests` - -Student-only endpoint for expressing interest in a listing. - -### Request Body - -Minimal request: - -```json -{} -``` - -With optional message: - -```json -{ - "message": "Hi, I can move in by 1 May and would like to visit this weekend." -} -``` - -### Scenario: student sends interest with message - -Status: `201` - -```json -{ - "status": "success", - "data": { - "interestRequestId": "66666666-6666-4666-8666-666666666666", - "studentId": "11111111-1111-4111-8111-111111111111", - "listingId": "55555555-5555-4555-8555-555555555555", - "message": "Hi, I can move in by 1 May and would like to visit this weekend.", - "status": "pending", - "createdAt": "2026-04-11T11:00:00.000Z" - } -} -``` - -### Scenario: student sends interest without message - -Status: `201` - -```json -{ - "status": "success", - "data": { - "interestRequestId": "66666666-6666-4666-8666-666666666666", - "studentId": "11111111-1111-4111-8111-111111111111", - "listingId": "55555555-5555-4555-8555-555555555555", - "message": null, - "status": "pending", - "createdAt": "2026-04-11T11:00:00.000Z" - } -} -``` - -### Scenario: listing not found - -Status: `404` - -```json -{ - "status": "error", - "message": "Listing not found" -} -``` - -### Scenario: listing expired - -Status: `422` - -```json -{ - "status": "error", - "message": "Listing has expired and is no longer available" -} -``` - -### Scenario: listing unavailable - -Status: `422` - -```json -{ - "status": "error", - "message": "Listing is no longer available" -} -``` - -### Scenario: student is the poster - -Status: `422` - -```json -{ - "status": "error", - "message": "You cannot express interest in your own listing" -} -``` - -### Scenario: duplicate active request - -Status: `409` - -```json -{ - "status": "error", - "message": "You already have a pending or accepted interest request for this listing" -} -``` - -## `GET /listings/:listingId/interests` - -Poster-facing dashboard for all requests on a listing. - -### Request Contract - -- Auth required: Yes -- Listing ownership required: Yes -- Query params: - - `status` - - `limit` - - `cursorTime` - - `cursorId` - -### Scenario: poster views listing interests - -Status: `200` - -```json -{ - "status": "success", - "data": { - "items": [ - { - "interestRequestId": "66666666-6666-4666-8666-666666666666", - "studentId": "11111111-1111-4111-8111-111111111111", - "status": "pending", - "message": "Hi, I can move in by 1 May and would like to visit this weekend.", - "createdAt": "2026-04-11T11:00:00.000Z", - "updatedAt": "2026-04-11T11:00:00.000Z", - "student": { - "userId": "11111111-1111-4111-8111-111111111111", - "fullName": "Priya Sharma", - "profilePhotoUrl": null, - "averageRating": 4.7 - } - } - ], - "nextCursor": null - } -} -``` - -### Scenario: non-owner tries to view listing interests - -If the listing exists but belongs to someone else: - -Status: `403` - -```json -{ - "status": "error", - "message": "You do not own this listing" -} -``` - -If the listing does not exist: - -Status: `404` - -```json -{ - "status": "error", - "message": "Listing not found" -} -``` - -## `GET /interests/me` - -Student-facing dashboard of all requests the authenticated student has sent. - -### Scenario: success - -Status: `200` - -```json -{ - "status": "success", - "data": { - "items": [ - { - "interestRequestId": "66666666-6666-4666-8666-666666666666", - "listingId": "55555555-5555-4555-8555-555555555555", - "status": "pending", - "message": "Hi, I can move in by 1 May and would like to visit this weekend.", - "createdAt": "2026-04-11T11:00:00.000Z", - "updatedAt": "2026-04-11T11:00:00.000Z", - "listing": { - "listingId": "55555555-5555-4555-8555-555555555555", - "title": "Single room in verified PG", - "city": "Pune", - "listingType": "pg_room", - "rentPerMonth": 9500 - } - } - ], - "nextCursor": null - } -} -``` - -## `GET /interests/:interestId` - -Returns a single interest request if the caller is either the sender or the listing poster. - -### Scenario: sender fetches request - -Status: `200` - -```json -{ - "status": "success", - "data": { - "interestRequestId": "66666666-6666-4666-8666-666666666666", - "studentId": "11111111-1111-4111-8111-111111111111", - "listingId": "55555555-5555-4555-8555-555555555555", - "message": "Hi, I can move in by 1 May and would like to visit this weekend.", - "status": "pending", - "createdAt": "2026-04-11T11:00:00.000Z", - "updatedAt": "2026-04-11T11:00:00.000Z", - "listing": { - "listingId": "55555555-5555-4555-8555-555555555555", - "title": "Single room in verified PG", - "city": "Pune", - "listingType": "pg_room" - }, - "student": { - "userId": "11111111-1111-4111-8111-111111111111", - "fullName": "Priya Sharma", - "profilePhotoUrl": null - } - } -} -``` - -### Scenario: poster fetches request - -Status: `200` - -Response shape is the same as above. - -### Scenario: outsider fetches request - -Status: `404` - -```json -{ - "status": "error", - "message": "Interest request not found" -} -``` - -This is privacy-preserving behavior. The API does not reveal whether the request exists. - -## `PATCH /interests/:interestId/status` - -Updates an interest request status. - -Allowed body: - -```json -{ - "status": "accepted" -} -``` - -Other allowed target values are `declined` and `withdrawn`. - -### Scenario: poster declines pending request - -Request: - -```json -{ - "status": "declined" -} -``` - -Status: `200` - -```json -{ - "status": "success", - "data": { - "interestRequestId": "66666666-6666-4666-8666-666666666666", - "studentId": "11111111-1111-4111-8111-111111111111", - "listingId": "55555555-5555-4555-8555-555555555555", - "status": "declined", - "updatedAt": "2026-04-11T11:30:00.000Z" - } -} -``` - -### Scenario: student withdraws pending request - -Request: - -```json -{ - "status": "withdrawn" -} -``` - -Status: `200` - -```json -{ - "status": "success", - "data": { - "interestRequestId": "66666666-6666-4666-8666-666666666666", - "studentId": "11111111-1111-4111-8111-111111111111", - "listingId": "55555555-5555-4555-8555-555555555555", - "status": "withdrawn", - "updatedAt": "2026-04-11T11:35:00.000Z" - } -} -``` - -### Scenario: poster accepts pending request - -Request: - -```json -{ - "status": "accepted" -} -``` - -Status: `200` - -```json -{ - "status": "success", - "data": { - "interestRequestId": "66666666-6666-4666-8666-666666666666", - "studentId": "11111111-1111-4111-8111-111111111111", - "listingId": "55555555-5555-4555-8555-555555555555", - "status": "accepted", - "connectionId": "77777777-7777-4777-8777-777777777777", - "whatsappLink": "https://wa.me/+919876543210?text=Hi%20Rohan%20Mehta%2C%20my%20interest%20request%20for%20Single%20room%20in%20verified%20PG%20was%20accepted%21", - "listingFilled": false - } -} -``` - -### Accept side effects - -When acceptance succeeds, the service does all of the following atomically before commit: - -- marks the interest request as `accepted` -- creates a connection row -- increments `current_occupants` -- marks the listing `filled` when capacity is exhausted -- expires other pending requests when the listing becomes filled - -After commit, notifications are enqueued asynchronously. - -### Scenario: accept fails because request is no longer pending - -Status: `422` - -```json -{ - "status": "error", - "message": "Cannot accept a request with status 'declined'" -} -``` - -### Scenario: decline or withdraw on non-pending request - -Status: `409` - -```json -{ - "status": "error", - "message": "Interest request cannot be withdrawn — current status is 'accepted'" -} -``` - -### Scenario: actor is not allowed - -Status: `403` - -```json -{ - "status": "error", - "message": "You are not authorised to perform this action" -} -``` - -### Scenario: listing expired during acceptance - -Status: `422` - -```json -{ - "status": "error", - "message": "Listing has expired and is no longer available" -} -``` - -### Scenario: listing unavailable during acceptance - -Status: `422` - -```json -{ - "status": "error", - "message": "Listing is no longer available" -} -``` - -### Scenario: concurrent listing status change - -Status: `409` - -```json -{ - "status": "error", - "message": "Listing status has already changed — please refresh" -} -``` - -### Scenario: unsupported target status - -Status: `400` - -```json -{ - "status": "error", - "message": "Invalid target status: expired" -} -``` - -## Interest Scenario Matrix - -| Scenario | Actor | Status | Meaning | -| --- | --- | --- | --- | -| Create request | student | `201` | request stored as `pending` | -| Decline request | poster | `200` | request becomes `declined` | -| Withdraw request | student | `200` | request becomes `withdrawn` | -| Accept request | poster | `200` | request becomes `accepted`, connection created | -| Outsider reads request | unrelated user | `404` | existence hidden | -| Non-owner reads listing requests | unrelated user | `403` or `404` | ownership enforced | - -## Integrator Notes - -- Students can only create and withdraw their own requests. -- Posters can only accept or decline requests on their own listings. -- The accept path is the main trust pipeline transition and has the most side effects. diff --git a/docs/api/listings.api.md b/docs/api/listings.api.md deleted file mode 100644 index f91a1eb..0000000 --- a/docs/api/listings.api.md +++ /dev/null @@ -1,1197 +0,0 @@ -# Listings API - -This document covers listing discovery, CRUD, lifecycle transitions, preferences, saved listings, and photo processing. - -Listings are role-sensitive: - -- students can create `student_room` listings -- verified PG owners can create `pg_room` and `hostel_bed` listings - -Prices are expressed in rupees in the API, even though the database stores paise internally. - -## Auth Policy for Read Routes - -`GET /listings` and `GET /listings/:listingId` are the only listing endpoints that accept unauthenticated requests. All -other listing endpoints require a valid access token. - -| Caller | Browsing | Compatibility score | Saving | Interest | -| ---------------- | ------------------------------ | ------------------------- | --------------- | --------------- | -| Guest (no token) | ✅ Up to 20 items per request | ❌ Always 0 / unavailable | ❌ 401 | ❌ 401 | -| Authenticated | ✅ Up to 100 items per request | ✅ When preferences exist | ✅ Student only | ✅ Student only | - -Guests receive identical listing data to authenticated users. The only differences are the item cap, the absence of -compatibility scoring, and the inability to use write endpoints. - -## Search and Retrieval - -### `GET /listings` - -Searches active, non-expired listings. - -#### Request Contract - -- Auth required: **No** (auth optional — send a token to unlock compatibility scoring and higher page limits) -- Supported query filters: - - `city` - - `minRent` - - `maxRent` - - `roomType` - - `bedType` - - `preferredGender` - - `listingType` - - `availableFrom` - - `lat`, `lng` - - `radius` - - `amenityIds` - - `limit` - - `cursorTime` - - `cursorId` - -#### Scenario: guest searches with city filter - -Request (no Authorization header, no cookie): - -```http -GET /api/v1/listings?city=Pune&listingType=pg_room -``` - -Status: `200` - -```json -{ - "status": "success", - "data": { - "items": [ - { - "listingId": "55555555-5555-4555-8555-555555555555", - "listingType": "pg_room", - "title": "Single room near Viman Nagar", - "city": "Pune", - "locality": "Viman Nagar", - "rentPerMonth": 9500, - "depositAmount": 5000, - "compatibilityScore": 0, - "compatibilityAvailable": false, - "roomType": "single", - "preferredGender": "female", - "availableFrom": "2026-05-01T00:00:00.000Z", - "status": "active" - } - ], - "nextCursor": null - } -} -``` - -`compatibilityScore` is always `0` and `compatibilityAvailable` is always `false` for guests. - -#### Scenario: authenticated search with city, rent, and amenity filters - -Request: - -```http -GET /api/v1/listings?city=Pune&minRent=7000&maxRent=12000&listingType=pg_room&amenityIds=2db2f8fc-d90c-47a1-aebb-c6fa9ea4450a,eec6f390-2906-4d50-bf26-4f937833c6f8 -``` - -Status: `200` - -```json -{ - "status": "success", - "data": { - "items": [ - { - "listingId": "55555555-5555-4555-8555-555555555555", - "listingType": "pg_room", - "title": "Single room near Viman Nagar", - "city": "Pune", - "locality": "Viman Nagar", - "rentPerMonth": 9500, - "depositAmount": 5000, - "compatibilityScore": 2, - "compatibilityAvailable": true, - "roomType": "single", - "preferredGender": "female", - "availableFrom": "2026-05-01T00:00:00.000Z", - "status": "active" - } - ], - "nextCursor": null - } -} -``` - -#### Scenario: search with proximity filters - -Request: - -```http -GET /api/v1/listings?lat=18.5679&lng=73.9143&radius=5000&listingType=pg_room -``` - -Status: `200` - -```json -{ - "status": "success", - "data": { - "items": [ - { - "listingId": "55555555-5555-4555-8555-555555555555", - "listingType": "pg_room", - "title": "Single room near Viman Nagar", - "city": "Pune", - "rentPerMonth": 9500, - "compatibilityScore": 0, - "compatibilityAvailable": false - } - ], - "nextCursor": { - "cursorTime": "2026-04-11T10:20:00.000Z", - "cursorId": "55555555-5555-4555-8555-555555555555" - } - } -} -``` - -#### Scenario: invalid rent range - -Status: `400` - -```json -{ - "status": "error", - "message": "Validation failed", - "errors": [ - { - "field": "query.minRent", - "message": "minRent cannot be greater than maxRent" - } - ] -} -``` - -#### Scenario: only one proximity coordinate is supplied - -Status: `400` - -```json -{ - "status": "error", - "message": "Validation failed", - "errors": [ - { - "field": "query.lng", - "message": "lat and lng must be provided together for proximity search" - } - ] -} -``` - -#### Scenario: partial cursor supplied - -Status: `400` - -```json -{ - "status": "error", - "message": "Validation failed", - "errors": [ - { - "field": "query.cursorTime", - "message": "cursorTime and cursorId must be provided together" - } - ] -} -``` - -### `GET /listings/:listingId` - -Fetches full listing detail. Also increments `views_count` asynchronously. - -#### Request Contract - -- Auth required: **No** (auth optional) - -#### Scenario: guest fetches listing detail - -Status: `200` - -Full listing object is returned. Same shape as the authenticated response below. - -#### Scenario: authenticated fetch - -Status: `200` - -```json -{ - "status": "success", - "data": { - "listingId": "55555555-5555-4555-8555-555555555555", - "postedBy": "22222222-2222-4222-8222-222222222222", - "propertyId": "44444444-4444-4444-8444-444444444444", - "listingType": "pg_room", - "title": "Single room near Viman Nagar", - "description": "Ideal for students and interns.", - "rentPerMonth": 9500, - "depositAmount": 5000, - "roomType": "single", - "bedType": "single_bed", - "totalCapacity": 1, - "currentOccupants": 0, - "preferredGender": "female", - "availableFrom": "2026-05-01T00:00:00.000Z", - "availableUntil": null, - "addressLine": null, - "city": "Pune", - "locality": null, - "landmark": null, - "pincode": null, - "latitude": null, - "longitude": null, - "status": "active", - "viewsCount": 12, - "expiresAt": "2026-06-10T10:00:00.000Z", - "poster_rating": 4.5, - "poster_rating_count": 12, - "poster_name": "Rohan Mehta", - "amenities": [], - "preferences": [], - "photos": [], - "property": { - "propertyId": "44444444-4444-4444-8444-444444444444", - "propertyName": "Sunrise PG Viman Nagar", - "propertyType": "pg", - "addressLine": "Lane 5, Viman Nagar", - "city": "Pune", - "locality": "Viman Nagar", - "latitude": 18.5679, - "longitude": 73.9143, - "houseRules": "No smoking inside rooms.", - "averageRating": 4.5, - "ratingCount": 12 - } - } -} -``` - -#### Scenario: listing not found - -Status: `404` - -```json -{ - "status": "error", - "message": "Listing not found" -} -``` - -## Create, Update, and Delete - -### `POST /listings` - -Creates a listing owned by the authenticated user. - -**Auth required.** - -#### Student room request example - -```json -{ - "listingType": "student_room", - "title": "Looking for a roommate near IIT Bombay", - "description": "Two-bedroom flat, one room available.", - "rentPerMonth": 12000, - "depositAmount": 15000, - "rentIncludesUtilities": false, - "isNegotiable": true, - "roomType": "single", - "bedType": "single_bed", - "totalCapacity": 1, - "preferredGender": "female", - "availableFrom": "2026-05-01", - "addressLine": "Flat 8B, Powai Residency", - "city": "Mumbai", - "locality": "Powai", - "landmark": "Near IIT Main Gate", - "pincode": "400076", - "latitude": 19.1334, - "longitude": 72.9133, - "amenityIds": [], - "preferences": [ - { - "preferenceKey": "sleep_schedule", - "preferenceValue": "early" - } - ] -} -``` - -#### PG room request example - -```json -{ - "listingType": "pg_room", - "propertyId": "44444444-4444-4444-8444-444444444444", - "title": "Single room in verified PG", - "description": "Meals and Wi-Fi included.", - "rentPerMonth": 9500, - "depositAmount": 5000, - "rentIncludesUtilities": true, - "isNegotiable": false, - "roomType": "single", - "bedType": "single_bed", - "totalCapacity": 1, - "preferredGender": "female", - "availableFrom": "2026-05-01", - "amenityIds": [], - "preferences": [] -} -``` - -#### Scenario: student creates `student_room` - -Status: `201` - -```json -{ - "status": "success", - "data": { - "listingId": "55555555-5555-4555-8555-555555555555", - "listingType": "student_room", - "title": "Looking for a roommate near IIT Bombay", - "rentPerMonth": 12000 - } -} -``` - -#### Scenario: PG owner creates `pg_room` - -Status: `201` - -```json -{ - "status": "success", - "data": { - "listingId": "55555555-5555-4555-8555-555555555555", - "listingType": "pg_room", - "propertyId": "44444444-4444-4444-8444-444444444444", - "title": "Single room in verified PG", - "rentPerMonth": 9500 - } -} -``` - -#### Scenario: PG owner creates `hostel_bed` - -Status: `201` - -```json -{ - "status": "success", - "data": { - "listingId": "55555555-5555-4555-8555-555555555555", - "listingType": "hostel_bed", - "propertyId": "44444444-4444-4444-8444-444444444444", - "title": "Hostel bed near college", - "rentPerMonth": 6500 - } -} -``` - -#### Scenario: student tries to create `pg_room` - -Status: `403` - -```json -{ - "status": "error", - "message": "Only verified PG owners can create pg_room or hostel_bed listings" -} -``` - -#### Scenario: PG owner tries to create `student_room` - -Status: `403` - -```json -{ - "status": "error", - "message": "Only students can create student_room listings" -} -``` - -#### Scenario: PG owner is not verified - -Status: `403` - -```json -{ - "status": "error", - "message": "Your account must be verified before you can manage properties or listings" -} -``` - -#### Scenario: property does not belong to owner - -Status: `404` - -```json -{ - "status": "error", - "message": "Property not found or does not belong to you" -} -``` - -#### Scenario: student-room create missing `addressLine` and `city` - -Status: `400` - -```json -{ - "status": "error", - "message": "Validation failed", - "errors": [ - { - "field": "body.addressLine", - "message": "addressLine and city are required for student_room listings" - } - ] -} -``` - -#### Scenario: PG or hostel create includes coordinates - -Status: `400` - -```json -{ - "status": "error", - "message": "Validation failed", - "errors": [ - { - "field": "body.latitude", - "message": "Coordinates are not accepted for pg_room or hostel_bed listings — location is inherited from the property" - } - ] -} -``` - -### `PUT /listings/:listingId` - -**Auth required.** Updates a listing owned by the authenticated user. - -#### Request example - -```json -{ - "title": "Updated title", - "description": "Now includes cleaning twice a week.", - "rentPerMonth": 9800, - "isNegotiable": true -} -``` - -#### Scenario: update succeeds - -Status: `200` - -```json -{ - "status": "success", - "data": { - "listingId": "55555555-5555-4555-8555-555555555555", - "title": "Updated title", - "rentPerMonth": 9800, - "isNegotiable": true - } -} -``` - -#### Scenario: no valid fields provided - -Status: `400` - -```json -{ - "status": "error", - "message": "No valid fields provided for update" -} -``` - -#### Scenario: PG/hostel update attempts property-owned location fields - -Status: `422` - -```json -{ - "status": "error", - "message": "Location fields (city, latitude) cannot be updated on a pg_room listing — they are inherited from the parent property. Update the property's address instead." -} -``` - -#### Scenario: listing not found - -Status: `404` - -```json -{ - "status": "error", - "message": "Listing not found" -} -``` - -### `DELETE /listings/:listingId` - -**Auth required.** Soft-deletes a listing and expires pending requests. - -#### Scenario: delete succeeds - -Status: `200` - -```json -{ - "status": "success", - "data": { - "listingId": "55555555-5555-4555-8555-555555555555", - "deleted": true - } -} -``` - -## Lifecycle and Preferences - -### `PATCH /listings/:listingId/status` - -**Auth required.** Supports poster-driven lifecycle transitions among `active`, `filled`, and `deactivated`. - -#### Request - -```json -{ - "status": "deactivated" -} -``` - -#### Scenario: update from active to deactivated - -Status: `200` - -```json -{ - "status": "success", - "data": { - "listingId": "55555555-5555-4555-8555-555555555555", - "status": "deactivated" - } -} -``` - -#### Scenario: update from active to filled - -Status: `200` - -```json -{ - "status": "success", - "data": { - "listingId": "55555555-5555-4555-8555-555555555555", - "status": "filled" - } -} -``` - -#### Scenario: invalid transition - -Status: `422` - -```json -{ - "status": "error", - "message": "Cannot change listing status from 'filled' to 'deactivated'" -} -``` - -#### Scenario: listing already expired - -Status: `422` - -```json -{ - "status": "error", - "message": "Listing has expired and is no longer available" -} -``` - -#### Scenario: concurrent state change - -Status: `409` - -```json -{ - "status": "error", - "message": "Listing status has already changed — please refresh" -} -``` - -### `GET /listings/:listingId/preferences` - -**Auth required.** - -#### Scenario: success - -Status: `200` - -```json -{ - "status": "success", - "data": [ - { - "preferenceKey": "sleep_schedule", - "preferenceValue": "early" - }, - { - "preferenceKey": "cleanliness", - "preferenceValue": "high" - } - ] -} -``` - -### `PUT /listings/:listingId/preferences` - -**Auth required.** - -#### Request - -```json -{ - "preferences": [ - { - "preferenceKey": "sleep_schedule", - "preferenceValue": "early" - } - ] -} -``` - -#### Scenario: update preferences succeeds - -Status: `200` - -```json -{ - "status": "success", - "data": [ - { - "preferenceKey": "sleep_schedule", - "preferenceValue": "early" - } - ] -} -``` - -## Interest Requests (Listing-Scoped) - -These are child-resource routes under a listing and are mounted in the listings router. - -### `POST /listings/:listingId/interests` - -Creates an interest request on a listing. - -**Auth required.** Role: `student`. - -#### Request body - -Minimal body: - -```json -{} -``` - -With optional message: - -```json -{ - "message": "Hi, I can move in by 1 May and would like to visit this weekend." -} -``` - -#### Scenario: interest created with message - -Status: `201` - -```json -{ - "status": "success", - "data": { - "interestRequestId": "66666666-6666-4666-8666-666666666666", - "studentId": "11111111-1111-4111-8111-111111111111", - "listingId": "55555555-5555-4555-8555-555555555555", - "message": "Hi, I can move in by 1 May and would like to visit this weekend.", - "status": "pending", - "createdAt": "2026-04-11T11:00:00.000Z" - } -} -``` - -#### Scenario: interest created without message - -Status: `201` - -```json -{ - "status": "success", - "data": { - "interestRequestId": "66666666-6666-4666-8666-666666666666", - "studentId": "11111111-1111-4111-8111-111111111111", - "listingId": "55555555-5555-4555-8555-555555555555", - "message": null, - "status": "pending", - "createdAt": "2026-04-11T11:00:00.000Z" - } -} -``` - -#### Scenario: listing not found - -Status: `404` - -```json -{ - "status": "error", - "message": "Listing not found" -} -``` - -#### Scenario: listing expired - -Status: `422` - -```json -{ - "status": "error", - "message": "Listing has expired and is no longer available" -} -``` - -#### Scenario: listing not active - -Status: `422` - -```json -{ - "status": "error", - "message": "Listing is no longer available" -} -``` - -#### Scenario: student tries own listing - -Status: `422` - -```json -{ - "status": "error", - "message": "You cannot express interest in your own listing" -} -``` - -#### Scenario: duplicate active request (pending/accepted already exists) - -Status: `409` - -```json -{ - "status": "error", - "message": "You already have a pending or accepted interest request for this listing" -} -``` - -### `GET /listings/:listingId/interests` - -Poster-facing list of requests for one listing. - -**Auth required.** Ownership required. - -#### Query params - -- `status` (`pending | accepted | declined | withdrawn | expired`) -- `limit` -- `cursorTime` -- `cursorId` - -Sorted by `createdAt DESC`, then `interestRequestId ASC`. - -#### Scenario: listing owner fetches interest requests - -Status: `200` - -```json -{ - "status": "success", - "data": { - "items": [ - { - "interestRequestId": "66666666-6666-4666-8666-666666666666", - "studentId": "11111111-1111-4111-8111-111111111111", - "status": "pending", - "message": "Hi, I can move in by 1 May and would like to visit this weekend.", - "createdAt": "2026-04-11T11:00:00.000Z", - "updatedAt": "2026-04-11T11:00:00.000Z", - "student": { - "userId": "11111111-1111-4111-8111-111111111111", - "fullName": "Priya Sharma", - "profilePhotoUrl": null, - "averageRating": 4.7 - } - } - ], - "nextCursor": null - } -} -``` - -#### Scenario: caller does not own the listing - -Status: `403` - -```json -{ - "status": "error", - "message": "You do not own this listing" -} -``` - -#### Scenario: listing not found - -Status: `404` - -```json -{ - "status": "error", - "message": "Listing not found" -} -``` - -## Saved Listings - -### `POST /listings/:listingId/save` - -**Auth required.** Role: `student`. - -#### Scenario: save succeeds - -Status: `200` - -```json -{ - "status": "success", - "data": { - "listingId": "55555555-5555-4555-8555-555555555555", - "saved": true - } -} -``` - -#### Scenario: wrong role - -Status: `403` - -```json -{ - "status": "error", - "message": "Forbidden" -} -``` - -#### Scenario: listing expired - -Status: `422` - -```json -{ - "status": "error", - "message": "Listing has expired and is no longer available" -} -``` - -#### Scenario: listing unavailable - -Status: `422` - -```json -{ - "status": "error", - "message": "Listing is no longer available" -} -``` - -### `DELETE /listings/:listingId/save` - -**Auth required.** Role: `student`. - -#### Scenario: unsave succeeds - -Status: `200` - -```json -{ - "status": "success", - "data": { - "listingId": "55555555-5555-4555-8555-555555555555", - "saved": false - } -} -``` - -### `GET /listings/me/saved` - -**Auth required.** Role: `student`. Returns the student's saved active listings. - -#### Scenario: paginated saved listings - -Status: `200` - -```json -{ - "status": "success", - "data": { - "items": [ - { - "listing_id": "55555555-5555-4555-8555-555555555555", - "listing_type": "pg_room", - "title": "Single room in verified PG", - "city": "Pune", - "locality": "Viman Nagar", - "room_type": "single", - "preferred_gender": "female", - "available_from": "2026-05-01T00:00:00.000Z", - "status": "active", - "saved_at": "2026-04-11T10:30:00.000Z", - "property_name": "Sunrise PG Viman Nagar", - "average_rating": 4.5, - "cover_photo_url": null, - "rentPerMonth": 9500, - "depositAmount": 5000 - } - ], - "nextCursor": null - } -} -``` - -## Photos - -Photo uploads are asynchronous. The HTTP request inserts a provisional row and queues a worker job. Clients should poll -`GET /listings/:listingId/photos`. - -**All photo endpoints require authentication.** - -### `GET /listings/:listingId/photos` - -Returns completed photos only. Processing placeholders are hidden. - -#### Scenario: success - -Status: `200` - -```json -{ - "status": "success", - "data": [ - { - "photoId": "6fd7cf0b-d6a7-4f31-bd0a-fdbcf5a5ef2e", - "photoUrl": "https://storage.example.com/listings/555/photo-1.webp", - "isCover": true, - "displayOrder": 0, - "createdAt": "2026-04-11T10:40:00.000Z" - } - ] -} -``` - -### `POST /listings/:listingId/photos` - -**Auth required.** Uploads a staged image using `multipart/form-data`. - -#### Multipart requirements - -- field name: `photo` -- accepted MIME types: `image/jpeg`, `image/png`, `image/webp` -- max size: `10MB` - -#### Scenario: upload accepted and queued - -Status: `202` - -```json -{ - "status": "success", - "data": { - "photoId": "6fd7cf0b-d6a7-4f31-bd0a-fdbcf5a5ef2e", - "status": "processing" - } -} -``` - -#### Scenario: file missing - -Status: `400` - -```json -{ - "status": "error", - "message": "No file uploaded — send the image under the field name 'photo'" -} -``` - -#### Scenario: unsupported MIME or extension - -Status: `400` - -```json -{ - "status": "error", - "message": "Unsupported file type: image/gif. Accepted types: JPEG, PNG, WebP" -} -``` - -#### Scenario: queue unavailable - -Status: `503` - -```json -{ - "status": "error", - "message": "Photo processing queue is temporarily unavailable. Please retry." -} -``` - -### `DELETE /listings/:listingId/photos/:photoId` - -**Auth required.** - -#### Scenario: delete succeeds - -Status: `200` - -```json -{ - "status": "success", - "data": { - "photoId": "6fd7cf0b-d6a7-4f31-bd0a-fdbcf5a5ef2e", - "deleted": true - } -} -``` - -### `PATCH /listings/:listingId/photos/:photoId/cover` - -**Auth required.** - -#### Scenario: set cover succeeds - -Status: `200` - -```json -{ - "status": "success", - "data": { - "photoId": "6fd7cf0b-d6a7-4f31-bd0a-fdbcf5a5ef2e", - "isCover": true - } -} -``` - -### `PUT /listings/:listingId/photos/reorder` - -**Auth required.** - -#### Request - -```json -{ - "photos": [ - { - "photoId": "6fd7cf0b-d6a7-4f31-bd0a-fdbcf5a5ef2e", - "displayOrder": 0 - }, - { - "photoId": "bcf8f73b-b1bd-4e30-9a7e-a82a18252d28", - "displayOrder": 1 - } - ] -} -``` - -#### Scenario: reorder succeeds - -Status: `200` - -```json -{ - "status": "success", - "data": [ - { - "photoId": "6fd7cf0b-d6a7-4f31-bd0a-fdbcf5a5ef2e", - "photoUrl": "https://storage.example.com/listings/555/photo-1.webp", - "isCover": true, - "displayOrder": 0, - "createdAt": "2026-04-11T10:40:00.000Z" - } - ] -} -``` - -#### Scenario: duplicate `photoId` - -Status: `422` - -```json -{ - "status": "error", - "message": "Duplicate photo IDs in reorder payload: 6fd7cf0b-d6a7-4f31-bd0a-fdbcf5a5ef2e" -} -``` - -#### Scenario: incomplete payload - -Status: `422` - -```json -{ - "status": "error", - "message": "Reorder payload must include all photos. Submitted 1, expected 3." -} -``` - -## Listing Scenario Matrix - -| Scenario | Endpoint | Status | Key behavior | -| -------------------------------- | ------------------------------------- | ------ | ------------------------------------------ | -| Guest browses listings | `GET /listings` | `200` | up to 20 items, no compatibility score | -| Guest fetches listing detail | `GET /listings/:listingId` | `200` | full detail, no auth required | -| Guest tries to save a listing | `POST /listings/:listingId/save` | `401` | auth required | -| Guest tries to express interest | `POST /listings/:listingId/interests` | `401` | auth required | -| Student sends interest request | `POST /listings/:listingId/interests` | `201` | creates pending request | -| Poster lists listing interests | `GET /listings/:listingId/interests` | `200` | owner-only, keyset paginated | -| Student creates roommate listing | `POST /listings` | `201` | requires address and city | -| PG owner creates PG listing | `POST /listings` | `201` | requires verified owner and owned property | -| Expired listing save attempt | `POST /listings/:listingId/save` | `422` | expired listings cannot be saved | -| Status changed concurrently | `PATCH /listings/:listingId/status` | `409` | caller should refresh | -| Photo upload request | `POST /listings/:listingId/photos` | `202` | worker-based async processing | - -## Integrator Notes - -- `GET /listings` and `GET /listings/:listingId` are the only endpoints that accept requests without a token. -- Guests see at most 20 listings per request. Authenticated users may request up to 100. -- `compatibilityScore` and `compatibilityAvailable` are always `0` / `false` for guests — there are no user preferences - to compare against. -- `student_room` listings own their own location fields. `pg_room` and `hostel_bed` listings inherit location from the - parent property. -- `GET /listings/:listingId/interests` is poster-only; ownership is enforced server-side. -- Listing expiry is also system-driven (cron). Expired listings are moved to `expired` and pending interest requests are - expired in bulk. -- Photo uploads are not immediately visible after `202 Accepted`. -- Saved listings only return currently active, non-expired listings. diff --git a/docs/api/notifications.md b/docs/api/notifications.md deleted file mode 100644 index 2d7327b..0000000 --- a/docs/api/notifications.md +++ /dev/null @@ -1,192 +0,0 @@ -# Notifications API - -Notifications are created asynchronously by the worker. The HTTP API only reads them and marks them as read. - -## `GET /notifications` - -Returns the authenticated user's notification feed. - -### Request Contract - -- Auth required: Yes -- Query params: - - `isRead` - - `limit` - - `cursorTime` - - `cursorId` - -### Scenario: fetch feed with pagination - -Status: `200` - -```json -{ - "status": "success", - "data": { - "items": [ - { - "notificationId": "31f907ca-df84-4f9d-9c4a-fd67eb1dfe7d", - "actorId": "22222222-2222-4222-8222-222222222222", - "type": "interest_request_accepted", - "entityType": "interest_request", - "entityId": "66666666-6666-4666-8666-666666666666", - "message": "Your interest request was accepted.", - "isRead": false, - "createdAt": "2026-04-11T12:10:00.000Z" - } - ], - "nextCursor": null - } -} -``` - -### Scenario: fetch only read notifications - -Request: - -```http -GET /api/v1/notifications?isRead=true -``` - -Status: `200` - -Response shape is the same as above, filtered to read rows only. - -### Scenario: fetch only unread notifications - -Request: - -```http -GET /api/v1/notifications?isRead=false -``` - -Status: `200` - -Response shape is the same as above, filtered to unread rows only. - -## `GET /notifications/unread-count` - -Returns the bell badge count. - -### Scenario: success - -Status: `200` - -```json -{ - "status": "success", - "data": { - "count": 3 - } -} -``` - -## `POST /notifications/mark-read` - -Marks notifications as read in one of two modes. - -### Mode 1: mark all - -Request: - -```json -{ - "all": true -} -``` - -Success: - -```json -{ - "status": "success", - "data": { - "updated": 3 - } -} -``` - -### Mode 2: mark selected IDs - -Request: - -```json -{ - "notificationIds": [ - "31f907ca-df84-4f9d-9c4a-fd67eb1dfe7d", - "efb6d828-3725-4caf-aa15-5a8ec749f590" - ] -} -``` - -Success: - -```json -{ - "status": "success", - "data": { - "updated": 2 - } -} -``` - -### Scenario: both modes supplied - -Status: `400` - -```json -{ - "status": "error", - "message": "Validation failed", - "errors": [ - { - "field": "body", - "message": "Provide exactly one mode: either { all: true } to mark all notifications as read, or { notificationIds: [...] } to mark specific ones — not both simultaneously" - } - ] -} -``` - -### Scenario: neither mode supplied - -Status: `400` - -```json -{ - "status": "error", - "message": "notificationIds must be a non-empty array when all is not true" -} -``` - -### Scenario: malformed IDs - -Status: `400` - -```json -{ - "status": "error", - "message": "Validation failed", - "errors": [ - { - "field": "body.notificationIds.0", - "message": "Each notification ID must be a valid UUID" - } - ] -} -``` - -### Scenario: authentication missing - -Status: `401` - -```json -{ - "status": "error", - "message": "No token provided" -} -``` - -## Integrator Notes - -- Mark-read operations are idempotent. -- Supplying another user's notification IDs does not update those rows because the update is scoped to the authenticated recipient. diff --git a/docs/api/preferences.md b/docs/api/preferences.md deleted file mode 100644 index e9949d7..0000000 --- a/docs/api/preferences.md +++ /dev/null @@ -1,134 +0,0 @@ -# Preferences API - -This document covers preference metadata and student self-preference management. - -`user_preferences` is optional. A user can have zero preferences and still search listings normally. - -## `GET /preferences/meta` - -Returns the authenticated metadata catalog of supported preference keys and values. - -### Request Contract - -- Auth required: Yes - -### Scenario: success - -Status: `200` - -```json -{ - "status": "success", - "data": { - "preferences": [ - { - "preferenceKey": "smoking", - "label": "Smoking", - "values": [ - { "value": "non_smoker", "label": "Non-smoker" }, - { "value": "smoker", "label": "Smoker" } - ] - } - ] - } -} -``` - -## `GET /students/:userId/preferences` - -Returns the current self preferences for the authenticated student. - -### Request Contract - -- Auth required: Yes -- Owner-only: `req.user.userId` must match `:userId` - -### Scenario: no preferences configured yet - -Status: `200` - -```json -{ - "status": "success", - "data": [] -} -``` - -### Scenario: has preferences - -Status: `200` - -```json -{ - "status": "success", - "data": [ - { "preferenceKey": "smoking", "preferenceValue": "non_smoker" }, - { "preferenceKey": "food_habit", "preferenceValue": "vegetarian" } - ] -} -``` - -## `PUT /students/:userId/preferences` - -Replaces the full preference set for the authenticated student. - -- Empty `preferences` array is valid and clears all preferences. -- Duplicate keys are silently de-duplicated using last-write-wins semantics. - -### Request body - -```json -{ - "preferences": [ - { "preferenceKey": "smoking", "preferenceValue": "non_smoker" }, - { "preferenceKey": "sleep_schedule", "preferenceValue": "early_bird" } - ] -} -``` - -### Scenario: clear all - -Request body: - -```json -{ - "preferences": [] -} -``` - -Status: `200` - -```json -{ - "status": "success", - "data": [] -} -``` - -### Scenario: invalid key/value pair - -Status: `400` - -```json -{ - "status": "error", - "message": "Validation failed", - "errors": [ - { - "field": "body.preferences.0.preferenceValue", - "message": "Invalid preferenceValue for 'smoking'" - } - ] -} -``` - -### Scenario: caller updates another user - -Status: `403` - -```json -{ - "status": "error", - "message": "Forbidden" -} -``` diff --git a/docs/api/profiles-and-contact.md b/docs/api/profiles-and-contact.md deleted file mode 100644 index 05bf3c9..0000000 --- a/docs/api/profiles-and-contact.md +++ /dev/null @@ -1,581 +0,0 @@ -# Profiles and Contact Reveal API - -This document covers student profiles, PG owner profiles, contact reveal behavior, and PG owner verification document submission. - -## `GET /students/:userId/profile` - -Returns a student profile. The authenticated caller can always fetch the profile, but private fields such as email are only included when the caller is the profile owner. - -### Request Contract - -- Auth required: Yes -- Path param: - - `userId` must be a UUID - -### Scenario: fetch existing student profile as another authenticated user - -Status: `200` - -```json -{ - "status": "success", - "data": { - "profile_id": "a11f71df-dbc0-4b7c-a9a8-9718eebdd9d1", - "user_id": "11111111-1111-4111-8111-111111111111", - "full_name": "Priya Sharma", - "date_of_birth": "2003-08-15T00:00:00.000Z", - "gender": "female", - "profile_photo_url": null, - "bio": "I am looking for a quiet flatmate near Powai.", - "course": "B.Tech", - "year_of_study": 3, - "institution_id": "c478b4c9-f4cf-4d58-b577-c5bca50d6f34", - "is_aadhaar_verified": false, - "email": null, - "is_email_verified": true, - "average_rating": 4.7, - "rating_count": 6, - "created_at": "2026-04-01T10:00:00.000Z" - } -} -``` - -### Scenario: fetch own student profile - -Status: `200` - -```json -{ - "status": "success", - "data": { - "profile_id": "a11f71df-dbc0-4b7c-a9a8-9718eebdd9d1", - "user_id": "11111111-1111-4111-8111-111111111111", - "full_name": "Priya Sharma", - "date_of_birth": "2003-08-15T00:00:00.000Z", - "gender": "female", - "profile_photo_url": null, - "bio": "I am looking for a quiet flatmate near Powai.", - "course": "B.Tech", - "year_of_study": 3, - "institution_id": "c478b4c9-f4cf-4d58-b577-c5bca50d6f34", - "is_aadhaar_verified": false, - "email": "priya@iitb.ac.in", - "is_email_verified": true, - "average_rating": 4.7, - "rating_count": 6, - "created_at": "2026-04-01T10:00:00.000Z" - } -} -``` - -### Scenario: profile not found - -Status: `404` - -```json -{ - "status": "error", - "message": "Student profile not found" -} -``` - -## `PUT /students/:userId/profile` - -Updates the authenticated student profile. - -### Request Body - -Minimal valid body: - -```json -{ - "bio": "Need a flatmate who is okay with early classes." -} -``` - -Full request example: - -```json -{ - "fullName": "Priya Sharma", - "bio": "Need a flatmate who is okay with early classes.", - "course": "B.Tech CSE", - "yearOfStudy": 3, - "gender": "female", - "dateOfBirth": "2003-08-15" -} -``` - -### Scenario: update own student profile - -Status: `200` - -```json -{ - "status": "success", - "data": { - "profile_id": "a11f71df-dbc0-4b7c-a9a8-9718eebdd9d1", - "user_id": "11111111-1111-4111-8111-111111111111", - "full_name": "Priya Sharma", - "bio": "Need a flatmate who is okay with early classes.", - "course": "B.Tech CSE", - "year_of_study": 3, - "gender": "female", - "date_of_birth": "2003-08-15T00:00:00.000Z" - } -} -``` - -### Scenario: caller tries to edit another student's profile - -Status: `403` - -```json -{ - "status": "error", - "message": "Forbidden" -} -``` - -### Scenario: no valid fields provided - -Status: `400` - -```json -{ - "status": "error", - "message": "No valid fields provided for update" -} -``` - -## `GET /students/:userId/contact/reveal` - -Reveals student contact information using the guest/unverified/verified contact policy. - -### Request Contract - -- Auth required: Optional -- Auth transport accepted: - - Cookie mode (`accessToken` cookie) - - Bearer mode (`Authorization: Bearer `) -- Quota-gated for guests and unverified users -- Student route uses `GET` -- Response header: `Cache-Control: no-store` - -### Scenario: guest reveal gets email-only bundle - -Status: `200` - -```json -{ - "status": "success", - "data": { - "user_id": "11111111-1111-4111-8111-111111111111", - "full_name": "Priya Sharma", - "email": "priya@iitb.ac.in" - } -} -``` - -### Scenario: unverified logged-in user still gets email-only bundle - -Status: `200` - -```json -{ - "status": "success", - "data": { - "user_id": "11111111-1111-4111-8111-111111111111", - "full_name": "Priya Sharma", - "email": "priya@iitb.ac.in" - } -} -``` - -### Scenario: verified user still receives the student route response actually enforced by the service - -Status: `200` - -```json -{ - "status": "success", - "data": { - "user_id": "11111111-1111-4111-8111-111111111111", - "full_name": "Priya Sharma", - "email": "priya@iitb.ac.in", - "whatsapp_phone": "+919876543210" - } -} -``` - -Important note: the reveal gate is the single source of truth for full-contact eligibility. Verified users receive full contact; guests and unverified users receive email-only. - -### Scenario: guest hits free reveal limit - -Status: `429` - -```json -{ - "status": "error", - "message": "Free contact reveal limit reached. Please log in or sign up to continue.", - "code": "CONTACT_REVEAL_LIMIT_REACHED", - "loginRedirect": "/login/signup" -} -``` - -### Scenario: malformed UUID - -Status: `400` - -```json -{ - "status": "error", - "message": "Validation failed", - "errors": [ - { - "field": "params.userId", - "message": "Invalid user ID" - } - ] -} -``` - -## `GET /pg-owners/:userId/profile` - -Returns a PG owner profile. Sensitive fields such as `business_phone` and `email` are only included when the requester is the profile owner. - -### Scenario: fetch existing PG owner profile - -Status: `200` - -```json -{ - "status": "success", - "data": { - "profile_id": "bc4cc5f8-93cb-4700-80f4-4f404410242f", - "user_id": "22222222-2222-4222-8222-222222222222", - "business_name": "Sunrise PG", - "owner_full_name": "Rohan Mehta", - "business_description": "Student-friendly PG near Viman Nagar.", - "business_phone": null, - "operating_since": 2018, - "verification_status": "verified", - "verified_at": "2026-04-02T12:00:00.000Z", - "email": null, - "is_email_verified": true, - "average_rating": 4.5, - "rating_count": 12, - "created_at": "2026-03-20T08:00:00.000Z" - } -} -``` - -### Scenario: profile not found - -Status: `404` - -```json -{ - "status": "error", - "message": "PG owner profile not found" -} -``` - -## `PUT /pg-owners/:userId/profile` - -Updates the authenticated PG owner profile. - -### Request Example - -```json -{ - "businessName": "Sunrise PG Premium", - "ownerFullName": "Rohan Mehta", - "businessDescription": "Secure PG with Wi-Fi, meals, and weekly cleaning.", - "businessPhone": "+919876543210", - "operatingSince": 2018 -} -``` - -### Scenario: update own PG owner profile - -Status: `200` - -```json -{ - "status": "success", - "data": { - "profile_id": "bc4cc5f8-93cb-4700-80f4-4f404410242f", - "user_id": "22222222-2222-4222-8222-222222222222", - "business_name": "Sunrise PG Premium", - "owner_full_name": "Rohan Mehta", - "business_description": "Secure PG with Wi-Fi, meals, and weekly cleaning.", - "business_phone": "+919876543210", - "operating_since": 2018 - } -} -``` - -### Scenario: caller edits another PG owner's profile - -Status: `403` - -```json -{ - "status": "error", - "message": "Forbidden" -} -``` - -### Scenario: no valid fields provided - -Status: `400` - -```json -{ - "status": "error", - "message": "No valid fields provided for update" -} -``` - -## `POST /pg-owners/:userId/contact/reveal` - -Reveals PG owner contact information. This endpoint is intentionally `POST`, not `GET`, and the route sets `Cache-Control: no-store`. - -### Request Contract - -- Auth required: Optional -- Auth transport accepted: - - Cookie mode (`accessToken` cookie) - - Bearer mode (`Authorization: Bearer `) -- Route method: `POST` -- Response header: `Cache-Control: no-store` - -### Scenario: guest or unverified user gets email-only bundle - -Status: `200` - -```json -{ - "status": "success", - "data": { - "user_id": "22222222-2222-4222-8222-222222222222", - "owner_full_name": "Rohan Mehta", - "business_name": "Sunrise PG", - "email": "owner@sunrisepg.in" - } -} -``` - -### Scenario: verified user gets full contact bundle - -Status: `200` - -```json -{ - "status": "success", - "data": { - "user_id": "22222222-2222-4222-8222-222222222222", - "owner_full_name": "Rohan Mehta", - "business_name": "Sunrise PG", - "email": "owner@sunrisepg.in", - "whatsapp_phone": "+919876543210" - } -} -``` - -### Scenario: reveal limit reached - -Status: `429` - -```json -{ - "status": "error", - "message": "Free contact reveal limit reached. Please log in or sign up to continue.", - "code": "CONTACT_REVEAL_LIMIT_REACHED", - "loginRedirect": "/login/signup" -} -``` - -## `GET /students/:userId/preferences` - -Returns the student's preference profile used for compatibility-aware discovery. - -### Request Contract - -- Auth required: Yes -- Ownership required: caller must match `:userId` -- Path param: - - `userId` must be a UUID - -### Scenario: fetch preferences - -Status: `200` - -```json -{ - "status": "success", - "data": [ - { - "preferenceKey": "food_habit", - "preferenceValue": "vegetarian" - }, - { - "preferenceKey": "smoking", - "preferenceValue": "no" - } - ] -} -``` - -### Scenario: caller requests another student's preferences - -Status: `403` - -```json -{ - "status": "error", - "message": "Forbidden" -} -``` - -## `PUT /students/:userId/preferences` - -Updates the student's preference profile. - -### Request Example - -```json -{ - "preferences": [ - { - "preferenceKey": "food_habit", - "preferenceValue": "eggetarian" - }, - { - "preferenceKey": "sleep_schedule", - "preferenceValue": "late_night" - } - ] -} -``` - -### Scenario: update succeeds - -Status: `200` - -```json -{ - "status": "success", - "data": [ - { - "preferenceKey": "food_habit", - "preferenceValue": "eggetarian" - }, - { - "preferenceKey": "sleep_schedule", - "preferenceValue": "late_night" - } - ] -} -``` - -### Scenario: validation failure (missing `preferences`) - -Status: `400` - -```json -{ - "status": "error", - "message": "Validation failed", - "errors": [ - { - "field": "body.preferences", - "message": "Invalid input: expected array, received undefined" - } - ] -} -``` - -## `POST /pg-owners/:userId/documents` - -Submits a verification document for PG owner approval. - -### Request Body - -```json -{ - "documentType": "property_document", - "documentUrl": "https://storage.example.com/verification/owner-property-proof.pdf" -} -``` - -### Scenario: document submission succeeds - -Status: `201` - -```json -{ - "status": "success", - "data": { - "request_id": "abf62d6a-2783-4dd1-a808-72d758fb18da", - "document_type": "property_document", - "document_url": "https://storage.example.com/verification/owner-property-proof.pdf", - "status": "pending", - "submitted_at": "2026-04-11T10:00:00.000Z" - } -} -``` - -### Scenario: caller is not the owner - -Status: `403` - -```json -{ - "status": "error", - "message": "Forbidden" -} -``` - -### Scenario: PG owner profile not found - -Status: `404` - -```json -{ - "status": "error", - "message": "PG owner profile not found" -} -``` - -### Scenario: pending request already exists - -Status: `409` - -```json -{ - "status": "error", - "message": "You already have a pending verification request" -} -``` - -## Profile and Contact Scenario Matrix - -| Scenario | Endpoint | Status | Key behavior | -| --- | --- | --- | --- | -| Student profile viewed by other user | `GET /students/:userId/profile` | `200` | email hidden | -| Student profile viewed by self | `GET /students/:userId/profile` | `200` | email included | -| PG owner profile viewed by other user | `GET /pg-owners/:userId/profile` | `200` | business phone hidden | -| Student contact reveal by guest | `GET /students/:userId/contact/reveal` | `200` | email only | -| PG owner contact reveal by verified user | `POST /pg-owners/:userId/contact/reveal` | `200` | email + WhatsApp phone | -| Student reads own preferences | `GET /students/:userId/preferences` | `200` | compatibility preference profile returned | -| Student updates preferences | `PUT /students/:userId/preferences` | `200` | preference profile updated for future matching | -| Guest quota exhausted | contact reveal routes | `429` | redirect hint included | -| Document submitted twice while pending | `POST /pg-owners/:userId/documents` | `409` | duplicate pending request blocked | - -## Integrator Notes - -- The student contact reveal route is `GET`, but the PG owner route is `POST` to avoid accidental caching or browser prefetch leaks. -- The PG owner contact reveal response can expose a WhatsApp phone number to verified users. -- The student contact reveal response is gate-controlled: verified users receive full contact, guests/unverified callers receive email-only. diff --git a/docs/api/properties.md b/docs/api/properties.md deleted file mode 100644 index 932bfa9..0000000 --- a/docs/api/properties.md +++ /dev/null @@ -1,316 +0,0 @@ -# Properties API - -Properties are the building-level records used by verified PG owners. Students cannot create or manage properties, but authenticated students can fetch property detail when viewing PG or hostel listings. - -## `GET /properties` - -Lists properties owned by the authenticated PG owner. - -### Request Contract - -- Auth required: Yes -- Role required: `pg_owner` -- Query params: - - `limit` - - `cursorTime` - - `cursorId` - -### Scenario: list own properties - -Status: `200` - -```json -{ - "status": "success", - "data": { - "items": [ - { - "property_id": "44444444-4444-4444-8444-444444444444", - "property_name": "Sunrise PG Viman Nagar", - "property_type": "pg", - "city": "Pune", - "locality": "Viman Nagar", - "status": "active", - "average_rating": 4.5, - "rating_count": 12, - "created_at": "2026-04-01T08:00:00.000Z", - "updated_at": "2026-04-05T08:00:00.000Z", - "amenity_count": 8, - "active_listing_count": 3 - } - ], - "nextCursor": null - } -} -``` - -### Scenario: wrong role - -Status: `403` - -```json -{ - "status": "error", - "message": "Forbidden" -} -``` - -## `POST /properties` - -Creates a property for a verified PG owner. - -### Request Body - -```json -{ - "propertyName": "Sunrise PG Viman Nagar", - "description": "Walking distance from Symbiosis and nearby tech parks.", - "propertyType": "pg", - "addressLine": "Lane 5, Viman Nagar", - "city": "Pune", - "locality": "Viman Nagar", - "landmark": "Near Phoenix Marketcity", - "pincode": "411014", - "latitude": 18.5679, - "longitude": 73.9143, - "houseRules": "No smoking inside rooms.", - "totalRooms": 22, - "amenityIds": [ - "2db2f8fc-d90c-47a1-aebb-c6fa9ea4450a", - "eec6f390-2906-4d50-bf26-4f937833c6f8" - ] -} -``` - -### Scenario: verified PG owner creates property - -Status: `201` - -```json -{ - "status": "success", - "data": { - "property_id": "44444444-4444-4444-8444-444444444444", - "owner_id": "22222222-2222-4222-8222-222222222222", - "property_name": "Sunrise PG Viman Nagar", - "description": "Walking distance from Symbiosis and nearby tech parks.", - "property_type": "pg", - "address_line": "Lane 5, Viman Nagar", - "city": "Pune", - "locality": "Viman Nagar", - "landmark": "Near Phoenix Marketcity", - "pincode": "411014", - "latitude": 18.5679, - "longitude": 73.9143, - "house_rules": "No smoking inside rooms.", - "total_rooms": 22, - "status": "active", - "average_rating": 0, - "rating_count": 0, - "amenities": [ - { - "amenityId": "2db2f8fc-d90c-47a1-aebb-c6fa9ea4450a", - "name": "Wi-Fi", - "category": "connectivity", - "iconName": "wifi" - } - ] - } -} -``` - -### Scenario: create fails for non-`pg_owner` - -Status: `403` - -```json -{ - "status": "error", - "message": "Forbidden" -} -``` - -### Scenario: create fails because owner is not verified - -Status: `403` - -```json -{ - "status": "error", - "message": "PG owner must be verified to perform this action" -} -``` - -### Scenario: validation failure - -Status: `400` - -```json -{ - "status": "error", - "message": "Validation failed", - "errors": [ - { - "field": "body.propertyName", - "message": "Property name must be at least 2 characters" - }, - { - "field": "body.city", - "message": "City is required" - } - ] -} -``` - -## `GET /properties/:propertyId` - -Fetches a property detail record. - -### Scenario: authenticated student views a property - -Status: `200` - -```json -{ - "status": "success", - "data": { - "property_id": "44444444-4444-4444-8444-444444444444", - "owner_id": "22222222-2222-4222-8222-222222222222", - "property_name": "Sunrise PG Viman Nagar", - "description": "Walking distance from Symbiosis and nearby tech parks.", - "property_type": "pg", - "address_line": "Lane 5, Viman Nagar", - "city": "Pune", - "locality": "Viman Nagar", - "landmark": "Near Phoenix Marketcity", - "pincode": "411014", - "latitude": 18.5679, - "longitude": 73.9143, - "house_rules": "No smoking inside rooms.", - "total_rooms": 22, - "status": "active", - "average_rating": 4.5, - "rating_count": 12, - "created_at": "2026-04-01T08:00:00.000Z", - "updated_at": "2026-04-05T08:00:00.000Z", - "amenities": [ - { - "amenityId": "2db2f8fc-d90c-47a1-aebb-c6fa9ea4450a", - "name": "Wi-Fi", - "category": "connectivity", - "iconName": "wifi" - } - ] - } -} -``` - -### Scenario: property not found - -Status: `404` - -```json -{ - "status": "error", - "message": "Property not found" -} -``` - -## `PUT /properties/:propertyId` - -Updates a property owned by the authenticated PG owner. - -### Request Example - -```json -{ - "description": "Now includes weekly room cleaning and breakfast.", - "landmark": "Near Phoenix Marketcity main gate", - "amenityIds": [ - "2db2f8fc-d90c-47a1-aebb-c6fa9ea4450a" - ] -} -``` - -### Scenario: update succeeds - -Status: `200` - -```json -{ - "status": "success", - "data": { - "property_id": "44444444-4444-4444-8444-444444444444", - "description": "Now includes weekly room cleaning and breakfast.", - "landmark": "Near Phoenix Marketcity main gate" - } -} -``` - -### Scenario: no valid fields provided - -Status: `400` - -```json -{ - "status": "error", - "message": "No valid fields provided for update" -} -``` - -### Scenario: property not found or not owned - -Status: `404` - -```json -{ - "status": "error", - "message": "Property not found" -} -``` - -## `DELETE /properties/:propertyId` - -Soft-deletes a property when it no longer has active listings. - -### Scenario: delete succeeds - -Status: `200` - -```json -{ - "status": "success", - "data": { - "propertyId": "44444444-4444-4444-8444-444444444444", - "deleted": true - } -} -``` - -### Scenario: delete blocked by active listings - -Status: `409` - -```json -{ - "status": "error", - "message": "Deactivate or remove all active listings before deleting this property" -} -``` - -### Scenario: property not found - -Status: `404` - -```json -{ - "status": "error", - "message": "Property not found" -} -``` - -## Integrator Notes - -- The service requires the PG owner to be verified before create, update, or delete operations. -- If address or coordinate fields are changed on the property, the service cascades those location updates to linked `pg_room` and `hostel_bed` listings in the same transaction. -- Property reads are broader than writes: any authenticated user can fetch a property by ID. diff --git a/docs/api/ratings-and-reports.md b/docs/api/ratings-and-reports.md deleted file mode 100644 index 7b302b9..0000000 --- a/docs/api/ratings-and-reports.md +++ /dev/null @@ -1,429 +0,0 @@ -# Ratings and Reports API - -Ratings are the reputation layer built on top of confirmed connections. Reports are the moderation layer for disputed ratings. - -## Ratings - -### `POST /ratings` - -Submits a rating for either a user or a property, anchored to a confirmed connection. - -### Request Body - -Minimal user-rating request: - -```json -{ - "connectionId": "77777777-7777-4777-8777-777777777777", - "revieweeType": "user", - "revieweeId": "22222222-2222-4222-8222-222222222222", - "overallScore": 5 -} -``` - -Full property-rating request: - -```json -{ - "connectionId": "77777777-7777-4777-8777-777777777777", - "revieweeType": "property", - "revieweeId": "44444444-4444-4444-8444-444444444444", - "overallScore": 4, - "cleanlinessScore": 5, - "communicationScore": 4, - "reliabilityScore": 4, - "valueScore": 4, - "comment": "Clean property and smooth onboarding process." -} -``` - -### Scenario: submit user rating after confirmed connection - -Status: `201` - -```json -{ - "status": "success", - "data": { - "ratingId": "88888888-8888-4888-8888-888888888888", - "createdAt": "2026-04-12T10:00:00.000Z" - } -} -``` - -### Scenario: submit property rating after confirmed stay - -Status: `201` - -```json -{ - "status": "success", - "data": { - "ratingId": "88888888-8888-4888-8888-888888888888", - "createdAt": "2026-04-12T10:00:00.000Z" - } -} -``` - -### Scenario: comment omitted - -The request is valid without `comment`, and optional dimension scores may also be omitted. - -### Scenario: invalid `revieweeType` - -Status: `400` - -```json -{ - "status": "error", - "message": "Invalid revieweeType: must be 'user' or 'property'" -} -``` - -### Scenario: reviewee user not found - -Status: `404` - -```json -{ - "status": "error", - "message": "Reviewee user not found" -} -``` - -### Scenario: reviewee property not found - -Status: `404` - -```json -{ - "status": "error", - "message": "Reviewee property not found" -} -``` - -### Scenario: connection not found - -Status: `404` - -```json -{ - "status": "error", - "message": "Connection not found" -} -``` - -### Scenario: connection exists but is not confirmed - -Status: `422` - -```json -{ - "status": "error", - "message": "Ratings can only be submitted for confirmed connections" -} -``` - -### Scenario: caller tries to rate themselves or an invalid party - -Status: `422` - -```json -{ - "status": "error", - "message": "The reviewee is not a valid party to this connection, or you cannot rate yourself" -} -``` - -### Scenario: duplicate rating - -Status: `409` - -```json -{ - "status": "error", - "message": "You have already submitted a rating for this connection and reviewee" -} -``` - -## `GET /ratings/connection/:connectionId` - -Returns ratings for one connection from the caller's perspective. - -### Scenario: success - -Status: `200` - -```json -{ - "status": "success", - "data": { - "myRatings": [ - { - "ratingId": "88888888-8888-4888-8888-888888888888", - "reviewerId": "11111111-1111-4111-8111-111111111111", - "revieweeType": "user", - "revieweeId": "22222222-2222-4222-8222-222222222222", - "overallScore": 5, - "cleanlinessScore": null, - "communicationScore": null, - "reliabilityScore": null, - "valueScore": null, - "comment": "Very responsive and transparent.", - "createdAt": "2026-04-12T10:00:00.000Z" - } - ], - "theirRatings": [] - } -} -``` - -### Scenario: outsider tries to view connection ratings - -Status: `404` - -```json -{ - "status": "error", - "message": "Connection not found" -} -``` - -## `GET /ratings/user/:userId` - -Public endpoint that returns visible ratings received by a user. - -### Scenario: success - -Status: `200` - -```json -{ - "status": "success", - "data": { - "items": [ - { - "ratingId": "88888888-8888-4888-8888-888888888888", - "overallScore": 5, - "cleanlinessScore": 5, - "communicationScore": 5, - "reliabilityScore": 5, - "valueScore": 4, - "comment": "Very responsive and transparent.", - "createdAt": "2026-04-12T10:00:00.000Z", - "reviewer": { - "fullName": "Priya Sharma", - "profilePhotoUrl": null - } - } - ], - "nextCursor": null - } -} -``` - -## `GET /ratings/me/given` - -Returns the authenticated user's rating history, including `isVisible`. - -### Scenario: success - -Status: `200` - -```json -{ - "status": "success", - "data": { - "items": [ - { - "ratingId": "88888888-8888-4888-8888-888888888888", - "connectionId": "77777777-7777-4777-8777-777777777777", - "revieweeType": "user", - "revieweeId": "22222222-2222-4222-8222-222222222222", - "overallScore": 5, - "cleanlinessScore": 5, - "communicationScore": 5, - "reliabilityScore": 5, - "valueScore": 4, - "comment": "Very responsive and transparent.", - "isVisible": true, - "createdAt": "2026-04-12T10:00:00.000Z", - "reviewee": { - "fullName": "Rohan Mehta", - "profilePhotoUrl": null, - "type": "user" - } - } - ], - "nextCursor": null - } -} -``` - -## `GET /ratings/property/:propertyId` - -Public endpoint that returns visible ratings received by a property. - -### Scenario: success - -Status: `200` - -```json -{ - "status": "success", - "data": { - "items": [ - { - "ratingId": "88888888-8888-4888-8888-888888888888", - "overallScore": 4, - "cleanlinessScore": 5, - "communicationScore": 4, - "reliabilityScore": 4, - "valueScore": 4, - "comment": "Clean property and smooth onboarding process.", - "createdAt": "2026-04-12T10:00:00.000Z", - "reviewer": { - "fullName": "Priya Sharma", - "profilePhotoUrl": null - } - } - ], - "nextCursor": null - } -} -``` - -### Scenario: property not found - -Status: `404` - -```json -{ - "status": "error", - "message": "Property not found" -} -``` - -## Reports - -### `POST /ratings/:ratingId/report` - -Lets a party to the underlying connection report a rating. - -### Request Contract - -- Auth required: Yes -- Party-membership gate: Yes (must belong to the connection behind the rating) -- Path param: - - `ratingId` must be a UUID -- Body: - - `reason` enum: `fake | abusive | conflict_of_interest | other` - - `explanation` optional free-text context - -### Request Examples - -```json -{ - "reason": "fake", - "explanation": "The stay never happened and this review is fabricated." -} -``` - -```json -{ - "reason": "abusive", - "explanation": "The review contains personal abuse unrelated to the stay." -} -``` - -```json -{ - "reason": "conflict_of_interest", - "explanation": "The reviewer is closely connected to the property owner." -} -``` - -```json -{ - "reason": "other", - "explanation": "Additional context for the moderator." -} -``` - -### Scenario: report submitted successfully - -Status: `201` - -```json -{ - "status": "success", - "data": { - "reportId": "99999999-9999-4999-8999-999999999999", - "reporterId": "11111111-1111-4111-8111-111111111111", - "ratingId": "88888888-8888-4888-8888-888888888888", - "reason": "fake", - "status": "open", - "createdAt": "2026-04-12T11:00:00.000Z" - } -} -``` - -### Scenario: rating not found or reporter is not a party - -Status: `404` - -```json -{ - "status": "error", - "message": "Rating not found or you are not a party to this connection" -} -``` - -Explanation: this route intentionally returns a privacy-preserving `404` so outsiders cannot probe whether a rating exists. - -### Scenario: duplicate open report - -Status: `409` - -```json -{ - "status": "error", - "message": "A record with this value already exists" -} -``` - -Explanation: duplicate open reports hit the database unique constraint and are surfaced by the global PostgreSQL conflict handler. - -## Report Re-Submission Rule - -The code allows only one open report per reporter per rating at a time. - -- open report already exists: conflict -- previous report resolved: a new report may be submitted later - -## Admin Moderation Linkage - -After submission, open reports flow into the admin queue: - -- `GET /admin/report-queue` returns open reports oldest-first. -- `PATCH /admin/reports/:reportId/resolve` closes a report as: - - `resolved_kept` (rating remains visible), or - - `resolved_removed` (rating is hidden). - -When `resolved_removed` is used, rating visibility is turned off and aggregate recomputation is handled downstream by database trigger logic. - -## Ratings and Reports Scenario Matrix - -| Scenario | Status | Why | -| --- | --- | --- | -| Rate after confirmed connection | `201` | trust gate satisfied | -| Rate before confirmation | `422` | connection exists but is not yet eligible | -| Rate wrong target or self | `422` | reviewee is not a valid rating target for that connection | -| Public property ratings on missing property | `404` | property existence checked first | -| Report duplicate open report | `409` | partial unique index on open reports | - -## Integrator Notes - -- Ratings for users and properties share the same endpoint, distinguished by `revieweeType`. -- Public ratings endpoints only return visible ratings. -- Reviewers can still see their own hidden ratings in `GET /ratings/me/given` because that endpoint includes `isVisible`. diff --git a/docs/deployment/tier0.md b/docs/deployment/tier0.md index 076a5e3..11e9bc8 100644 --- a/docs/deployment/tier0.md +++ b/docs/deployment/tier0.md @@ -6,6 +6,17 @@ > someone who has never used any of these services before. **Backend URL:** `https://api.roomies.sumitly.app` **Last > verified:** April 2026. +> **Live API base URL:** `https://roomies-api.onrender.com/api/v1` +> +> **Live health check:** `https://roomies-api.onrender.com/api/v1/health` + +> **Operational note:** the checked-in `.env.render` currently mirrors deployed values and includes `TRUST_PROXY=false`. +> For Render this is a misconfiguration. Set `TRUST_PROXY=1` in the Render dashboard. +> +> **Secret rotation note:** if production credentials are pasted into chat, logs, tickets, screenshots, or docs, rotate +> those credentials before relying on production security. Never commit full database URLs, storage keys, API keys, +> OAuth client secrets, JWT secrets, or Redis passwords. + --- ## Read This First — What You're Actually Doing @@ -42,8 +53,20 @@ owns your repository. Render connects to GitHub to deploy your code automaticall **Azure** — You already have this at [portal.azure.com](https://portal.azure.com) with your student credits. You only need it for Blob Storage, which uses the always-free 5 GB tier. -**Brevo** — [brevo.com](https://brevo.com) You already have this configured locally. You just need to find your API key -(the `xkeysib-` one), not the SMTP key. +**Brevo** — [brevo.com](https://brevo.com) You already have this configured locally. You just need to find your REST +API key, not the SMTP key. + +### Current Tier 0 Resource Names + +| Resource | Name | URL/Endpoint | +| --------------------- | ----------------- | ------------------------------------------------------------ | +| Render Web Service | `roomies-api` | `https://roomies-api.onrender.com` | +| Neon Project | `roomies` | `ep-XXXXX.ap-southeast-1.aws.neon.tech` | +| Upstash Redis | `roomies-redis` | `upward-mule-75729.upstash.io:6379` | +| Azure Resource Group | `roomies-rg` | Azure Portal resource group | +| Azure Storage Account | `roomiesblob` | `roomiesblob.blob.core.windows.net` | +| Blob Container | `roomies-uploads` | `https://roomiesblob.blob.core.windows.net/roomies-uploads/` | +| Email Provider | Brevo API | HTTPS API | --- @@ -84,14 +107,14 @@ if (parsed.data.EMAIL_PROVIDER === "brevo-api") { console.error( `❌ EMAIL_PROVIDER is "brevo-api" but these required variables are missing:\n` + missing.map((v) => ` ${v}`).join("\n") + - `\n\nBREVO_API_KEY starts with "xkeysib-". Find it in Brevo → Settings → SMTP & API → API Keys.\n`, + `\n\nFind BREVO_API_KEY in Brevo → Settings → SMTP & API → API Keys.\n`, ); process.exit(1); } if (parsed.data.BREVO_API_KEY?.startsWith("xsmtpsib-")) { console.error( `❌ BREVO_API_KEY starts with "xsmtpsib-" which is an SMTP key, not an API key.\n` + - ` The API key starts with "xkeysib-". Find it in Brevo → Settings → SMTP & API → API Keys.\n`, + ` Find the API key in Brevo → Settings → SMTP & API → API Keys.\n`, ); process.exit(1); } @@ -357,13 +380,13 @@ On your database overview page, find the **REST API** section. But you don't wan for the standard Redis protocol. Look for: - **Endpoint:** something like `definite-robin-12345.upstash.io` -- **Port:** `6380` (always 6380 for TLS connections) +- **Port:** `6379` (TLS connection with `rediss://`) - **Password:** a long random string Construct your `REDIS_URL` like this: ``` -rediss://default:YOUR_UPSTASH_PASSWORD@YOUR-ENDPOINT.upstash.io:6380 +rediss://default:YOUR_UPSTASH_PASSWORD@YOUR-ENDPOINT.upstash.io:6379 ``` Note the double `s` in `rediss://` — this signals TLS. The username is always `default` for Upstash. @@ -444,8 +467,8 @@ The REST API uses port 443 (standard HTTPS) which is always open. You already have a Brevo account. Log in at [app.brevo.com](https://app.brevo.com). -Go to **Settings** (top right menu) → **API Keys** → find your existing API key or click **Generate a new API key**. The -API key starts with `xkeysib-`. Copy it — this is your `BREVO_API_KEY`. +Go to **Settings** (top right menu) → **API Keys** → find your existing REST API key or click **Generate a new API key**. +Copy it — this is your `BREVO_API_KEY`. This is different from your SMTP key (which starts with `xsmtpsib-`). The API key is what the REST API uses. @@ -482,9 +505,10 @@ ENV_FILE=.env.render # ─── Neon PostgreSQL ─────────────────────────────────────────────────────────── DATABASE_URL=postgresql://neondb_owner:YOUR_PASSWORD@ep-YOUR-ENDPOINT.ap-southeast-1.aws.neon.tech/neondb?sslmode=require +DB_POOL_MAX=5 # ─── Upstash Redis (TLS required — note the double-s in rediss://) ───────────── -REDIS_URL=rediss://default:YOUR_UPSTASH_PASSWORD@YOUR-ENDPOINT.upstash.io:6380 +REDIS_URL=rediss://default:REDACTED@upward-mule-75729.upstash.io:6379 # ─── JWT ────────────────────────────────────────────────────────────────────── JWT_SECRET=YOUR_GENERATED_64_CHAR_SECRET @@ -499,20 +523,27 @@ AZURE_STORAGE_CONTAINER=roomies-uploads # ─── Email via Brevo REST API (no SMTP, works on Render free tier) ───────────── EMAIL_PROVIDER=brevo-api -BREVO_API_KEY=xkeysib-YOUR_API_KEY_HERE -BREVO_SMTP_FROM=your-verified-sender@example.com +BREVO_API_KEY=REDACTED +BREVO_SMTP_FROM=sumity1642@gmail.com -# ─── CORS (allow everything for local testing) ──────────────────────────────── -ALLOWED_ORIGINS=http://localhost:5173 +# ─── CORS ──────────────────────────────────────────────────────────────────── +ALLOWED_ORIGINS=https://roomies-lilac.vercel.app # ─── Google OAuth ───────────────────────────────────────────────────────────── -GOOGLE_CLIENT_ID=xxxxxxxxxxxxxxxxxxxx -GOOGLE_CLIENT_SECRET=xxxxxxxxxxxxxxxx +GOOGLE_CLIENT_ID=535680244018-qbhv8r4iufvlh4qrlcro2g1n4et5uvh2.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=REDACTED -# ─── Trust Proxy (false for local, 1 for Render) ───────────────────────────── -TRUST_PROXY=false +# ─── Trust Proxy (Render must use 1 for real client IP extraction) ─────────── +TRUST_PROXY=1 ``` +**About `.env.render` vs Render dashboard values:** + +- `NODE_ENV` should be `production` in both places. +- If you keep a personal local override file, use something like `.env.render.local` (never commit it). +- The currently committed `.env.render` may still show historical values (`TRUST_PROXY=false`, localhost-only + `ALLOWED_ORIGINS`) that must be corrected in the Render dashboard for production behavior. + Generate fresh JWT secrets (run this command twice to get two different secrets): ```bash @@ -543,7 +574,7 @@ Server running on port 3000 [production] If PostgreSQL shows `unhealthy`, the most common cause is a wrong `DATABASE_URL`. Double-check that you copied the full Neon connection string without cutting any characters. -If Redis shows `unhealthy`, check that your `REDIS_URL` starts with `rediss://` (double s) and ends with `:6380`. +If Redis shows `unhealthy`, check that your `REDIS_URL` starts with `rediss://` (double s) and ends with `:6379`. Test the health endpoint: @@ -605,7 +636,8 @@ Add these variables: | `NODE_ENV` | `production` | No | | `PORT` | `10000` | No | | `DATABASE_URL` | your Neon connection string | **Yes** | -| `REDIS_URL` | `rediss://default:PASSWORD@ENDPOINT.upstash.io:6380` | **Yes** | +| `DB_POOL_MAX` | `5` | No | +| `REDIS_URL` | `rediss://default:REDACTED@upward-mule-75729.upstash.io:6379` | **Yes** | | `JWT_SECRET` | your 64-char secret | **Yes** | | `JWT_REFRESH_SECRET` | your other 64-char secret | **Yes** | | `JWT_EXPIRES_IN` | `15m` | No | @@ -614,13 +646,20 @@ Add these variables: | `AZURE_STORAGE_CONNECTION_STRING` | the full connection string from Azure | **Yes** | | `AZURE_STORAGE_CONTAINER` | `roomies-uploads` | No | | `EMAIL_PROVIDER` | `brevo-api` | No | -| `BREVO_API_KEY` | your `xkeysib-` key | **Yes** | +| `BREVO_API_KEY` | your Brevo API key | **Yes** | | `BREVO_SMTP_FROM` | your verified sender email | No | | `GOOGLE_CLIENT_ID` | your Google client ID | No | | `GOOGLE_CLIENT_SECRET` | your Google client secret | **Yes** | -| `ALLOWED_ORIGINS` | `https://roomies.sumitly.app` | No | +| `ALLOWED_ORIGINS` | `https://roomies-lilac.vercel.app` | No | | `TRUST_PROXY` | `1` | No | +`TRUST_PROXY=1` is security-relevant on Render. Without it, Express sees the proxy IP instead of the real client IP, +which breaks OTP IP throttling, guest fingerprinting in `contactRevealGate`, and Redis-backed auth rate limits. + +`ALLOWED_ORIGINS` should be the deployed frontend origin. For the current production frontend, use: + +`https://roomies-lilac.vercel.app` + **Important note about `PORT`:** Render injects its own PORT environment variable (usually 10000) into the process. Your `config/env.js` reads `PORT` and coerces it to a number. Setting `PORT=10000` here ensures the fallback is correct. In practice, Render's injected PORT takes precedence. @@ -880,7 +919,7 @@ requires Render Starter tier or Azure App Service. | Symptom | Most Likely Cause | Fix | | ---------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------- | | `database: "unhealthy"` on health | Wrong DATABASE_URL or Neon compute cold start | Check the Neon console — is compute active? Wait 30s and retry | -| `redis: "unhealthy"` on health | Wrong REDIS_URL format | Must be `rediss://` (double-s), port `6380` | +| `redis: "unhealthy"` on health | Wrong REDIS_URL format | Must be `rediss://` (double-s), port `6379` | | Emails not arriving | Wrong BREVO_API_KEY or unverified sender | Check Brevo → Transactional → Logs for errors | | Photos stuck at `processing:` | BullMQ media worker not started | Check Render logs for `Media processing worker started` | | HTTPS not working on custom domain | DNS not yet propagated or SSL not yet issued | Run `dig CNAME api.roomies.sumitly.app` and check Render's Custom Domains panel | diff --git a/docs/deployment/tier2.md b/docs/deployment/tier2.md index bd3dc3b..7252d27 100644 --- a/docs/deployment/tier2.md +++ b/docs/deployment/tier2.md @@ -400,7 +400,7 @@ az keyvault secret set $KV --name "DATABASE-URL" \ --value "postgresql://roomiesadmin:YOUR_PASSWORD@roomies-db.postgres.database.azure.com:5432/roomies_db?sslmode=require" az keyvault secret set $KV --name "REDIS-URL" \ - --value "rediss://default:YOUR_UPSTASH_PASSWORD@YOUR-ENDPOINT.upstash.io:6380" + --value "rediss://default:YOUR_UPSTASH_PASSWORD@YOUR-ENDPOINT.upstash.io:6379" # (or Azure Redis URL if you chose that in Phase 3) # Generate new JWT secrets — use fresh ones for Azure, not the same as Render @@ -829,5 +829,5 @@ az postgres flexible-server start --resource-group roomies-rg --name roomies-db | Key Vault | `roomies-kv` | `https://roomies-kv.vault.azure.net` | | Storage Account | `roomiesblob` | `roomiesblob.blob.core.windows.net` | | Blob Container | `roomies-uploads` | `https://roomiesblob.blob.core.windows.net/roomies-uploads/` | -| Redis (Upstash) | `roomies-redis` | `YOUR-ENDPOINT.upstash.io:6380` | +| Redis (Upstash) | `roomies-redis` | `YOUR-ENDPOINT.upstash.io:6379` | | Custom Domain | — | `api.roomies.sumitly.app` | diff --git a/migrations/001_initial_schema.sql b/migrations/001_initial_schema.sql index bacb506..27e33a0 100644 --- a/migrations/001_initial_schema.sql +++ b/migrations/001_initial_schema.sql @@ -1,8 +1,10 @@ --- Active: 1774765370673@@127.0.0.1@5432@roomies_db + CREATE EXTENSION IF NOT EXISTS postgis; CREATE EXTENSION IF NOT EXISTS pgcrypto; - +CREATE EXTENSION IF NOT EXISTS postgis_topology; +CREATE EXTENSION IF NOT EXISTS fuzzystrmatch; +CREATE EXTENSION IF NOT EXISTS postgis_tiger_geocoder; DO $$ BEGIN CREATE TYPE account_status_enum AS ENUM ('active', 'suspended', 'banned', 'deactivated'); EXCEPTION WHEN duplicate_object THEN NULL; diff --git a/migrations/002_verification_event_outbox.sql b/migrations/002_verification_event_outbox.sql index 3105126..bba065a 100644 --- a/migrations/002_verification_event_outbox.sql +++ b/migrations/002_verification_event_outbox.sql @@ -1,4 +1,5 @@ --- Active: 1774765370673@@127.0.0.1@5432@roomies_db +-- Migration 002: Add verification event outbox pattern +-- Creates outbox table for async notification processing on verification status changes DO $$ BEGIN IF NOT EXISTS ( diff --git a/migrations/003:profile_photo_url_pg_owner_profiles.sql b/migrations/003:profile_photo_url_pg_owner_profiles.sql new file mode 100644 index 0000000..884cccc --- /dev/null +++ b/migrations/003:profile_photo_url_pg_owner_profiles.sql @@ -0,0 +1,9 @@ +-- Active: 1777192065763@@127.0.0.1@5432@roomies_db +-- Migration 003: Add profile_photo_url to pg_owner_profiles +-- +-- student_profiles already has this column from migration 001. +-- pg_owner_profiles was missing it; adding it here so PG owners can upload +-- a profile photo through the same endpoint pattern as students. + +ALTER TABLE pg_owner_profiles + ADD COLUMN IF NOT EXISTS profile_photo_url TEXT; \ No newline at end of file diff --git a/migrations/004savedsearches.SQL b/migrations/004savedsearches.SQL new file mode 100644 index 0000000..cd5fb1a --- /dev/null +++ b/migrations/004savedsearches.SQL @@ -0,0 +1,21 @@ +-- Active: 1777192065763@@127.0.0.1@5432@roomies_db +ALTER TYPE notification_type_enum ADD VALUE IF NOT EXISTS 'saved_search_alert'; + +CREATE TABLE IF NOT EXISTS saved_searches ( + search_id UUID PRIMARY KEY DEFAULT gen_random_uuid (), + user_id UUID NOT NULL REFERENCES users (user_id) ON DELETE RESTRICT, + name VARCHAR(100) NOT NULL, + filters JSONB NOT NULL DEFAULT '{}', + last_alerted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_saved_searches_user_id ON saved_searches (user_id) +WHERE + deleted_at IS NULL; + +CREATE OR REPLACE TRIGGER trg_saved_searches_updated_at + BEFORE UPDATE ON saved_searches + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); \ No newline at end of file diff --git a/migrations/005: Roommate matching support.sql b/migrations/005: Roommate matching support.sql new file mode 100644 index 0000000..fa2fd80 --- /dev/null +++ b/migrations/005: Roommate matching support.sql @@ -0,0 +1,34 @@ +-- Active: 1777192065763@@127.0.0.1@5432@roomies_db +-- Migration 005: Roommate matching support +-- +-- Adds opt-in roommate-seeking flag and bio to student_profiles. +-- Creates roommate_blocks so students can hide specific users from their feed. + +ALTER TABLE student_profiles +ADD COLUMN IF NOT EXISTS looking_for_roommate BOOLEAN NOT NULL DEFAULT FALSE, +ADD COLUMN IF NOT EXISTS roommate_bio TEXT, +ADD COLUMN IF NOT EXISTS looking_updated_at TIMESTAMPTZ; + +-- Sparse index — only indexes rows that are actively seeking. +-- The roommate feed query filters on this exact condition so the index is hit on every feed request. +CREATE INDEX IF NOT EXISTS idx_student_roommate_lookup ON student_profiles ( + looking_updated_at DESC, + user_id +) +WHERE + looking_for_roommate = TRUE + AND deleted_at IS NULL; + +-- Block table — bidirectional blocking is handled at the service layer +-- by checking both (blocker=caller, blocked=candidate) and vice-versa. +CREATE TABLE IF NOT EXISTS roommate_blocks ( + blocker_id UUID NOT NULL REFERENCES users (user_id) ON DELETE RESTRICT, + blocked_id UUID NOT NULL REFERENCES users (user_id) ON DELETE RESTRICT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (blocker_id, blocked_id), + CONSTRAINT chk_no_self_block CHECK (blocker_id <> blocked_id) +); + +CREATE INDEX IF NOT EXISTS idx_roommate_blocks_blocker ON roommate_blocks (blocker_id); + +CREATE INDEX IF NOT EXISTS idx_roommate_blocks_blocked ON roommate_blocks (blocked_id); \ No newline at end of file diff --git a/migrations/006: Proper rent index.sql b/migrations/006: Proper rent index.sql new file mode 100644 index 0000000..f1123a1 --- /dev/null +++ b/migrations/006: Proper rent index.sql @@ -0,0 +1,117 @@ +-- Active: 1777192065763@@127.0.0.1@5432@roomies_db +-- Migration 006: Proper rent index +-- +-- rent_observations: one row per active listing event (created / renewed). +-- Written by a DB trigger on listings — no application code path can forget it. +-- +-- rent_index: materialised p25/p50/p75 per (city, locality, room_type). +-- Refreshed nightly by cron/rentIndexRefresh.js. +-- Listings JOIN this table to expose rentDeviation in API responses. +-- +-- Fix (originally migration 011): The trigger now also fires when expires_at +-- changes so that renewals (status stays 'active' but expires_at advances) +-- correctly record a 'listing_renewed' observation. The UPDATE condition was +-- broadened to: NEW.status = 'active' AND (OLD.status <> 'active' OR +-- NEW.expires_at IS DISTINCT FROM OLD.expires_at). + +CREATE TABLE IF NOT EXISTS rent_observations ( + observation_id UUID PRIMARY KEY DEFAULT gen_random_uuid (), + listing_id UUID NOT NULL REFERENCES listings (listing_id) ON DELETE CASCADE, + city VARCHAR(100) NOT NULL, + locality VARCHAR(100), -- normalised to LOWER(TRIM(...)), NULL for city-wide + room_type room_type_enum NOT NULL, + rent_per_month INTEGER NOT NULL, -- paise, same unit as listings.rent_per_month + observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + -- 'listing_created' | 'listing_renewed' — set by the trigger + source VARCHAR(30) NOT NULL DEFAULT 'listing_created', + CONSTRAINT chk_positive_rent CHECK (rent_per_month > 0) +); + +-- Index optimised for the cron aggregation query: GROUP BY city, locality, room_type +-- filtered to observations within the rolling 180-day window. +CREATE INDEX IF NOT EXISTS idx_rent_obs_aggregation ON rent_observations ( + city, + locality, + room_type, + observed_at DESC +); + +-- Fast lookup for cascading hard-deletes (cleanup cron). +CREATE INDEX IF NOT EXISTS idx_rent_obs_listing_id ON rent_observations (listing_id); + +-- Materialised rent index — upserted by cron, never written from app code. +-- locality IS NULL means the row is the city-wide fallback used when no +-- locality-specific data meets the minimum sample threshold. +CREATE TABLE IF NOT EXISTS rent_index ( + rent_index_id UUID PRIMARY KEY DEFAULT gen_random_uuid (), + city VARCHAR(100) NOT NULL, + locality VARCHAR(100), -- NULL = city-wide fallback + room_type room_type_enum NOT NULL, + p25 INTEGER NOT NULL, -- 25th percentile, paise + p50 INTEGER NOT NULL, -- median + p75 INTEGER NOT NULL, -- 75th percentile + sample_count INTEGER NOT NULL, + computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_rent_index UNIQUE (city, locality, room_type), + CONSTRAINT chk_rent_index_order CHECK ( + p25 <= p50 + AND p50 <= p75 + ) +); + +CREATE INDEX IF NOT EXISTS idx_rent_index_lookup ON rent_index (city, locality, room_type); + +-- Trigger function: fires after INSERT on listings (new listing goes active) +-- and after UPDATE when status flips to 'active' OR expires_at changes while +-- already active (i.e. a renewal). Runs inside the same transaction as the +-- listing write — observation is never lost. +-- +-- Fix vs original: the UPDATE branch previously only fired when OLD.status <> +-- 'active', which meant renewals (status unchanged, only expires_at advancing) +-- were silently dropped. Now we also fire when expires_at changes. +CREATE OR REPLACE FUNCTION capture_rent_observation() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' AND NEW.status = 'active' AND NEW.deleted_at IS NULL THEN + INSERT INTO rent_observations + (listing_id, city, locality, room_type, rent_per_month, source) + VALUES ( + NEW.listing_id, + NEW.city, + NULLIF(LOWER(TRIM(COALESCE(NEW.locality, ''))), ''), + NEW.room_type, + NEW.rent_per_month, + 'listing_created' + ); + + ELSIF TG_OP = 'UPDATE' + AND NEW.status = 'active' + AND NEW.deleted_at IS NULL + AND ( + OLD.status <> 'active' + OR NEW.expires_at IS DISTINCT FROM OLD.expires_at + ) + THEN + INSERT INTO rent_observations + (listing_id, city, locality, room_type, rent_per_month, source) + VALUES ( + NEW.listing_id, + NEW.city, + NULLIF(LOWER(TRIM(COALESCE(NEW.locality, ''))), ''), + NEW.room_type, + NEW.rent_per_month, + 'listing_renewed' + ); + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Watch both status and expires_at so renewals (expires_at advances while +-- status stays 'active') also fire the trigger. +DROP TRIGGER IF EXISTS trg_capture_rent_observation ON listings; + +CREATE TRIGGER trg_capture_rent_observation + AFTER INSERT OR UPDATE OF status, expires_at ON listings + FOR EACH ROW EXECUTE FUNCTION capture_rent_observation(); \ No newline at end of file diff --git a/migrations/007_fix_roommate_constraints.sql b/migrations/007_fix_roommate_constraints.sql new file mode 100644 index 0000000..b7d63eb --- /dev/null +++ b/migrations/007_fix_roommate_constraints.sql @@ -0,0 +1,72 @@ +-- Active: 1777192065763@@127.0.0.1@5432@roomies_db +-- Migration 007: Fix roommate_blocks FK cascade + redundant index + CHECK constraint +-- +-- Fixes applied from migration 005: +-- 1. Add CHECK constraint: looking_for_roommate = TRUE requires looking_updated_at IS NOT NULL +-- 2. Change roommate_blocks FKs from ON DELETE RESTRICT to ON DELETE CASCADE +-- 3. Drop redundant idx_roommate_blocks_blocker (covered by PK on blocker_id, blocked_id) +-- +-- Revised approach (safe for environments that already have data in roommate_blocks): +-- Instead of DROP TABLE + recreate (which destroys all block rows), we use +-- ALTER TABLE to swap FK actions and add the CHECK constraint in-place. +-- A fresh environment that has no roommate_blocks table yet gets a CREATE TABLE. + +-- Fix 1: Backfill timestamp — required before adding the CHECK constraint. +UPDATE student_profiles +SET looking_updated_at = NOW() +WHERE looking_for_roommate = TRUE + AND looking_updated_at IS NULL; + +-- Add CHECK constraint (safe now that all TRUE rows have a timestamp). +ALTER TABLE student_profiles +ADD CONSTRAINT chk_looking_has_timestamp CHECK ( + looking_for_roommate = FALSE + OR looking_updated_at IS NOT NULL +); + +-- Fix 2 + 3: Create roommate_blocks with CASCADE if it doesn't exist yet +-- (fresh environment), otherwise alter FKs in-place to preserve existing data. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_name = 'roommate_blocks' + ) THEN + -- Fresh environment: create with correct constraints from the start. + CREATE TABLE roommate_blocks ( + blocker_id UUID NOT NULL REFERENCES users (user_id) ON DELETE CASCADE, + blocked_id UUID NOT NULL REFERENCES users (user_id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (blocker_id, blocked_id), + CONSTRAINT chk_no_self_block CHECK (blocker_id <> blocked_id) + ); + ELSE + -- Existing environment: swap RESTRICT FKs to CASCADE without touching data. + ALTER TABLE roommate_blocks + DROP CONSTRAINT IF EXISTS roommate_blocks_blocker_id_fkey, + DROP CONSTRAINT IF EXISTS roommate_blocks_blocked_id_fkey; + + ALTER TABLE roommate_blocks + ADD CONSTRAINT roommate_blocks_blocker_id_fkey + FOREIGN KEY (blocker_id) REFERENCES users (user_id) ON DELETE CASCADE, + ADD CONSTRAINT roommate_blocks_blocked_id_fkey + FOREIGN KEY (blocked_id) REFERENCES users (user_id) ON DELETE CASCADE; + + -- Add self-block check only if it doesn't already exist. + IF NOT EXISTS ( + SELECT 1 FROM information_schema.check_constraints + WHERE constraint_name = 'chk_no_self_block' + ) THEN + ALTER TABLE roommate_blocks + ADD CONSTRAINT chk_no_self_block CHECK (blocker_id <> blocked_id); + END IF; + END IF; +END; +$$; + +-- Fix 3: Drop redundant index (blocker_id is the leftmost column of the PK, +-- so the PK index already satisfies all blocker_id lookups). +DROP INDEX IF EXISTS idx_roommate_blocks_blocker; + +-- Keep only the blocked_id index (needed for reverse-lookup: "who is blocking me"). +CREATE INDEX IF NOT EXISTS idx_roommate_blocks_blocked ON roommate_blocks (blocked_id); \ No newline at end of file diff --git a/migrations/008_fix_rent_index_redundant_index.sql b/migrations/008_fix_rent_index_redundant_index.sql new file mode 100644 index 0000000..b7cf378 --- /dev/null +++ b/migrations/008_fix_rent_index_redundant_index.sql @@ -0,0 +1,8 @@ +-- Active: 1777192065763@@127.0.0.1@5432@roomies_db +-- Migration 008: Drop redundant idx_rent_index_lookup on rent_index +-- +-- The UNIQUE constraint uq_rent_index (city, locality, room_type) already +-- creates a B-tree index covering the same columns. The explicit index +-- idx_rent_index_lookup is redundant and wastes storage + write overhead. + +DROP INDEX IF EXISTS idx_rent_index_lookup; \ No newline at end of file diff --git a/migrations/009_idx_listings_posted_by_status_city.sql b/migrations/009_idx_listings_posted_by_status_city.sql new file mode 100644 index 0000000..73195d6 --- /dev/null +++ b/migrations/009_idx_listings_posted_by_status_city.sql @@ -0,0 +1,20 @@ +-- Active: 1777192065763@@127.0.0.1@5432@roomies_db +-- Migration 009: Composite partial index for roommate feed city-filter EXISTS subquery +-- +-- The roommate feed's city filter uses an EXISTS subquery on listings: +-- WHERE l.posted_by = sp.user_id AND l.deleted_at IS NULL +-- AND l.status = 'active' AND LOWER(l.city) LIKE LOWER($n) ESCAPE '\' +-- +-- The existing idx_listings_posted_by covers posted_by alone but not the +-- full predicate. This index covers the common access pattern. +-- +-- Revised (originally migration 013): The original index keyed raw city but the +-- query uses LOWER(l.city) LIKE LOWER($n), so the planner could not use it. +-- The index is recreated as an expression index on LOWER(city) so the EXISTS +-- subquery is covered and the planner can use an index scan. + +DROP INDEX IF EXISTS idx_listings_posted_by_status_city; + +CREATE INDEX IF NOT EXISTS idx_listings_posted_by_status_city + ON listings (posted_by, status, LOWER(city)) + WHERE deleted_at IS NULL; \ No newline at end of file diff --git a/migrations/010_saved_search_cap.sql b/migrations/010_saved_search_cap.sql new file mode 100644 index 0000000..97ae3e6 --- /dev/null +++ b/migrations/010_saved_search_cap.sql @@ -0,0 +1,33 @@ +CREATE OR REPLACE FUNCTION enforce_saved_search_cap() +RETURNS TRIGGER AS $$ +DECLARE + active_count INTEGER; +BEGIN + IF NEW.deleted_at IS NOT NULL THEN + RETURN NEW; + END IF; + + PERFORM pg_advisory_xact_lock(hashtext(NEW.user_id::text)); + + SELECT COUNT(*)::int + INTO active_count + FROM saved_searches + WHERE user_id = NEW.user_id + AND deleted_at IS NULL + AND (TG_OP <> 'UPDATE' OR search_id <> NEW.search_id); + + IF active_count >= 10 THEN + RAISE EXCEPTION 'You can save at most 10 searches' + USING ERRCODE = '23514', + CONSTRAINT = 'saved_searches_active_cap_per_user'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_saved_searches_cap ON saved_searches; + +CREATE TRIGGER trg_saved_searches_cap + BEFORE INSERT OR UPDATE OF user_id, deleted_at ON saved_searches + FOR EACH ROW EXECUTE FUNCTION enforce_saved_search_cap(); diff --git a/package.json b/package.json index 8fc7c4d..6b39354 100644 --- a/package.json +++ b/package.json @@ -6,14 +6,10 @@ "scripts": { "dev": "nodemon src/server.js", "dev:azure": "ENV_FILE=.env.azure nodemon src/server.js", + "dev:render": "ENV_FILE=.env.render nodemon src/server.js", "start": "node src/server.js", "start:azure": "ENV_FILE=.env.azure node src/server.js", "seed:amenities": "ENV_FILE=.env.local node src/db/seeds/amenities.js", - "migrate": "node src/db/migrate.js", - "migrate:azure": "ENV_FILE=.env.azure node src/db/migrate.js", - "migrate:status": "node src/db/migrate.js --status", - "migrate:status:azure": "ENV_FILE=.env.azure node src/db/migrate.js --status", - "migrate:dry-run": "node src/db/migrate.js --dry-run", "test": "node --experimental-vm-modules node_modules/.bin/jest" }, "keywords": [], diff --git a/src/app.js b/src/app.js index cd78e84..363de72 100644 --- a/src/app.js +++ b/src/app.js @@ -1,5 +1,3 @@ -// src/app.js - import express from "express"; import helmet from "helmet"; import cors from "cors"; @@ -12,57 +10,58 @@ import { config } from "./config/env.js"; export const app = express(); -// Explicit proxy trust policy so req.ip is accurate behind reverse proxies -// (used by OTP verify IP throttling). TRUST_PROXY is parsed at startup into -// either a positive integer (hop count) or false — never a string — so Express -// receives exactly the type it expects. app.set("trust proxy", config.TRUST_PROXY); -// ─── Security headers ────────────────────────────────────────────────────── -app.use(helmet()); - -// ─── CORS ────────────────────────────────────────────────────────────────── -// origin: '*' with credentials: true is a browser spec violation — browsers -// block credentialed requests to wildcard origins. -// Development: origin: true reflects the incoming Origin header back, which -// works with credentials and allows any local origin. -// Production: explicit whitelist from config, parsed at startup by Zod. -// -// Guard: if we are running in production and ALLOWED_ORIGINS is empty, every -// credentialed cross-origin request will be silently rejected by the browser. +app.use( + helmet({ + crossOriginResourcePolicy: { policy: "cross-origin" }, + }), +); + if (config.NODE_ENV !== "development" && config.ALLOWED_ORIGINS.length === 0) { logger.fatal( "ALLOWED_ORIGINS is empty in a non-development environment. " + "Set ALLOWED_ORIGINS to a comma-separated list of allowed origins " + - "(e.g. https://roomies.in,https://www.roomies.in) in your env file.", + "(e.g. https://roomies-lilac.vercel.app) in your env file.", ); process.exit(1); } app.use( cors({ - origin: config.NODE_ENV === "development" ? true : config.ALLOWED_ORIGINS, + origin: (origin, callback) => { + if (!origin) return callback(null, true); + + if (config.NODE_ENV === "development") { + return callback(null, true); + } + + if (config.ALLOWED_ORIGINS.includes(origin)) { + return callback(null, true); + } + + logger.warn({ origin }, "CORS: origin not allowed"); + callback(null, false); + }, + credentials: true, + + exposedHeaders: ["X-Request-Id"], }), ); -// ─── Request logging ─────────────────────────────────────────────────────── app.use(pinoHttp({ logger })); -// ─── Body parsing ────────────────────────────────────────────────────────── app.use(express.json({ limit: "10mb" })); app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); -// ─── Static files (local uploads in dev) ─────────────────────────────────── if (config.STORAGE_ADAPTER === "local") { app.use("/uploads", express.static("uploads")); } -// ─── API routes ──────────────────────────────────────────────────────────── app.use("/api/v1", rootRouter); -// ─── 404 handler ─────────────────────────────────────────────────────────── app.use((req, res) => { res.status(404).json({ status: "error", @@ -70,5 +69,4 @@ app.use((req, res) => { }); }); -// ─── Global error handler ────────────────────────────────────────────────── app.use(errorHandler); diff --git a/src/cache/client.js b/src/cache/client.js index 104fcd1..5585f30 100644 --- a/src/cache/client.js +++ b/src/cache/client.js @@ -1,5 +1,3 @@ -// src/cache/client.js - import { createClient } from "redis"; import { config } from "../config/env.js"; import { logger } from "../logger/index.js"; diff --git a/src/config/constants.js b/src/config/constants.js index 5dc6021..bb59863 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -3,3 +3,4 @@ export const MAX_UPLOAD_SIZE_BYTES = 10 * 1024 * 1024; export const UPLOAD_FIELD_NAME = "photo"; +export const MAX_PHOTOS_PER_LISTING = 5; diff --git a/src/config/env.js b/src/config/env.js index 0839e2c..00d75e2 100644 --- a/src/config/env.js +++ b/src/config/env.js @@ -66,9 +66,12 @@ const envSchema = z.object({ // Cross-field guards after the schema parse enforce that the right variables // are present for the chosen provider, and exit with a clear per-variable // error message if anything is missing. + /** + * "brevo-api": For tier0 deployment, Render doesn't provide SMTP on the free tier, so we use the Brevo HTTP API as a fallback option. + */ EMAIL_PROVIDER: z - .enum(["ethereal", "brevo"], { - error: 'EMAIL_PROVIDER must be either "ethereal" or "brevo"', + .enum(["ethereal", "brevo", "brevo-api"], { + error: 'EMAIL_PROVIDER must be "ethereal", "brevo", or "brevo-api"', }) .default("ethereal"), @@ -103,6 +106,7 @@ const envSchema = z.object({ BREVO_SMTP_LOGIN: z.email({ error: "BREVO_SMTP_LOGIN must be a valid email address" }).optional(), BREVO_SMTP_KEY: z.string().min(1).optional(), BREVO_SMTP_FROM: z.email({ error: "BREVO_SMTP_FROM must be a valid email address" }).optional(), + BREVO_API_KEY: z.string().min(1).optional(), // ── Storage ───────────────────────────────────────────────────────────────── // @@ -201,6 +205,28 @@ if (parsed.data.EMAIL_PROVIDER === "brevo") { } } +// Add this block after the existing brevo guard: +if (parsed.data.EMAIL_PROVIDER === "brevo-api") { + const missing = []; + if (!parsed.data.BREVO_API_KEY) missing.push("BREVO_API_KEY"); + if (!parsed.data.BREVO_SMTP_FROM) missing.push("BREVO_SMTP_FROM"); + if (missing.length > 0) { + console.error( + `❌ EMAIL_PROVIDER is "brevo-api" but these required variables are missing:\n` + + missing.map((v) => ` ${v}`).join("\n") + + `\n\nBREVO_API_KEY starts with "xkeysib-". Find it in Brevo → Settings → SMTP & API → API Keys.\n`, + ); + process.exit(1); + } + if (parsed.data.BREVO_API_KEY?.startsWith("xsmtpsib-")) { + console.error( + `❌ BREVO_API_KEY starts with "xsmtpsib-" which is an SMTP key, not an API key.\n` + + ` The API key starts with "xkeysib-". Find it in Brevo → Settings → SMTP & API → API Keys.\n`, + ); + process.exit(1); + } +} + // ─── CROSS-FIELD GUARD: Azure Blob Storage ─────────────────────────────────── if (parsed.data.STORAGE_ADAPTER === "azure") { const missing = []; diff --git a/src/config/preferences.js b/src/config/preferences.js index bed0e98..c00df7b 100644 --- a/src/config/preferences.js +++ b/src/config/preferences.js @@ -1,34 +1,6 @@ -// src/config/preferences.js -// -// ─── IMMUTABILITY GUARANTEE ─────────────────────────────────────────────────── -// -// PREFERENCE_DEFINITIONS and preferenceMetadata are module-level catalogs that -// are also exported for use by validators and controllers. If an importer were -// to mutate either (e.g. push a new entry into PREFERENCE_DEFINITIONS.values or -// reassign a preferenceKey), allowedValuesByKey — which is built once at module -// load — would silently become stale, causing validation to accept or reject -// values based on the old snapshot while the exported array reflects the new one. -// -// The fix is to deep-freeze everything before building allowedValuesByKey. -// Object.freeze is shallow, so we apply it recursively via deepFreeze. The Map -// itself is then built from the frozen objects, after which no caller can alter -// the underlying source material. The Map is an internal object and never -// exported directly, so it does not need freezing, but the defensive-copy -// contract in getAllowedPreferenceValues still protects against external mutation -// of any returned Set. - -/** - * Recursively freezes an object and all its enumerable property values that are - * themselves objects or arrays. Primitive values pass through unchanged. - * - * @template T - * @param {T} obj - * @returns {T} - the same reference, now deeply frozen - */ const deepFreeze = (obj) => { if (obj === null || typeof obj !== "object") return obj; - // Freeze nested values first (post-order traversal) before freezing the - // container, so the freeze of the container does not block property access. + Object.keys(obj).forEach((key) => deepFreeze(obj[key])); Object.freeze(obj); return obj; @@ -97,9 +69,6 @@ export const PREFERENCE_DEFINITIONS = deepFreeze([ }, ]); -// Internal map — never exported directly. Built after deepFreeze so the source -// material is immutable by the time the Map is constructed. All external access -// goes through getAllowedPreferenceValues which returns a defensive copy. const allowedValuesByKey = new Map( PREFERENCE_DEFINITIONS.map((definition) => [ definition.preferenceKey, @@ -107,24 +76,12 @@ const allowedValuesByKey = new Map( ]), ); -// Returns the set of allowed values for a given preference key. -// -// IMPORTANT: returns a DEFENSIVE COPY of the internal Set, not the Set itself. -// Returning the internal Set directly would allow callers to mutate the shared -// catalog via set.add() or set.clear(), silently corrupting every subsequent -// lookup for the life of the process. A defensive copy means callers can freely -// iterate, spread, or pass the result without risk of shared-state corruption. -// -// The allocation cost (one new Set per call) is negligible — this is called -// only during request validation, not in hot inner loops. export const getAllowedPreferenceValues = (preferenceKey) => { const values = allowedValuesByKey.get(preferenceKey); - // Return a copy so callers cannot mutate the shared internal catalog. + return values ? new Set(values) : new Set(); }; -// Database uniqueness is on (user_id/listing_id, preference_key), so -// duplicate keys are collapsed with last-write-wins semantics before inserts. export const dedupePreferencesByKey = (preferences) => { const byKey = new Map(); for (const preference of preferences) { @@ -136,9 +93,6 @@ export const dedupePreferencesByKey = (preferences) => { .map(([preferenceKey, preferenceValue]) => ({ preferenceKey, preferenceValue })); }; -// preferenceMetadata is also deep-frozen so that the exported catalog object -// and the nested PREFERENCE_DEFINITIONS reference it holds are both immutable. -// This keeps the metadata and the allowedValuesByKey map permanently in sync. export const preferenceMetadata = deepFreeze({ preferences: PREFERENCE_DEFINITIONS, }); diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js index f8a0a07..e192ea9 100644 --- a/src/controllers/auth.controller.js +++ b/src/controllers/auth.controller.js @@ -1,23 +1,20 @@ // src/controllers/auth.controller.js +// +// Token transport strategy (mobile + web): +// - Cookies are always set (HttpOnly, Secure) — browser clients use these. +// - The JSON body includes tokens ONLY when the request signals it is a +// native mobile client, detected via the X-Client-Type: mobile header. +// Android / iOS clients must send this header to receive tokens in the body. +// - Browser clients (no header) get { authenticated: true } — no token leak. +// +// This keeps the API secure for browsers while remaining compatible with native +// apps that cannot access HttpOnly cookies. import * as authService from "../services/auth.service.js"; import { parseTtlSeconds } from "../services/auth.service.js"; import { AppError } from "../middleware/errorHandler.js"; import { config } from "../config/env.js"; - -const ACCESS_COOKIE_OPTIONS = { - httpOnly: true, - secure: config.NODE_ENV === "production", - sameSite: "strict", - maxAge: parseTtlSeconds(config.JWT_EXPIRES_IN, 15 * 60) * 1000, -}; - -const REFRESH_COOKIE_OPTIONS = { - httpOnly: true, - secure: config.NODE_ENV === "production", - sameSite: "strict", - maxAge: parseTtlSeconds(config.JWT_REFRESH_EXPIRES_IN, 7 * 24 * 60 * 60) * 1000, -}; +import { ACCESS_COOKIE_OPTIONS, REFRESH_COOKIE_OPTIONS } from "../middleware/authenticate.js"; const setAuthCookies = (res, accessToken, refreshToken) => { res.cookie("accessToken", accessToken, ACCESS_COOKIE_OPTIONS); @@ -27,49 +24,40 @@ const setAuthCookies = (res, accessToken, refreshToken) => { const clearAuthCookies = (res) => { res.clearCookie("accessToken", { httpOnly: true, - secure: config.NODE_ENV === "production", - sameSite: "strict", + secure: ACCESS_COOKIE_OPTIONS.secure, + sameSite: ACCESS_COOKIE_OPTIONS.sameSite, }); res.clearCookie("refreshToken", { httpOnly: true, - secure: config.NODE_ENV === "production", - sameSite: "strict", + secure: REFRESH_COOKIE_OPTIONS.secure, + sameSite: REFRESH_COOKIE_OPTIONS.sameSite, }); }; -// Determines whether the caller is a non-browser client that manages its own -// token lifecycle (e.g. the Android app). When true, tokens are included in the -// JSON response body. When false, tokens are delivered only via HttpOnly cookies -// and the body contains only safe session metadata. -// -// Browser clients using cookies gain no benefit from receiving raw tokens in the -// body — they cannot read HttpOnly cookies from JavaScript anyway, and including -// the tokens in JSON directly undermines the XSS protection that HttpOnly provides -// by giving any script on the page an additional exfiltration surface. -// -// Android clients set X-Client-Transport: bearer to signal that they are managing -// tokens explicitly and expect them in the response body. -const isBearerTransport = (req) => req.headers["x-client-transport"] === "bearer"; - -// Builds the safe body payload for cookie-mode responses. Contains everything -// the browser UI needs (user identity, roles, verification state) without -// exposing the raw token strings that only the HttpOnly cookie transport should -// carry. The sid is included so the client can reference the current session -// (e.g. for the session management UI) without needing the token itself. -const buildSafeBody = (tokens) => ({ - user: tokens.user, - sid: tokens.sid, -}); - -// ─── Controllers ────────────────────────────────────────────────────────────── +/** + * Returns true when the caller is a native mobile app. + * Mobile clients MUST send `X-Client-Type: mobile` to receive tokens in the body. + * Browsers never send this header, so they only get HttpOnly cookies. + */ +const isMobileClient = (req) => req.headers["x-client-type"]?.toLowerCase() === "mobile"; + +/** + * Build the JSON data payload for auth responses. + * - Mobile: full token pair so the client can store them in secure storage. + * - Browser: no tokens in body; cookies carry the session. + */ +const authResponseData = (req, tokens) => { + if (isMobileClient(req)) { + return tokens; // { accessToken, refreshToken, user, sid } + } + return { user: tokens.user }; // cookies-only transport for browsers +}; export const register = async (req, res, next) => { try { const tokens = await authService.register(req.body); setAuthCookies(res, tokens.accessToken, tokens.refreshToken); - - const data = isBearerTransport(req) ? tokens : buildSafeBody(tokens); - res.status(201).json({ status: "success", data }); + res.status(201).json({ status: "success", data: authResponseData(req, tokens) }); } catch (err) { next(err); } @@ -79,9 +67,7 @@ export const login = async (req, res, next) => { try { const tokens = await authService.login(req.body); setAuthCookies(res, tokens.accessToken, tokens.refreshToken); - - const data = isBearerTransport(req) ? tokens : buildSafeBody(tokens); - res.json({ status: "success", data }); + res.json({ status: "success", data: authResponseData(req, tokens) }); } catch (err) { next(err); } @@ -124,11 +110,8 @@ export const refresh = async (req, res, next) => { } const tokens = await authService.refresh(incomingRefreshToken); - setAuthCookies(res, tokens.accessToken, tokens.refreshToken); - - const data = isBearerTransport(req) ? tokens : buildSafeBody(tokens); - res.json({ status: "success", data }); + res.json({ status: "success", data: authResponseData(req, tokens) }); } catch (err) { next(err); } @@ -193,9 +176,7 @@ export const googleCallback = async (req, res, next) => { try { const tokens = await authService.googleOAuth(req.body); setAuthCookies(res, tokens.accessToken, tokens.refreshToken); - - const data = isBearerTransport(req) ? tokens : buildSafeBody(tokens); - res.json({ status: "success", data }); + res.json({ status: "success", data: authResponseData(req, tokens) }); } catch (err) { next(err); } diff --git a/src/controllers/connection.controller.js b/src/controllers/connection.controller.js index cb8fc3c..b293f45 100644 --- a/src/controllers/connection.controller.js +++ b/src/controllers/connection.controller.js @@ -1,14 +1,5 @@ -// src/controllers/connection.controller.js - import * as connectionService from "../services/connection.service.js"; -// POST /api/v1/connections/:connectionId/confirm -// -// Either party calls this endpoint to record that the real-world interaction -// happened from their side. The service determines which flag to flip by -// comparing req.user.userId against the two party IDs on the row. No role -// restriction at the route level — both students and PG owners participate -// in two-sided confirmation. export const confirmConnection = async (req, res, next) => { try { const result = await connectionService.confirmConnection(req.user.userId, req.params.connectionId); @@ -18,11 +9,6 @@ export const confirmConnection = async (req, res, next) => { } }; -// GET /api/v1/connections/:connectionId -// -// Full detail for one connection. Both parties can see the full shape including -// both confirmation flags — so each party knows whether the other has confirmed -// yet. Third parties receive 404 (enforced in service via WHERE clause). export const getConnection = async (req, res, next) => { try { const result = await connectionService.getConnection(req.user.userId, req.params.connectionId); @@ -32,10 +18,6 @@ export const getConnection = async (req, res, next) => { } }; -// GET /api/v1/connections/me -// -// The authenticated user's full connection dashboard — all connections they are -// a party to, regardless of role. Supports filtering and keyset pagination. export const getMyConnections = async (req, res, next) => { try { const result = await connectionService.getMyConnections(req.user.userId, req.query); diff --git a/src/controllers/interest.controller.js b/src/controllers/interest.controller.js index dbaceb8..355991e 100644 --- a/src/controllers/interest.controller.js +++ b/src/controllers/interest.controller.js @@ -1,17 +1,11 @@ -// src/controllers/interest.controller.js - import * as interestService from "../services/interest.service.js"; export const createInterestRequest = async (req, res, next) => { try { - // Pass the full body object (or a safe default) so the service can - // destructure { message } from it. Passing req.body?.message directly - // would give the service a string instead of an object, causing a - // TypeError when it tries to destructure { message } from a primitive. const result = await interestService.createInterestRequest( req.user.userId, req.params.listingId, - req.body ?? {}, // always an object; message is optional inside + req.body ?? {}, ); res.status(201).json({ status: "success", data: result }); } catch (err) { @@ -19,10 +13,6 @@ export const createInterestRequest = async (req, res, next) => { } }; -// GET /api/v1/interests/:interestId -// Returns full detail for one interest request. The service enforces that the -// caller is one of the two parties — no role restriction at the route level -// because both students (sender) and posters (receiver) need this endpoint. export const getInterestRequest = async (req, res, next) => { try { const result = await interestService.getInterestRequest(req.user.userId, req.params.interestId); diff --git a/src/controllers/listing.controller.js b/src/controllers/listing.controller.js index 563ca76..4360338 100644 --- a/src/controllers/listing.controller.js +++ b/src/controllers/listing.controller.js @@ -1,5 +1,3 @@ -// src/controllers/listing.controller.js - import * as listingService from "../services/listing.service.js"; export const createListing = async (req, res, next) => { @@ -20,8 +18,6 @@ export const getListing = async (req, res, next) => { } }; -// userId is nullable: req.user is undefined for guests (optionalAuthenticate ran). -// Passing null signals the service to skip compatibility scoring. export const searchListings = async (req, res, next) => { try { const userId = req.user?.userId ?? null; diff --git a/src/controllers/listingAnalytics.controller.js b/src/controllers/listingAnalytics.controller.js new file mode 100644 index 0000000..7715a58 --- /dev/null +++ b/src/controllers/listingAnalytics.controller.js @@ -0,0 +1,12 @@ +// src/controllers/listingAnalytics.controller.js + +import { getListingAnalytics } from "../services/listingAnalytics.service.js"; + +export const getListingAnalyticsHandler = async (req, res, next) => { + try { + const result = await getListingAnalytics(req.user.userId, req.params.listingId); + res.json({ status: "success", data: result }); + } catch (err) { + next(err); + } +}; diff --git a/src/controllers/listingRenewal.controller.js b/src/controllers/listingRenewal.controller.js new file mode 100644 index 0000000..1a9e0c0 --- /dev/null +++ b/src/controllers/listingRenewal.controller.js @@ -0,0 +1,12 @@ +// src/controllers/listingRenewal.controller.js + +import { renewListing } from "../services/listingRenewal.service.js"; + +export const renewListingHandler = async (req, res, next) => { + try { + const result = await renewListing(req.user.userId, req.params.listingId); + res.json({ status: "success", data: result }); + } catch (err) { + next(err); + } +}; diff --git a/src/controllers/notification.controller.js b/src/controllers/notification.controller.js index 0b41b7b..d78c5dc 100644 --- a/src/controllers/notification.controller.js +++ b/src/controllers/notification.controller.js @@ -1,8 +1,5 @@ -// src/controllers/notification.controller.js - import * as notificationService from "../services/notification.service.js"; -// GET /api/v1/notifications export const getFeed = async (req, res, next) => { try { const result = await notificationService.getFeed(req.user.userId, req.query); @@ -12,7 +9,6 @@ export const getFeed = async (req, res, next) => { } }; -// GET /api/v1/notifications/unread-count export const getUnreadCount = async (req, res, next) => { try { const result = await notificationService.getUnreadCount(req.user.userId); @@ -22,7 +18,6 @@ export const getUnreadCount = async (req, res, next) => { } }; -// POST /api/v1/notifications/mark-read export const markRead = async (req, res, next) => { try { const result = await notificationService.markRead(req.user.userId, req.body); diff --git a/src/controllers/pgOwner.controller.js b/src/controllers/pgOwner.controller.js index b7e8548..b483b18 100644 --- a/src/controllers/pgOwner.controller.js +++ b/src/controllers/pgOwner.controller.js @@ -1,5 +1,3 @@ -// src/controllers/pgOwner.controller.js - import * as pgOwnerService from "../services/pgOwner.service.js"; import { AppError } from "../middleware/errorHandler.js"; @@ -23,11 +21,6 @@ export const updateProfile = async (req, res, next) => { export const revealContact = async (req, res, next) => { try { - // contactRevealGate must always run before this controller. If it is absent - // (programming error — gate removed from the route definition), fail closed - // with a 500 rather than accidentally disclosing the full contact bundle. - // Defaulting to false here would silently broaden access; a loud 500 forces - // the developer to notice and fix the route configuration. if (!req.contactReveal) { return next(new AppError("Contact reveal gate context is missing — internal configuration error", 500)); } diff --git a/src/controllers/photo.controller.js b/src/controllers/photo.controller.js index 619b7ec..6fcebc7 100644 --- a/src/controllers/photo.controller.js +++ b/src/controllers/photo.controller.js @@ -1,5 +1,3 @@ -// src/controllers/photo.controller.js - import * as photoService from "../services/photo.service.js"; import { rm } from "node:fs/promises"; import { UPLOAD_FIELD_NAME } from "../config/constants.js"; @@ -9,7 +7,6 @@ const cleanupStagedFile = async (filePath) => { try { await rm(filePath, { force: true }); } catch (cleanupErr) { - // Log but don't throw — preserve the original API error response. logger.warn({ msg: "Failed to cleanup staged file", filePath, err: cleanupErr }); } }; @@ -26,12 +23,10 @@ export const uploadPhoto = async (req, res, next) => { const result = await photoService.enqueuePhotoUpload( req.user.userId, req.params.listingId, - req.file.path, // Absolute path to the staged file on disk - req.body.displayOrder, // May be undefined — service handles the default + req.file.path, + req.body.displayOrder, ); - // 202 Accepted: the request has been received and queued. The photo is not - // yet available. The client should poll GET /photos to observe completion. res.status(202).json({ status: "success", data: result }); } catch (err) { await cleanupStagedFile(req.file?.path); @@ -41,7 +36,7 @@ export const uploadPhoto = async (req, res, next) => { export const getPhotos = async (req, res, next) => { try { - const photos = await photoService.getListingPhotos(req.params.listingId); + const photos = await photoService.getListingPhotos(req.params.listingId, { ownerId: req.user?.userId }); res.json({ status: "success", data: photos }); } catch (err) { next(err); diff --git a/src/controllers/preferences.controller.js b/src/controllers/preferences.controller.js index 112995c..2f3d13a 100644 --- a/src/controllers/preferences.controller.js +++ b/src/controllers/preferences.controller.js @@ -1,5 +1,3 @@ -// src/controllers/preferences.controller.js - import * as preferencesService from "../services/preferences.service.js"; export const getMetadata = async (req, res, next) => { diff --git a/src/controllers/profilePhoto.controller.js b/src/controllers/profilePhoto.controller.js new file mode 100644 index 0000000..319234c --- /dev/null +++ b/src/controllers/profilePhoto.controller.js @@ -0,0 +1,80 @@ +// src/controllers/profilePhoto.controller.js + +import { rm } from "node:fs/promises"; +import { logger } from "../logger/index.js"; +import { UPLOAD_FIELD_NAME } from "../config/constants.js"; +import * as profilePhotoService from "../services/profilePhoto.service.js"; + +// ─── shared helper ──────────────────────────────────────────────────────────── + +const cleanupStagedFile = async (filePath) => { + if (!filePath) return; + try { + await rm(filePath, { force: true }); + } catch (err) { + logger.warn({ filePath, err }, "profilePhoto: failed to clean up staged file"); + } +}; + +// ─── student ───────────────────────────────────────────────────────────────── + +export const uploadStudentPhoto = async (req, res, next) => { + try { + if (!req.file) { + return res.status(400).json({ + status: "error", + message: `No file uploaded — send the image under the field name '${UPLOAD_FIELD_NAME}'`, + }); + } + + const result = await profilePhotoService.uploadStudentPhoto(req.user.userId, req.params.userId, req.file.path); + + // Staging file has been consumed by sharp; remove it. + await cleanupStagedFile(req.file.path); + + res.json({ status: "success", data: result }); + } catch (err) { + await cleanupStagedFile(req.file?.path); + next(err); + } +}; + +export const deleteStudentPhoto = async (req, res, next) => { + try { + const result = await profilePhotoService.deleteStudentPhoto(req.user.userId, req.params.userId); + res.json({ status: "success", data: result }); + } catch (err) { + next(err); + } +}; + +// ─── pg owner ──────────────────────────────────────────────────────────────── + +export const uploadPgOwnerPhoto = async (req, res, next) => { + try { + if (!req.file) { + return res.status(400).json({ + status: "error", + message: `No file uploaded — send the image under the field name '${UPLOAD_FIELD_NAME}'`, + }); + } + + const result = await profilePhotoService.uploadPgOwnerPhoto(req.user.userId, req.params.userId, req.file.path); + + await cleanupStagedFile(req.file.path); + + res.json({ status: "success", data: result }); + } catch (err) { + await cleanupStagedFile(req.file?.path); + next(err); + } +}; + +export const deletePgOwnerPhoto = async (req, res, next) => { + try { + const result = await profilePhotoService.deletePgOwnerPhoto(req.user.userId, req.params.userId); + res.json({ status: "success", data: result }); + } catch (err) { + next(err); + } +}; diff --git a/src/controllers/property.controller.js b/src/controllers/property.controller.js index f40a116..e36033c 100644 --- a/src/controllers/property.controller.js +++ b/src/controllers/property.controller.js @@ -1,5 +1,3 @@ -// src/controllers/property.controller.js - import * as propertyService from "../services/property.service.js"; export const createProperty = async (req, res, next) => { diff --git a/src/controllers/rating.controller.js b/src/controllers/rating.controller.js index 5fa6f27..ce7eb68 100644 --- a/src/controllers/rating.controller.js +++ b/src/controllers/rating.controller.js @@ -1,19 +1,5 @@ -// src/controllers/rating.controller.js -// -// Thin controller wrappers — no business logic here. All validation has already -// run via the validate() middleware; all service functions throw AppError on -// any known failure and the global error handler converts those to the correct -// HTTP status codes. - import * as ratingService from "../services/rating.service.js"; -// POST /api/v1/ratings -// Submit a rating for a user or property, anchored to a confirmed connection. -// Returns 201 with { ratingId, createdAt } on success. -// Possible error codes from the service: -// 404 — reviewee not found, or connection not found / caller not a party -// 409 — rating already submitted for this (reviewer, connection, reviewee) triple -// 422 — connection exists but confirmation_status is not 'confirmed' export const submitRating = async (req, res, next) => { try { const result = await ratingService.submitRating(req.user.userId, req.body); @@ -23,10 +9,6 @@ export const submitRating = async (req, res, next) => { } }; -// GET /api/v1/ratings/connection/:connectionId -// Returns both ratings for a connection from the caller's perspective: -// { myRatings: Rating[], theirRatings: Rating[] } -// Only the two connection parties can call this. Third parties get 404. export const getRatingsForConnection = async (req, res, next) => { try { const result = await ratingService.getRatingsForConnection(req.user.userId, req.params.connectionId); @@ -36,10 +18,6 @@ export const getRatingsForConnection = async (req, res, next) => { } }; -// GET /api/v1/ratings/user/:userId -// Public rating history for any user. No authentication required at the route -// level — the service enforces no caller-identity check. Returns paginated -// ratings the user has received (reviewee_type = 'user'). export const getPublicRatings = async (req, res, next) => { try { const result = await ratingService.getPublicRatings(req.params.userId, req.query); @@ -49,9 +27,6 @@ export const getPublicRatings = async (req, res, next) => { } }; -// GET /api/v1/ratings/me/given -// The authenticated user's full history of ratings they have submitted. -// Keyset paginated, newest first. Includes reviewee summary (name, photo, type). export const getMyGivenRatings = async (req, res, next) => { try { const result = await ratingService.getMyGivenRatings(req.user.userId, req.query); @@ -61,10 +36,6 @@ export const getMyGivenRatings = async (req, res, next) => { } }; -// GET /api/v1/ratings/property/:propertyId -// Public rating history for a property. No authentication required. -// Returns paginated ratings the property has received (reviewee_type = 'property'). -// 404 if the property doesn't exist or is soft-deleted. export const getPublicPropertyRatings = async (req, res, next) => { try { const result = await ratingService.getPublicPropertyRatings(req.params.propertyId, req.query); diff --git a/src/controllers/rentIndex.controller.js b/src/controllers/rentIndex.controller.js new file mode 100644 index 0000000..851e863 --- /dev/null +++ b/src/controllers/rentIndex.controller.js @@ -0,0 +1,21 @@ +// src/controllers/rentIndex.controller.js + +import * as rentIndexService from "../services/rentIndex.service.js"; + +export const getRentIndex = async (req, res, next) => { + try { + const { city, locality, roomType } = req.query; + + if (!city || !locality || !roomType) { + return res.status(400).json({ + status: "error", + message: "Missing required query parameters: city, locality, roomType", + }); + } + + const result = await rentIndexService.getRentIndex({ city, locality, roomType }); + res.json({ status: "success", data: result }); + } catch (err) { + next(err); + } +}; diff --git a/src/controllers/report.controller.js b/src/controllers/report.controller.js index 1cb2c07..c4f49e8 100644 --- a/src/controllers/report.controller.js +++ b/src/controllers/report.controller.js @@ -1,16 +1,9 @@ // src/controllers/report.controller.js -// -// Thin controller wrappers for the rating report pipeline. No business logic -// here — validation has already run through the validate() middleware and all -// service functions throw AppError on known failures. import * as reportService from "../services/report.service.js"; -const UUID_V1_TO_V5_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +// ─── submitReport ───────────────────────────────────────────────────────────── -// POST /api/v1/ratings/:ratingId/report -// Any authenticated user who is a party to the underlying connection can report -// a rating. The service enforces the party-membership check. export const submitReport = async (req, res, next) => { try { const result = await reportService.submitReport(req.user.userId, req.params.ratingId, req.body); @@ -20,50 +13,18 @@ export const submitReport = async (req, res, next) => { } }; -// GET /api/v1/admin/report-queue -// Admin-only. Returns open reports oldest-first with full context. export const getReportQueue = async (req, res, next) => { try { const { cursorTime, cursorId, limit } = req.query; - - const parsedCursorTime = - typeof cursorTime === "string" ? - (() => { - const dt = new Date(cursorTime); - return Number.isNaN(dt.getTime()) ? undefined : dt; - })() - : undefined; - - const parsedCursorId = - typeof cursorId === "string" && UUID_V1_TO_V5_REGEX.test(cursorId) ? cursorId : undefined; - - // Fix: Number("") === 0, which would cause the service to return no rows. - // Treat empty strings, whitespace-only strings, and any value that doesn't - // produce a finite positive integer as "not provided" (undefined), which - // causes the service to use its own default limit instead. - // - // Guard order: - // 1. Is limit even provided as a non-empty string? - // 2. Is the result a finite number? (rejects NaN from non-numeric strings) - // 3. Is it a positive integer? (rejects 0 and negative values) - const parsedLimit = typeof limit === "string" && limit.trim() !== "" ? Number(limit) : undefined; - - const safeParsedLimit = - Number.isFinite(parsedLimit) && Number.isInteger(parsedLimit) && parsedLimit > 0 ? parsedLimit : undefined; - - const result = await reportService.getReportQueue({ - cursorTime: parsedCursorTime, - cursorId: parsedCursorId, - limit: safeParsedLimit, - }); + const result = await reportService.getReportQueue({ cursorTime, cursorId, limit }); res.json({ status: "success", data: result }); } catch (err) { next(err); } }; -// PATCH /api/v1/admin/reports/:reportId/resolve -// Admin-only. Closes the report with either resolved_removed or resolved_kept. +// ─── resolveReport ──────────────────────────────────────────────────────────── + export const resolveReport = async (req, res, next) => { try { const result = await reportService.resolveReport(req.user.userId, req.params.reportId, req.body); diff --git a/src/controllers/roommate.controller.js b/src/controllers/roommate.controller.js new file mode 100644 index 0000000..72a30c5 --- /dev/null +++ b/src/controllers/roommate.controller.js @@ -0,0 +1,25 @@ +// src/controllers/roommate.controller.js + +import * as roommateService from "../services/roommate.service.js"; + +const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); + +export const getFeed = asyncHandler(async (req, res) => { + const result = await roommateService.getRoommateFeed(req.user.userId, req.query); + res.json({ status: "success", data: result }); +}); + +export const updateRoommateProfile = asyncHandler(async (req, res) => { + const result = await roommateService.updateRoommateProfile(req.user.userId, req.params.userId, req.body); + res.json({ status: "success", data: result }); +}); + +export const blockUser = asyncHandler(async (req, res) => { + const result = await roommateService.blockUser(req.user.userId, req.params.targetUserId); + res.json({ status: "success", data: result }); +}); + +export const unblockUser = asyncHandler(async (req, res) => { + const result = await roommateService.unblockUser(req.user.userId, req.params.targetUserId); + res.json({ status: "success", data: result }); +}); diff --git a/src/controllers/savedSearch.controller.js b/src/controllers/savedSearch.controller.js new file mode 100644 index 0000000..1ee8103 --- /dev/null +++ b/src/controllers/savedSearch.controller.js @@ -0,0 +1,39 @@ +// src/controllers/savedSearch.controller.js + +import * as savedSearchService from "../services/savedSearch.service.js"; + +export const create = async (req, res, next) => { + try { + const result = await savedSearchService.createSavedSearch(req.user.userId, req.body); + res.status(201).json({ status: "success", data: result }); + } catch (err) { + next(err); + } +}; + +export const list = async (req, res, next) => { + try { + const result = await savedSearchService.listSavedSearches(req.user.userId); + res.json({ status: "success", data: result }); + } catch (err) { + next(err); + } +}; + +export const update = async (req, res, next) => { + try { + const result = await savedSearchService.updateSavedSearch(req.user.userId, req.params.searchId, req.body); + res.json({ status: "success", data: result }); + } catch (err) { + next(err); + } +}; + +export const remove = async (req, res, next) => { + try { + const result = await savedSearchService.deleteSavedSearch(req.user.userId, req.params.searchId); + res.json({ status: "success", data: result }); + } catch (err) { + next(err); + } +}; diff --git a/src/controllers/student.controller.js b/src/controllers/student.controller.js index da91f74..8394118 100644 --- a/src/controllers/student.controller.js +++ b/src/controllers/student.controller.js @@ -1,5 +1,3 @@ -// src/controllers/student.controller.js - import * as studentService from "../services/student.service.js"; import { AppError } from "../middleware/errorHandler.js"; @@ -45,20 +43,10 @@ export const updatePreferences = async (req, res, next) => { export const revealContact = async (req, res, next) => { try { - // contactRevealGate MUST run before this controller. If it is absent - // (a programming error — gate removed from the route definition), fail - // closed with a 500 rather than accidentally disclosing the full contact - // bundle. Defaulting to emailOnly=true here would silently broaden access - // for guests on a misconfigured route; a loud 500 forces the developer to - // notice and fix the route configuration. if (!req.contactReveal) { return next(new AppError("Contact reveal gate context is missing — internal configuration error", 500)); } - // The gate's emailOnly decision is the single source of truth. The service - // no longer re-derives tier eligibility — it trusts this value entirely. - // This is the correct architecture: middleware decides WHO can see WHAT, - // and the service only fetches and shapes the data accordingly. const contact = await studentService.getStudentContactReveal(req.params.userId, req.contactReveal.emailOnly); res.json({ status: "success", data: contact }); diff --git a/src/controllers/verification.controller.js b/src/controllers/verification.controller.js index 96828fe..3a992b9 100644 --- a/src/controllers/verification.controller.js +++ b/src/controllers/verification.controller.js @@ -4,7 +4,11 @@ import * as verificationService from "../services/verification.service.js"; export const submitDocument = async (req, res, next) => { try { - const result = await verificationService.submitDocument(req.user.userId, req.params.userId, req.body); + const result = await verificationService.submitDocument( + req.user.userId, + req.user.userId, // targetUserId = self + req.body, + ); res.status(201).json({ status: "success", data: result }); } catch (err) { next(err); diff --git a/src/cron/expiryWarning.js b/src/cron/expiryWarning.js index 2f2edb5..7dbdcaa 100644 --- a/src/cron/expiryWarning.js +++ b/src/cron/expiryWarning.js @@ -1,68 +1,3 @@ -// src/cron/expiryWarning.js -// -// Scheduled maintenance job: send advance expiry warnings to posters whose -// active listings will expire within the next 7 days. -// -// ─── DESIGN NOTES ──────────────────────────────────────────────────────────── -// -// Warning window: we notify posters when their listing will expire within 7 -// days. This gives them enough lead time to renew. -// -// ─── IDEMPOTENCY — DURABLE INSERT BEFORE COMMIT ────────────────────────────── -// -// Previous design: SELECT candidate listings inside the transaction, COMMIT, -// then call enqueueNotification for each row. This created a TOCTOU window: -// two concurrent runners could both pass the NOT EXISTS check before either -// committed, then both enqueue jobs, resulting in duplicate notifications even -// though the BullMQ worker's idempotency_key ON CONFLICT prevents duplicate DB -// rows. Double-enqueuing is harmless for final correctness but wastes Redis -// resources and creates noisy log entries. -// -// Current design: for each candidate listing we INSERT a notifications row -// directly inside the transaction, using ON CONFLICT (idempotency_key) DO -// NOTHING with a deterministic key derived from the listing_id and the UTC -// calendar date. Only the rows actually inserted (rowCount contribution from -// the INSERT) are enqueued post-commit. Because the INSERT and the COMMIT are -// atomic, a concurrent runner that tries the same INSERT will hit the UNIQUE -// constraint and insert zero rows — it will enqueue nothing, which is correct. -// -// The idempotency_key format is: -// expiry_warning:{listing_id}:{YYYY-MM-DD} -// where the date is the UTC calendar day at the time the cron runs. This means: -// 1. Exactly one notification per listing per calendar day — the 24-hour -// NOT EXISTS window of the old design is now enforced at the unique-index -// level rather than through a soft SELECT check. -// 2. Re-running the cron multiple times on the same day (e.g. after a crash) -// safely produces no duplicates. -// 3. The next UTC calendar day produces a new key, so a listing expiring in -// a future window can still be warned on a subsequent day if needed. -// -// ─── CONCURRENT-RUNNER GUARD ───────────────────────────────────────────────── -// -// We continue to hold a transaction-scoped advisory lock -// (pg_try_advisory_xact_lock) to prevent two runners from even attempting the -// SELECT + INSERT simultaneously. The advisory lock is the first line of -// defence; the UNIQUE constraint on idempotency_key is the second. -// -// WHY TRANSACTION-SCOPED AND NOT SESSION-SCOPED: see the original design notes -// above the ADVISORY_LOCK_KEY constant. Summary: transaction-scoped locks -// are released automatically on COMMIT/ROLLBACK, preventing stale lock -// retention across connection-pool reuse. -// -// ─── NOTIFICATION WORKER MESSAGE ───────────────────────────────────────────── -// -// The notifications row inserted here carries message = null. The notification -// worker's NOTIFICATION_MESSAGES map (notificationWorker.js) provides the -// human-readable text when the job is processed. Storing null here and letting -// the worker fill in the message is consistent with how all other notification -// inserts work — the message is applied at INSERT time in the worker, not here. -// -// ─── OBSERVABILITY ─────────────────────────────────────────────────────────── -// -// Logs the number of warnings enqueued (rows returned by the INSERT, not just -// rows selected) so the number reflects actual new notifications, not -// re-selections of already-warned listings. - import cron from "node-cron"; import { pool } from "../db/client.js"; import { logger } from "../logger/index.js"; @@ -70,26 +5,18 @@ import { enqueueNotification } from "../workers/notificationQueue.js"; const SCHEDULE = process.env.CRON_EXPIRY_WARNING ?? "0 1 * * *"; const WARNING_WINDOW_DAYS = 7; -const ADVISORY_LOCK_KEY = 7001; // stable key: 7001 = expiryWarning cron +const ADVISORY_LOCK_KEY = 7001; -// Build a deterministic idempotency key for a given listing on the current UTC -// calendar day. The key is stable for the entire day regardless of what time -// the cron fires, which prevents duplicates on re-runs within the same day. const buildIdempotencyKey = (listingId, utcDateStr) => `expiry_warning:${listingId}:${utcDateStr}`; const runExpiryWarning = async () => { const startedAt = Date.now(); logger.info("cron:expiryWarning — starting run"); - // UTC calendar date as YYYY-MM-DD, used for idempotency key construction. const today = new Date().toISOString().slice(0, 10); const client = await pool.connect(); try { - // Begin the transaction BEFORE acquiring the lock. - // pg_try_advisory_xact_lock REQUIRES an active transaction — it is bound - // to the transaction lifecycle and released automatically on COMMIT or - // ROLLBACK. await client.query("BEGIN"); const { rows: lockRows } = await client.query("SELECT pg_try_advisory_xact_lock($1) AS acquired", [ @@ -105,11 +32,6 @@ const runExpiryWarning = async () => { return; } - // Find all active listings expiring within the warning window that do not - // already have a listing_expiring notification for today's idempotency key. - // We filter using a direct idempotency_key match rather than a 24-hour - // window timestamp check, so the uniqueness semantics are exact and - // consistent with the INSERT below. const { rows: candidates } = await client.query( `SELECT l.listing_id, l.posted_by, l.expires_at FROM listings l @@ -131,12 +53,6 @@ const runExpiryWarning = async () => { return; } - // For each candidate, attempt a durable INSERT of the notification row - // inside this transaction with the deterministic idempotency key. - // ON CONFLICT DO NOTHING means a concurrent runner that somehow slipped - // through the advisory lock will insert zero rows for that listing. - // We collect only the listing_ids for which the INSERT actually succeeded - // (i.e. the row was new) so we only enqueue jobs for those. const insertedListings = []; for (const row of candidates) { @@ -150,18 +66,10 @@ const runExpiryWarning = async () => { ); if (rowCount === 1) { - // Row was freshly inserted — safe to enqueue after commit. insertedListings.push(row); } } - // COMMIT atomically persists all inserted notification rows and releases - // the advisory lock. Post-commit enqueueing below is therefore safe: if - // enqueueNotification fails for any listing, the notification row is - // already durably stored, and the BullMQ worker will process it on the - // next retry triggered by some other code path (or the notification will - // remain undelivered until the next cron run, which is acceptable because - // there are still multiple days until expiry). await client.query("COMMIT"); if (insertedListings.length > 0) { @@ -183,15 +91,12 @@ const runExpiryWarning = async () => { "cron:expiryWarning — expiry warnings enqueued", ); } else { - // All candidates already had their notification rows inserted (race - // with another runner that committed first). Nothing to enqueue. logger.debug( { candidateCount: candidates.length, durationMs: Date.now() - startedAt }, "cron:expiryWarning — all candidates already warned; nothing enqueued", ); } } catch (err) { - // ROLLBACK releases the advisory lock if it was acquired. try { await client.query("ROLLBACK"); } catch (rollbackErr) { @@ -199,7 +104,6 @@ const runExpiryWarning = async () => { } logger.error({ err, durationMs: Date.now() - startedAt }, "cron:expiryWarning — run failed"); } finally { - // No pg_advisory_unlock call needed — the transaction ending handles it. client.release(); } }; diff --git a/src/cron/hardDeleteCleanup.js b/src/cron/hardDeleteCleanup.js index 780eef1..9975684 100644 --- a/src/cron/hardDeleteCleanup.js +++ b/src/cron/hardDeleteCleanup.js @@ -1,87 +1,15 @@ // src/cron/hardDeleteCleanup.js -// -// Scheduled maintenance job: permanently remove rows that have been soft-deleted -// for more than N days across all tables that carry a deleted_at column. -// -// ─── DESIGN NOTES ──────────────────────────────────────────────────────────── -// -// Soft-delete tables in this schema (from roomies_db_setup.sql): -// users, student_profiles, pg_owner_profiles, user_preferences, -// verification_requests, institutions, properties, listings, listing_photos, -// listing_preferences (via CASCADE from listings), listing_amenities (CASCADE), -// saved_listings, interest_requests, connections, notifications, -// ratings, rating_reports -// -// Deletion order matters because of foreign key constraints. We delete child -// rows before parent rows. The schema uses ON DELETE CASCADE on many junction -// tables, but ON DELETE RESTRICT on the core entity tables — so we must follow -// the dependency graph manually: -// -// rating_reports → ratings (FK: rating_id) -// ratings → connections (FK: connection_id) and users (FK: reviewer_id) -// notifications → users (FK: recipient_id, actor_id) -// interest_requests → listings (FK: listing_id) and users (FK: sender_id) -// connections → interest_requests (FK), listings (FK), users (FK) -// saved_listings → listings (FK) and users (FK) -// listing_photos, listing_preferences, listing_amenities → listings (CASCADE) -// listings → properties (FK) and users (FK) -// verification_requests → users (FK) -// pg_owner_profiles, student_profiles → users (FK) -// -// ─── THE FK-VIOLATION PROBLEM AND ITS FIX ──────────────────────────────────── -// -// A naive "delete children before parents" approach has a critical gap: -// the WHERE clause `deleted_at < cutoff` creates a temporal window where a -// soft-deleted child that hasn't yet aged past the retention window will NOT be -// deleted in the children step, but will still block its parent's hard-delete -// via ON DELETE RESTRICT FK constraint. -// -// Concrete scenario: -// - user soft-deleted 100 days ago → eligible for hard-delete (past 90-day cutoff) -// - connection soft-deleted 30 days ago → NOT eligible (hasn't reached cutoff) -// - Step 4 skips the connection (deleted_at < cutoff = false) -// - Step 14 tries to DELETE FROM users → PostgreSQL raises 23503 (FK violation) -// because the connection still references the user → entire transaction rolls back -// -// The fix: each parent-table DELETE includes NOT EXISTS guards that check whether -// any soft-deleted (but not yet aged) child row still references the parent. -// If so, the parent is skipped — it will be cleaned up in a future run once all -// its children have also aged past the retention cutoff. -// -// ─── CONNECTIONS → RATINGS FK GUARD ───────────────────────────────────────── -// -// ratings.connection_id references connections.connection_id ON DELETE RESTRICT. -// Step 2 hard-deletes aged ratings, but a connection aged enough for hard-delete -// may still have a recently-soft-deleted (not-yet-aged) rating referencing it. -// Without a guard, Step 4 (DELETE connections) would be blocked by that rating -// and the entire transaction would roll back. -// -// Fix: Step 4's DELETE skips any connection that still has a not-yet-aged -// soft-deleted rating (i.e. a rating whose deleted_at >= cutoff OR is NULL). -// -// ─── ALIAS NAMING ──────────────────────────────────────────────────────────── -// -// PostgreSQL treats `no` as an unreserved keyword (used in NO ACTION, NO MAXVALUE -// etc.) and it is legal as an alias in most contexts. However, using reserved or -// near-reserved words as aliases is error-prone and confusing to human readers. -// The notifications alias is renamed to `nt` throughout this file for clarity. -// -// ─── RETENTION VALIDATION ──────────────────────────────────────────────────── -// -// SOFT_DELETE_RETENTION_DAYS is validated at module load time (fail-fast pattern). -// The strict parse (/^[0-9]+$/ before numeric conversion) intentionally rejects -// values like "90days", "-30", "1e2" that parseInt() would silently accept. import cron from "node-cron"; import { pool } from "../db/client.js"; import { logger } from "../logger/index.js"; +import { storageService } from "../storage/index.js"; -const SCHEDULE = process.env.CRON_HARD_DELETE ?? "0 4 * * 0"; // Sundays at 04:00 +const SCHEDULE = process.env.CRON_HARD_DELETE ?? "0 4 * * 0"; const DEFAULT_RETENTION_DAYS = 90; const MIN_RETENTION_DAYS = 1; -const MAX_RETENTION_DAYS = 3650; // ~10 years +const MAX_RETENTION_DAYS = 3650; -// ─── Strict retention parsing (runs at module load, not per tick) ───────────── const _envRetention = process.env.SOFT_DELETE_RETENTION_DAYS; const _trimmed = typeof _envRetention === "string" ? _envRetention.trim() : undefined; const STRICT_DECIMAL_INTEGER_RE = /^[0-9]+$/; @@ -134,18 +62,19 @@ const runHardDeleteCleanup = async () => { let client; const results = {}; + // Collected inside the transaction so we know exactly which rows were + // deleted; blob deletion happens after COMMIT so storage errors never + // interfere with the DB transaction. + let photoUrlsToDelete = []; + try { client = await pool.connect(); await client.query("BEGIN"); - // The cutoff expression: rows older than this timestamp are eligible. - // Passed as a parameter so the query plan is stable across runs and - // there is no possibility of SQL injection from env vars. const cutoffExpr = `NOW() - ($1::int * INTERVAL '1 day')`; const p = [RETENTION_DAYS]; - // ── Step 1: rating_reports (depends on ratings) ─────────────────────── - // No children reference rating_reports directly, so no guard needed. + // ── rating_reports ──────────────────────────────────────────────────────── const { rowCount: rr } = await client.query( `DELETE FROM rating_reports WHERE deleted_at IS NOT NULL @@ -154,9 +83,7 @@ const runHardDeleteCleanup = async () => { ); results.rating_reports = rr; - // ── Step 2: ratings (depends on connections and users) ──────────────── - // rating_reports rows that reference this rating were deleted in step 1. - // No other ON DELETE RESTRICT child references ratings directly. + // ── ratings ─────────────────────────────────────────────────────────────── const { rowCount: ra } = await client.query( `DELETE FROM ratings WHERE deleted_at IS NOT NULL @@ -165,9 +92,7 @@ const runHardDeleteCleanup = async () => { ); results.ratings = ra; - // ── Step 3: notifications (depends on users) ────────────────────────── - // No children reference notifications. Alias 'nt' used (not 'no') to - // avoid ambiguity with PostgreSQL's unreserved keyword NO. + // ── notifications ───────────────────────────────────────────────────────── const { rowCount: no } = await client.query( `DELETE FROM notifications WHERE deleted_at IS NOT NULL @@ -176,16 +101,7 @@ const runHardDeleteCleanup = async () => { ); results.notifications = no; - // ── Step 4: connections (depends on interest_requests, listings, users) ─ - // - // ratings.connection_id ON DELETE RESTRICT means we must NOT hard-delete a - // connection if a not-yet-aged rating still references it. Step 2 removed - // aged ratings, but a rating soft-deleted recently (deleted_at >= cutoff) - // or not yet soft-deleted (deleted_at IS NULL) still holds the FK. - // - // Guard A (ratings): skip connections that still have a live or recently - // soft-deleted rating. We check both NULL (not soft-deleted) and - // recent (deleted_at >= cutoff) to cover every RESTRICT scenario. + // ── connections ─────────────────────────────────────────────────────────── const { rowCount: co } = await client.query( `DELETE FROM connections WHERE deleted_at IS NOT NULL @@ -199,8 +115,7 @@ const runHardDeleteCleanup = async () => { ); results.connections = co; - // ── Step 5: interest_requests (depends on listings and users) ───────── - // connections that reference this interest_request were deleted in step 4. + // ── interest_requests ───────────────────────────────────────────────────── const { rowCount: ir } = await client.query( `DELETE FROM interest_requests WHERE deleted_at IS NOT NULL @@ -209,7 +124,7 @@ const runHardDeleteCleanup = async () => { ); results.interest_requests = ir; - // ── Step 6: saved_listings (depends on listings and users) ──────────── + // ── saved_listings ──────────────────────────────────────────────────────── const { rowCount: sl } = await client.query( `DELETE FROM saved_listings WHERE deleted_at IS NOT NULL @@ -218,7 +133,7 @@ const runHardDeleteCleanup = async () => { ); results.saved_listings = sl; - // ── Step 7: listing_photos (soft-deleted photos outliving their listing) ─ + // ── listing_photos ──────────────────────────────────────────────────────── const { rowCount: lp } = await client.query( `DELETE FROM listing_photos WHERE deleted_at IS NOT NULL @@ -227,20 +142,11 @@ const runHardDeleteCleanup = async () => { ); results.listing_photos = lp; - // ── Step 8: listings ────────────────────────────────────────────────── - // Cascade handles listing_preferences and listing_amenities automatically. - // - // GUARD: Only delete a listing when no soft-deleted-but-not-yet-aged child - // rows still reference it via ON DELETE RESTRICT. - // - // Children that use ON DELETE RESTRICT on listings: - // - interest_requests (listing_id) — deleted in step 5 if aged, otherwise block - // - connections (listing_id) — deleted in step 4 if aged, otherwise block - // - saved_listings (listing_id) — deleted in step 6 if aged, otherwise block - // - // The NOT EXISTS guards check for soft-deleted children that have NOT yet - // reached the cutoff — those will block a FK delete and must skip this parent. - const { rowCount: li } = await client.query( + // ── listings — capture IDs of actually-deleted rows ─────────────────────── + // rent_observations are deleted AFTER this using the returned IDs so we + // only remove observations for listings that were truly hard-deleted (not + // ones blocked by the NOT EXISTS guards). + const { rows: deletedListingRows } = await client.query( `DELETE FROM listings WHERE deleted_at IS NOT NULL AND deleted_at < ${cutoffExpr} @@ -261,15 +167,28 @@ const runHardDeleteCleanup = async () => { WHERE sl.listing_id = listings.listing_id AND sl.deleted_at IS NOT NULL AND sl.deleted_at >= ${cutoffExpr} - )`, - // We need the cutoff twice in the same query (for the outer WHERE and for - // each NOT EXISTS). PostgreSQL allows referencing $1 multiple times in the - // same parameterized query — each occurrence refers to the same value. + ) + RETURNING listing_id`, p, ); - results.listings = li; + results.listings = deletedListingRows.length; + + // ── rent_observations — scoped to actually-deleted listings only ─────────── + // Deleting before the listings DELETE (as the original code did) would + // remove observations for listings that the NOT EXISTS guards then keep + // alive, producing orphaned rows / incorrect data loss. + let ro = 0; + if (deletedListingRows.length > 0) { + const deletedListingIds = deletedListingRows.map((r) => r.listing_id); + const { rowCount: roCount } = await client.query( + `DELETE FROM rent_observations WHERE listing_id = ANY($1::uuid[])`, + [deletedListingIds], + ); + ro = roCount; + } + results.rent_observations = ro; - // ── Step 9: verification_requests (depends on users) ────────────────── + // ── verification_requests ───────────────────────────────────────────────── const { rowCount: vr } = await client.query( `DELETE FROM verification_requests WHERE deleted_at IS NOT NULL @@ -278,8 +197,7 @@ const runHardDeleteCleanup = async () => { ); results.verification_requests = vr; - // ── Step 10: pg_owner_profiles and student_profiles ─────────────────── - // These depend on users. No other ON DELETE RESTRICT children reference them. + // ── pg_owner_profiles ───────────────────────────────────────────────────── const { rowCount: pop } = await client.query( `DELETE FROM pg_owner_profiles WHERE deleted_at IS NOT NULL @@ -288,6 +206,19 @@ const runHardDeleteCleanup = async () => { ); results.pg_owner_profiles = pop; + // Collect blob URLs before deleting student_profiles rows so we know what + // to clean up from storage. Actual storageService.delete calls happen after + // COMMIT — storage errors must not roll back the DB transaction. + const { rows: photoRows } = await client.query( + `SELECT profile_photo_url FROM student_profiles + WHERE deleted_at IS NOT NULL + AND deleted_at < ${cutoffExpr} + AND profile_photo_url IS NOT NULL`, + p, + ); + photoUrlsToDelete = photoRows.map((r) => r.profile_photo_url); + + // ── student_profiles ────────────────────────────────────────────────────── const { rowCount: sp } = await client.query( `DELETE FROM student_profiles WHERE deleted_at IS NOT NULL @@ -296,15 +227,7 @@ const runHardDeleteCleanup = async () => { ); results.student_profiles = sp; - // ── Step 11: properties ─────────────────────────────────────────────── - // - // GUARD: Only delete a property when no soft-deleted-but-not-yet-aged - // listing still references it (listings.property_id ON DELETE RESTRICT). - // - // Note: ratings with reviewee_type = 'property' are polymorphic — they - // reference property_id in the ratings.reviewee_id column, but this is NOT - // a FK constraint in the schema (polymorphic references cannot use standard - // FKs). So ratings do not block property deletion at the DB level. + // ── properties ──────────────────────────────────────────────────────────── const { rowCount: pr } = await client.query( `DELETE FROM properties WHERE deleted_at IS NOT NULL @@ -319,9 +242,7 @@ const runHardDeleteCleanup = async () => { ); results.properties = pr; - // ── Step 12: institutions ───────────────────────────────────────────── - // student_profiles references institutions via ON DELETE SET NULL, so - // institutions have no RESTRICT children after the profiles are cleaned. + // ── institutions ────────────────────────────────────────────────────────── const { rowCount: ins } = await client.query( `DELETE FROM institutions WHERE deleted_at IS NOT NULL @@ -330,23 +251,7 @@ const runHardDeleteCleanup = async () => { ); results.institutions = ins; - // ── Step 13: users — last, after all dependents are cleared ─────────── - // - // GUARD: Only delete a user when no soft-deleted-but-not-yet-aged child - // rows still reference it via ON DELETE RESTRICT. - // - // Children that use ON DELETE RESTRICT on users (after all cascades): - // - connections (initiator_id, counterpart_id) - // - interest_requests (sender_id) - // - ratings (reviewer_id) - // - notifications (recipient_id) — actor_id is SET NULL so only - // recipient_id is RESTRICT; alias 'nt' used (not 'no') - // - verification_requests (user_id) - // - student_profiles (user_id) - // - pg_owner_profiles (user_id) - // - // Steps 1–10 cleared the aged children. The NOT EXISTS guards here protect - // against recently soft-deleted children that haven't aged past the cutoff. + // ── users ───────────────────────────────────────────────────────────────── const { rowCount: us } = await client.query( `DELETE FROM users WHERE deleted_at IS NOT NULL @@ -389,7 +294,7 @@ const runHardDeleteCleanup = async () => { ) AND NOT EXISTS ( SELECT 1 FROM pg_owner_profiles pop - WHERE pop.user_id = users.user_id + WHERE pop.user_id = users.user_id AND pop.deleted_at IS NOT NULL AND pop.deleted_at >= ${cutoffExpr} )`, @@ -424,11 +329,26 @@ const runHardDeleteCleanup = async () => { { err, retentionDays: RETENTION_DAYS, durationMs: Date.now() - startedAt }, "cron:hardDeleteCleanup — run failed", ); + return; // skip blob cleanup if the transaction failed } finally { if (client) { client.release(); } } + + // Delete blobs after the transaction has committed. Storage errors are + // logged but do not affect the DB state — the rows are already gone and + // orphaned blobs can be reconciled manually or by a future storage audit. + for (const url of photoUrlsToDelete) { + try { + await storageService.delete(url); + } catch (storageErr) { + logger.error( + { storageErr, profile_photo_url: url }, + "cron:hardDeleteCleanup — failed to delete profile photo blob", + ); + } + } }; export const registerHardDeleteCleanupCron = () => { diff --git a/src/cron/listingExpiry.js b/src/cron/listingExpiry.js index 0b3e92b..cba9538 100644 --- a/src/cron/listingExpiry.js +++ b/src/cron/listingExpiry.js @@ -1,35 +1,8 @@ -// src/cron/listingExpiry.js -// -// Scheduled maintenance job: expire active listings whose expires_at timestamp -// has passed. -// -// ─── DESIGN NOTES ──────────────────────────────────────────────────────────── -// -// Idempotency: the UPDATE predicate (status = 'active' AND expires_at < NOW()) -// means re-running this job any number of times produces the same result — rows -// already transitioned to 'expired' do not match and are not touched. -// -// Observability: every run logs how many rows were affected. Zero-row runs are -// logged at debug level so they appear in verbose mode without cluttering -// production dashboards. Runs that expire rows are logged at info level. -// -// Pending request cleanup: when a listing expires, any still-pending interest -// requests on it become permanently unreachable — the listing cannot be -// re-activated from 'expired' state. We expire those requests in the same -// transaction so the DB is always consistent: no pending request points at -// an expired listing after this job runs. -// -// Notification: after the transaction commits we enqueue listing_expired -// notifications for the affected posters. Using the shared enqueueNotification -// helper means a Redis failure never rolls back the DB state change. - import cron from "node-cron"; import { pool } from "../db/client.js"; import { logger } from "../logger/index.js"; import { enqueueNotification } from "../workers/notificationQueue.js"; -// How often this job runs. '0 2 * * *' = 02:00 every night in the server's -// local timezone. Adjust via the CRON_LISTING_EXPIRY env var if needed. const SCHEDULE = process.env.CRON_LISTING_EXPIRY ?? "0 2 * * *"; const runListingExpiry = async () => { @@ -42,9 +15,6 @@ const runListingExpiry = async () => { try { await client.query("BEGIN"); - // Transition all active, past-expiry listings to 'expired' in one statement. - // RETURNING captures the listing_id and posted_by values we need for the - // interest-request cleanup and notifications below — no second query needed. const { rows: expiredRows } = await client.query( `UPDATE listings SET status = 'expired'::listing_status_enum, @@ -58,9 +28,6 @@ const runListingExpiry = async () => { expiredListingIds = expiredRows.map((r) => r.listing_id); if (expiredListingIds.length > 0) { - // Bulk-expire all pending interest requests across every newly-expired - // listing. Using ANY($1::uuid[]) lets us do this in one query instead of - // N queries, which is important when many listings expire at once. const { rowCount: requestsExpired } = await client.query( `UPDATE interest_requests SET status = 'expired'::request_status_enum, updated_at = NOW() @@ -81,8 +48,6 @@ const runListingExpiry = async () => { await client.query("COMMIT"); - // Post-commit: notify each affected poster. Fire-and-forget — a Redis - // hiccup must not trigger a rollback of the DB state we just committed. for (const row of expiredRows) { enqueueNotification({ recipientId: row.posted_by, @@ -107,17 +72,8 @@ const runListingExpiry = async () => { } }; -// ─── Register the cron schedule ─────────────────────────────────────────────── -// -// Returns the scheduled task so server.js can call task.stop() during graceful -// shutdown. node-cron tasks hold no async work open after stop(), so they do -// not need to be awaited during shutdown — stop() is synchronous. export const registerListingExpiryCron = () => { const task = cron.schedule(SCHEDULE, () => { - // We intentionally do not await the promise here. node-cron fires the - // callback synchronously on each tick; if we were to block the tick we - // would prevent the scheduler from firing other jobs. The async work runs - // in the background and logs its own completion/failure. runListingExpiry().catch((err) => { logger.error({ err }, "cron:listingExpiry — unhandled error in job runner"); }); diff --git a/src/cron/rentIndexRefresh.js b/src/cron/rentIndexRefresh.js new file mode 100644 index 0000000..7ac77ca --- /dev/null +++ b/src/cron/rentIndexRefresh.js @@ -0,0 +1,129 @@ +// src/cron/rentIndexRefresh.js +// +// Nightly job that recomputes p25/p50/p75 in rent_index from the last +// WINDOW_DAYS of rent_observations. +// +// Two passes per run: +// 1. Locality-level rows (city + locality + room_type), minimum 3 observations. +// 2. City-wide fallback rows (city + NULL locality + room_type), minimum 5. +// +// Uses pg_try_advisory_xact_lock so only one instance runs when the +// server is horizontally scaled (same pattern as expiryWarning.js). + +import cron from "node-cron"; +import { pool } from "../db/client.js"; +import { logger } from "../logger/index.js"; + +const SCHEDULE = process.env.CRON_RENT_INDEX ?? "0 3 * * *"; +const ADVISORY_LOCK_KEY = 7002; +const WINDOW_DAYS = 180; +const MIN_SAMPLE_LOCALITY = 3; +const MIN_SAMPLE_CITY = 5; + +const runRentIndexRefresh = async () => { + const startedAt = Date.now(); + logger.info("cron:rentIndexRefresh — starting run"); + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const { rows: lockRows } = await client.query("SELECT pg_try_advisory_xact_lock($1) AS acquired", [ + ADVISORY_LOCK_KEY, + ]); + + if (!lockRows[0].acquired) { + await client.query("ROLLBACK"); + logger.info( + { durationMs: Date.now() - startedAt }, + "cron:rentIndexRefresh — advisory lock not acquired (another runner active); skipping", + ); + return; + } + + // Pass 1 — locality-level rows + const { rowCount: localityRows } = await client.query( + `INSERT INTO rent_index (city, locality, room_type, p25, p50, p75, sample_count, computed_at) + SELECT + city, + locality, + room_type, + ROUND(percentile_cont(0.25) WITHIN GROUP (ORDER BY rent_per_month))::int, + ROUND(percentile_cont(0.50) WITHIN GROUP (ORDER BY rent_per_month))::int, + ROUND(percentile_cont(0.75) WITHIN GROUP (ORDER BY rent_per_month))::int, + COUNT(*)::int, + NOW() + FROM rent_observations + WHERE observed_at > NOW() - ($1::int * INTERVAL '1 day') + AND locality IS NOT NULL + AND locality <> '' + GROUP BY city, locality, room_type + HAVING COUNT(*) >= $2 + ON CONFLICT (city, locality, room_type) + DO UPDATE SET + p25 = EXCLUDED.p25, + p50 = EXCLUDED.p50, + p75 = EXCLUDED.p75, + sample_count = EXCLUDED.sample_count, + computed_at = EXCLUDED.computed_at`, + [WINDOW_DAYS, MIN_SAMPLE_LOCALITY], + ); + + // Pass 2 — city-wide fallback rows (locality IS NULL) + // The unique constraint uses (city, locality, room_type) and locality IS NULL, + // so we need a NULL-safe upsert. PostgreSQL unique constraints treat NULLs as + // distinct, so we use a partial unique index defined in the migration instead. + // We work around this by first deleting stale city-wide rows then reinserting. + await client.query(`DELETE FROM rent_index WHERE locality IS NULL`); + + const { rowCount: cityRows } = await client.query( + `INSERT INTO rent_index (city, locality, room_type, p25, p50, p75, sample_count, computed_at) + SELECT + city, + NULL AS locality, + room_type, + ROUND(percentile_cont(0.25) WITHIN GROUP (ORDER BY rent_per_month))::int, + ROUND(percentile_cont(0.50) WITHIN GROUP (ORDER BY rent_per_month))::int, + ROUND(percentile_cont(0.75) WITHIN GROUP (ORDER BY rent_per_month))::int, + COUNT(*)::int, + NOW() + FROM rent_observations + WHERE observed_at > NOW() - ($1::int * INTERVAL '1 day') + GROUP BY city, room_type + HAVING COUNT(*) >= $2`, + [WINDOW_DAYS, MIN_SAMPLE_CITY], + ); + + await client.query("COMMIT"); + + logger.info( + { + localityRowsUpserted: localityRows, + cityRowsInserted: cityRows, + windowDays: WINDOW_DAYS, + durationMs: Date.now() - startedAt, + }, + "cron:rentIndexRefresh — run complete", + ); + } catch (err) { + try { + await client.query("ROLLBACK"); + } catch (rollbackErr) { + logger.error({ rollbackErr }, "cron:rentIndexRefresh — rollback failed"); + } + logger.error({ err, durationMs: Date.now() - startedAt }, "cron:rentIndexRefresh — run failed"); + } finally { + client.release(); + } +}; + +export const registerRentIndexRefreshCron = () => { + const task = cron.schedule(SCHEDULE, () => { + runRentIndexRefresh().catch((err) => { + logger.error({ err }, "cron:rentIndexRefresh — unhandled error in job runner"); + }); + }); + + logger.info({ schedule: SCHEDULE }, "cron:rentIndexRefresh — registered"); + return task; +}; diff --git a/src/cron/savedSearchAlert.js b/src/cron/savedSearchAlert.js new file mode 100644 index 0000000..be744d3 --- /dev/null +++ b/src/cron/savedSearchAlert.js @@ -0,0 +1,196 @@ +// src/cron/savedSearchAlert.js + +import cron from "node-cron"; +import { pool } from "../db/client.js"; +import { logger } from "../logger/index.js"; +import { enqueueNotificationsBulk } from "../workers/notificationQueue.js"; + +const SCHEDULE = process.env.CRON_SAVED_SEARCH_ALERT ?? "0 8 * * *"; + +const toPositiveInt = (value, fallback) => { + const n = Number(value); + return Number.isInteger(n) && n > 0 ? n : fallback; +}; + +const SEARCH_BATCH_SIZE = toPositiveInt(process.env.SAVED_SEARCH_ALERT_BATCH_SIZE, 500); +const NOTIFICATION_CHUNK_SIZE = toPositiveInt(process.env.SAVED_SEARCH_ALERT_NOTIFICATION_CHUNK_SIZE, 100); + +const chunk = (items, size) => { + const chunks = []; + for (let i = 0; i < items.length; i += size) { + chunks.push(items.slice(i, i + size)); + } + return chunks; +}; + +const fetchMatchedSearches = async (cursorId) => { + const queryStartedAt = Date.now(); + const { rows } = await pool.query( + `WITH search_batch AS ( + SELECT search_id, user_id, filters, last_alerted_at + FROM saved_searches + WHERE deleted_at IS NULL + AND ($1::uuid IS NULL OR search_id > $1::uuid) + ORDER BY search_id ASC + LIMIT $2 + ), + candidate_listings AS ( + SELECT l.* + FROM listings l + WHERE l.status = 'active' + AND l.deleted_at IS NULL + AND l.expires_at > NOW() + AND EXISTS (SELECT 1 FROM search_batch) + AND l.created_at > ( + SELECT COALESCE(MIN(last_alerted_at), 'epoch'::timestamptz) + FROM search_batch + ) + ), + matched_searches AS ( + SELECT DISTINCT s.search_id, s.user_id + FROM search_batch s + JOIN candidate_listings l + ON l.created_at > COALESCE(s.last_alerted_at, 'epoch'::timestamptz) + WHERE + ( + NOT (s.filters ? 'city') + OR LOWER(l.city) LIKE LOWER( + REPLACE(REPLACE(REPLACE(s.filters->>'city', '\\', '\\\\'), '%', '\\%'), '_', '\\_') || '%' + ) ESCAPE '\\' + ) + AND (NOT (s.filters ? 'minRent') OR l.rent_per_month >= ((s.filters->>'minRent')::numeric * 100)::int) + AND (NOT (s.filters ? 'maxRent') OR l.rent_per_month <= ((s.filters->>'maxRent')::numeric * 100)::int) + AND (NOT (s.filters ? 'roomType') OR l.room_type::text = s.filters->>'roomType') + AND (NOT (s.filters ? 'bedType') OR l.bed_type::text = s.filters->>'bedType') + AND ( + NOT (s.filters ? 'preferredGender') + OR l.preferred_gender::text = s.filters->>'preferredGender' + OR l.preferred_gender IS NULL + ) + AND (NOT (s.filters ? 'listingType') OR l.listing_type::text = s.filters->>'listingType') + AND (NOT (s.filters ? 'availableFrom') OR l.available_from <= (s.filters->>'availableFrom')::date) + AND ( + NOT (s.filters ? 'amenityIds') + OR jsonb_array_length(s.filters->'amenityIds') = 0 + OR EXISTS ( + SELECT 1 + FROM listing_amenities la + WHERE la.listing_id = l.listing_id + AND la.amenity_id = ANY( + ARRAY( + SELECT jsonb_array_elements_text(s.filters->'amenityIds')::uuid + ) + ) + GROUP BY la.listing_id + HAVING COUNT(DISTINCT la.amenity_id) = jsonb_array_length(s.filters->'amenityIds') + ) + ) + ), + batch_meta AS ( + SELECT + (SELECT search_id FROM search_batch ORDER BY search_id DESC LIMIT 1) AS batch_cursor_id, + (SELECT COUNT(*)::int FROM search_batch) AS batch_count + ) + SELECT + bm.batch_cursor_id, + bm.batch_count, + ms.search_id, + ms.user_id + FROM batch_meta bm + LEFT JOIN matched_searches ms ON TRUE + ORDER BY ms.search_id ASC`, + [cursorId, SEARCH_BATCH_SIZE], + ); + + return { rows, queryDurationMs: Date.now() - queryStartedAt }; +}; + +const markSearchesAlerted = async (searchIds) => { + if (!searchIds.length) return 0; + + const { rowCount } = await pool.query( + `UPDATE saved_searches + SET last_alerted_at = NOW() + WHERE search_id = ANY($1::uuid[]) + AND deleted_at IS NULL`, + [searchIds], + ); + + return rowCount; +}; + +export const runSavedSearchAlert = async () => { + const startedAt = Date.now(); + logger.info("cron:savedSearchAlert — starting run"); + + let cursorId = null; + let scanned = 0; + let matched = 0; + let enqueued = 0; + let updated = 0; + let queryDurationMs = 0; + + while (true) { + const { rows, queryDurationMs: batchQueryDurationMs } = await fetchMatchedSearches(cursorId); + queryDurationMs += batchQueryDurationMs; + + const batchCursorId = rows[0]?.batch_cursor_id ?? null; + const batchCount = Number(rows[0]?.batch_count ?? 0); + scanned += batchCount; + + const notifications = rows + .filter((row) => row.search_id) + .map((row) => ({ + recipientId: row.user_id, + type: "saved_search_alert", + entityType: "saved_search", + entityId: row.search_id, + })); + + matched += notifications.length; + + for (const notificationChunk of chunk(notifications, NOTIFICATION_CHUNK_SIZE)) { + enqueued += await enqueueNotificationsBulk(notificationChunk); + } + + const batchUpdated = await markSearchesAlerted(notifications.map((notification) => notification.entityId)); + updated += batchUpdated; + + logger.debug( + { + batchCount, + batchMatched: notifications.length, + batchUpdated, + batchQueryDurationMs, + cursorId: batchCursorId, + }, + "cron:savedSearchAlert — batch processed", + ); + + if (batchCount < SEARCH_BATCH_SIZE || batchCursorId === null) break; + cursorId = batchCursorId; + } + + logger.info( + { + scanned, + matched, + enqueued, + updated, + queryDurationMs, + durationMs: Date.now() - startedAt, + }, + "cron:savedSearchAlert — run complete", + ); +}; + +export const registerSavedSearchAlertCron = () => { + const task = cron.schedule(SCHEDULE, () => { + runSavedSearchAlert().catch((err) => { + logger.error({ err }, "cron:savedSearchAlert — unhandled error"); + }); + }); + + logger.info({ schedule: SCHEDULE }, "cron:savedSearchAlert — registered"); + return task; +}; diff --git a/src/db/client.js b/src/db/client.js index 4c6f612..7232659 100644 --- a/src/db/client.js +++ b/src/db/client.js @@ -1,62 +1,31 @@ -// src/db/client.js - import pg from "pg"; import { config } from "../config/env.js"; import { logger } from "../logger/index.js"; const { Pool } = pg; -// One pool for the entire process — never create a new Pool per request. -// pg manages the connection lifecycle internally. +const parsedPoolMax = Number.parseInt(process.env.DB_POOL_MAX ?? "", 10); +const DB_POOL_MAX = Number.isInteger(parsedPoolMax) && parsedPoolMax > 0 ? parsedPoolMax : 10; + export const pool = new Pool({ connectionString: config.DATABASE_URL, - - // Max connections in the pool. - // Keep this below your PostgreSQL max_connections setting. - max: 20, - - // How long (ms) a client can sit idle before being closed. + max: DB_POOL_MAX, idleTimeoutMillis: 30_000, - - // How long (ms) to wait for a connection before throwing an error. connectionTimeoutMillis: 5_000, }); -// Log when a new client connects — useful for spotting connection leaks pool.on("connect", () => { logger.debug("pg pool: new client connected"); }); - -// for example, the PostgreSQL backend process was killed, or the TCP connection -// was dropped by a network device. In these cases pg has already removed the -// bad client from the pool and will open a fresh connection on the next query. -// -// The previous behaviour was to unconditionally call process.exit(1) here, -// which crashes the entire server for what is often a fully recoverable event. -// A transient backend crash during a low-traffic window would take the whole -// Node process down unnecessarily. -// -// The revised behaviour: log the error so it is visible in Pino / Azure Monitor, -// then check whether the error is genuinely fatal (no way to recover) before -// deciding to exit. For most idle-client errors, logging is sufficient — pg -// will self-heal on the next request. We only exit if the error carries an -// explicit fatal flag or an ECONNREFUSED code, which indicates the database -// server itself is gone and no further queries can succeed anyway. + +const fatalCodes = new Set(["ECONNREFUSED", "ENOTFOUND"]); + pool.on("error", (err) => { logger.error({ err }, "pg pool: unexpected error on idle client"); - if (fatalCodes.has(err.code)) { logger.fatal({ err }, "pg pool: unrecoverable connection error — shutting down"); process.exit(1); } - - // pg discards the bad client and the pool recovers automatically. - // No exit needed — the error is logged and the server keeps running. }); - -// ENOTFOUND means DNS resolution failed — the host does not exist. -// Both are unrecoverable at runtime; defined once at module scope to avoid -// reallocating on every error event. -const fatalCodes = new Set(["ECONNREFUSED", "ENOTFOUND"]); - + export const query = (text, params) => pool.query(text, params); diff --git a/src/db/migrate.js b/src/db/migrate.js index 5a814c7..1f3c207 100644 --- a/src/db/migrate.js +++ b/src/db/migrate.js @@ -1,41 +1,3 @@ -// src/db/migrate.js -// -// Minimal, dependency-free migration runner for Roomies. -// -// Design philosophy: -// This runner deliberately avoids any migration framework (Flyway, Liquibase, -// node-pg-migrate) to keep the stack lean. The implementation is ~100 lines -// and does exactly what a migration runner needs to do: track which migrations -// have been applied, apply pending ones in order, and never apply the same -// migration twice. No magic, no abstractions, no new dependencies. -// -// How it works: -// 1. Creates a schema_migrations table in your database on first run. -// 2. Reads all *.sql files from the migrations/ directory, sorted by filename. -// The naming convention 001_, 002_, 003_ guarantees stable alphabetical -// ordering which equals chronological ordering. -// 3. Compares the file list against already-applied migrations in the DB. -// 4. Runs each pending migration inside its own transaction. If a migration -// fails, the transaction rolls back and the runner exits with a non-zero -// code — the database is left in a clean, pre-migration state. -// 5. On success, records the migration filename + checksum in schema_migrations. -// -// Checksum guard: -// Each migration file's SHA-256 checksum is stored when it is first applied. -// On subsequent runs, if an already-applied migration's file has been modified -// on disk, the runner logs a warning and exits. This protects against the -// common mistake of editing a previously-applied migration instead of creating -// a new one — a habit that causes schema drift across environments. -// -// Usage: -// node src/db/migrate.js ← apply pending migrations -// node src/db/migrate.js --dry-run ← show what would run, apply nothing -// node src/db/migrate.js --status ← list applied/pending migration status -// -// In CI/CD: -// Add "node src/db/migrate.js" as a pre-deployment step. It is safe to run -// on every deploy because already-applied migrations are skipped. - import fs from "fs/promises"; import path from "path"; import crypto from "crypto"; @@ -43,9 +5,6 @@ import { fileURLToPath } from "url"; import pg from "pg"; import dotenv from "dotenv"; -// ─── Bootstrap env ──────────────────────────────────────────────────────────── -// The runner is invoked directly (not through app.js), so we load env vars -// manually. ENV_FILE follows the same convention as the main app. const envFile = process.env.ENV_FILE; if (envFile) { dotenv.config({ path: envFile }); @@ -60,24 +19,17 @@ if (!process.env.DATABASE_URL) { process.exit(1); } -// ─── Paths ──────────────────────────────────────────────────────────────────── const __dirname = path.dirname(fileURLToPath(import.meta.url)); const MIGRATIONS_DIR = path.resolve(__dirname, "../../migrations"); -// ─── Parse CLI flags ───────────────────────────────────────────────────────── const args = new Set(process.argv.slice(2)); const DRY_RUN = args.has("--dry-run"); const STATUS_ONLY = args.has("--status"); -// ─── Helpers ────────────────────────────────────────────────────────────────── - const sha256 = (content) => crypto.createHash("sha256").update(content, "utf8").digest("hex"); const pad = (str, width) => str.toString().padEnd(width); -// ─── Migration tracking table ───────────────────────────────────────────────── -// Created automatically on first run. Deliberately simple — no framework -// metadata, just the filename, checksum, and when it was applied. const ENSURE_MIGRATIONS_TABLE = ` CREATE TABLE IF NOT EXISTS schema_migrations ( filename VARCHAR(255) PRIMARY KEY, @@ -86,8 +38,6 @@ CREATE TABLE IF NOT EXISTS schema_migrations ( ); `; -// ─── Main ───────────────────────────────────────────────────────────────────── - const run = async () => { const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); @@ -95,21 +45,17 @@ const run = async () => { await client.connect(); console.log("✅ Connected to database"); - // Ensure the tracking table exists before any queries against it. await client.query(ENSURE_MIGRATIONS_TABLE); - // Fetch the set of already-applied migrations and their checksums. const { rows: appliedRows } = await client.query( `SELECT filename, checksum FROM schema_migrations ORDER BY filename`, ); const applied = new Map(appliedRows.map((r) => [r.filename, r.checksum])); - // Read the migrations directory and sort files lexicographically. - // The 001_, 002_ prefix convention guarantees chronological order. let files; try { const entries = await fs.readdir(MIGRATIONS_DIR); - files = entries.filter((f) => f.endsWith(".sql")).sort(); // lexicographic = chronological with the naming convention + files = entries.filter((f) => f.endsWith(".sql")).sort(); } catch (err) { console.error(`❌ Cannot read migrations directory: ${MIGRATIONS_DIR}`); console.error(` Make sure the migrations/ folder exists at the project root.`); @@ -121,12 +67,9 @@ const run = async () => { return; } - // ── Checksum integrity check for already-applied migrations ───────── - // If a previously-applied file has been edited on disk, warn and exit. - // This is the most common cause of schema drift between environments. let checksumViolation = false; for (const file of files) { - if (!applied.has(file)) continue; // Pending migration, skip for now + if (!applied.has(file)) continue; const filePath = path.join(MIGRATIONS_DIR, file); const content = await fs.readFile(filePath, "utf8"); @@ -144,7 +87,6 @@ const run = async () => { } if (checksumViolation) process.exit(1); - // ── Status report ─────────────────────────────────────────────────── if (STATUS_ONLY) { console.log("\nMigration status:\n"); console.log(`${pad("File", 45)} ${pad("Status", 12)} Applied at`); @@ -159,7 +101,6 @@ const run = async () => { return; } - // ── Apply pending migrations ───────────────────────────────────────── const pending = files.filter((f) => !applied.has(f)); if (pending.length === 0) { @@ -176,7 +117,6 @@ const run = async () => { return; } - // Apply each pending migration inside its own transaction. for (const file of pending) { const filePath = path.join(MIGRATIONS_DIR, file); const content = await fs.readFile(filePath, "utf8"); @@ -185,9 +125,6 @@ const run = async () => { process.stdout.write(` Applying ${file} ... `); try { - // Each migration gets its own BEGIN/COMMIT so a failure in - // migration N does not affect migrations 1..N-1 which already - // committed. The failed migration is the one that rolls back. await client.query("BEGIN"); await client.query(content); await client.query( @@ -199,12 +136,9 @@ const run = async () => { console.log("✅"); } catch (err) { - // Roll back the failed migration transaction. try { await client.query("ROLLBACK"); - } catch (_) { - /* ignore */ - } + } catch (_) {} console.log("❌"); console.error(`\nMigration failed: ${file}`); diff --git a/src/db/seeds/amenities.js b/src/db/seeds/amenities.js index b54a83b..399cbaf 100644 --- a/src/db/seeds/amenities.js +++ b/src/db/seeds/amenities.js @@ -1,61 +1,8 @@ -// src/db/seeds/amenities.js -// -// Idempotent amenity seed script. Run once per environment after schema setup. -// -// Usage: -// npm run seed:amenities -// -// Safe to re-run — ON CONFLICT (name) DO NOTHING means existing rows are never -// touched. Only new entries in the AMENITIES array below produce inserts. -// Previously seeded rows with manually-edited icon_name or category values in -// the DB will not be overwritten. -// -// When adding new amenities: append to the array below and re-run. Do not -// remove entries that already exist in production — soft-delete them via the -// admin panel instead. Hard-deleting from this array and re-running does nothing -// to the DB (ON CONFLICT DO NOTHING, not DELETE + INSERT). -// -// icon_name convention: kebab-case strings matching Lucide icon names where -// possible (https://lucide.dev/icons/). The frontend maps these directly to -// React components. If no Lucide icon exists, use a descriptive kebab-case -// string and document it here. -// wifi → Lucide: Wifi -// power-backup → Lucide: BatteryCharging -// water-supply → Lucide: Droplets -// piped-gas → Lucide: Flame -// laundry → Lucide: WashingMachine -// housekeeping → Lucide: Sparkles -// cctv → Lucide: Camera -// security-guard → Lucide: Shield -// gated-entry → Lucide: DoorClosed -// biometric-access → Lucide: Fingerprint -// fire-safety → Lucide: FireExtinguisher (custom — not in Lucide core) -// air-conditioning → Lucide: AirVent -// ceiling-fan → Lucide: Wind -// attached-bathroom→ Lucide: Bath -// furnished → Lucide: Sofa -// gym → Lucide: Dumbbell -// common-room → Lucide: Users -// parking → Lucide: ParkingSquare -// rooftop → Lucide: Building2 - -import "../../../src/config/env.js"; // Zod env validation runs at import +import "../../../src/config/env.js"; import { pool } from "../client.js"; import { logger } from "../../logger/index.js"; -// ─── Amenity definitions ────────────────────────────────────────────────────── -// -// Three categories match the amenity_category_enum in the schema exactly: -// utility — infrastructure a resident depends on daily -// safety — physical security of the building and residents -// comfort — quality-of-life additions beyond the basics -// -// Ordering within each group is display order — the frontend renders amenities -// in the order they appear in this array (after the DB returns them ORDER BY name, -// the seed order has no effect on retrieval, but the grouping here documents intent). - const AMENITIES = [ - // ── Utility ────────────────────────────────────────────────────────────── { name: "WiFi", category: "utility", icon_name: "wifi" }, { name: "Power Backup", category: "utility", icon_name: "power-backup" }, { name: "24-Hour Water Supply", category: "utility", icon_name: "water-supply" }, @@ -63,14 +10,12 @@ const AMENITIES = [ { name: "Laundry", category: "utility", icon_name: "laundry" }, { name: "Housekeeping", category: "utility", icon_name: "housekeeping" }, - // ── Safety ──────────────────────────────────────────────────────────────── { name: "CCTV Surveillance", category: "safety", icon_name: "cctv" }, { name: "Security Guard", category: "safety", icon_name: "security-guard" }, { name: "Gated Entry", category: "safety", icon_name: "gated-entry" }, { name: "Biometric / Key-Card Access", category: "safety", icon_name: "biometric-access" }, { name: "Fire Safety Equipment", category: "safety", icon_name: "fire-safety" }, - // ── Comfort ─────────────────────────────────────────────────────────────── { name: "Air Conditioning", category: "comfort", icon_name: "air-conditioning" }, { name: "Ceiling Fan", category: "comfort", icon_name: "ceiling-fan" }, { name: "Attached Bathroom", category: "comfort", icon_name: "attached-bathroom" }, @@ -84,16 +29,10 @@ const AMENITIES = [ const seed = async () => { logger.info(`Seeding ${AMENITIES.length} amenities…`); - // Build a multi-row VALUES clause: ($1, $2, $3), ($4, $5, $6), ... - // A single INSERT with multiple value tuples is far more efficient than - // N individual INSERT statements — one round-trip to the DB instead of N. const placeholders = AMENITIES.map((_, i) => `($${i * 3 + 1}, $${i * 3 + 2}, $${i * 3 + 3})`).join(", "); const values = AMENITIES.flatMap(({ name, category, icon_name }) => [name, category, icon_name]); - // ON CONFLICT (name) DO NOTHING — name has a UNIQUE constraint in the schema. - // rowCount reflects only the rows that were actually inserted, not the total - // attempted, so it tells us exactly how many new amenities were added this run. const result = await pool.query( `INSERT INTO amenities (name, category, icon_name) VALUES ${placeholders} @@ -107,11 +46,6 @@ const seed = async () => { logger.info({ inserted, skipped }, "Amenity seed complete"); }; -// ─── Run ────────────────────────────────────────────────────────────────────── -// -// pool.end() must be called explicitly — the script would otherwise hang -// indefinitely with open idle connections after the query completes, because -// the pg pool keeps connections alive waiting for future queries. seed() .catch((err) => { logger.error({ err }, "Amenity seed failed"); diff --git a/src/db/utils/auth.js b/src/db/utils/auth.js index c65afe0..a9ba69f 100644 --- a/src/db/utils/auth.js +++ b/src/db/utils/auth.js @@ -1,5 +1,3 @@ -// src/db/utils/auth.js - import { pool } from "../client.js"; export const findUserById = async (id, client = pool) => { @@ -35,8 +33,6 @@ export const findUserById = async (id, client = pool) => { user.roles = user.roles === "{}" ? [] : user.roles.replace(/^{|}$/g, "").split(","); } return user; - - // return rows[0] ?? null; }; export const findUserByEmail = async (email, client = pool) => { @@ -56,22 +52,7 @@ export const findUserByEmail = async (email, client = pool) => { ); return rows[0] ?? null; }; -// Google ID token). Called during OAuth sign-in to determine whether this -// Google account has been seen before (returning user) or is new. -// -// Returns enough columns for the service to make a branching decision: -// - user_id → needed for JWT issuance and Redis key -// - email → included in JWT payload -// - google_id → returned so the caller can confirm the link rather -// than assuming it -// - account_status → checked before issuing tokens, same as login -// - is_email_verified → included in token response user object -// -// Does NOT return password_hash — OAuth users may not have one, and the -// OAuth flow never needs it. Keeping it out prevents accidental logging. -// -// Accepts an optional client so it can participate in a transaction when the -// account-linking UPDATE and a subsequent SELECT need to be atomic. + export const findUserByGoogleId = async (googleId, client = pool) => { const { rows } = await client.query( ` diff --git a/src/db/utils/compatibility.js b/src/db/utils/compatibility.js index f304de2..148e050 100644 --- a/src/db/utils/compatibility.js +++ b/src/db/utils/compatibility.js @@ -1,13 +1,8 @@ -// src/db/utils/compatibility.js -// - - import { pool } from "../client.js"; - export const scoreListingsForUser = async (userId, listingIds, client = pool) => { if (!listingIds.length) return {}; - + const { rows } = await client.query( `SELECT lp.listing_id, @@ -21,7 +16,7 @@ export const scoreListingsForUser = async (userId, listingIds, client = pool) => GROUP BY lp.listing_id`, [listingIds, userId], ); - + return rows.reduce((acc, row) => { acc[row.listing_id] = row.match_count; return acc; diff --git a/src/db/utils/institutions.js b/src/db/utils/institutions.js index 6cd1472..9812f78 100644 --- a/src/db/utils/institutions.js +++ b/src/db/utils/institutions.js @@ -1,22 +1,5 @@ -// src/db/utils/institutions.js - import { pool } from "../client.js"; - -// Called during student registration to determine whether the email address -// qualifies for automatic verification without going through the OTP flow. -// -// The WHERE clause includes deleted_at IS NULL for two reasons: -// 1. It matches the predicate of the partial unique index on (email_domain), -// so the query planner uses an index scan rather than a seq scan. -// 2. A soft-deleted institution should not trigger auto-verification for new -// registrations, even though old student records retain their institution_id. -// -// Accepts an optional client so it can participate in a transaction transparently. -// When called inside the registration transaction, the same client is passed in — -// no separate connection, no risk of reading uncommitted state from another session. -// Defaults to the shared pool for standalone calls (tests, one-offs). -// -// Returns { institution_id, name } or null. Never undefined, never an empty array. + export const findInstitutionByDomain = async (domain, client = pool) => { const { rows } = await client.query( ` diff --git a/src/db/utils/pgOwner.js b/src/db/utils/pgOwner.js index 9709f09..018bc68 100644 --- a/src/db/utils/pgOwner.js +++ b/src/db/utils/pgOwner.js @@ -1,23 +1,6 @@ -// src/db/utils/pgOwner.js -// - import { pool } from "../client.js"; import { AppError } from "../../middleware/errorHandler.js"; - -// with verification_status = 'verified'. Throws AppError on any failure: -// 404 — no pg_owner_profiles row exists for this user (should not happen if -// authorize('pg_owner') middleware ran first, but guards against data -// integrity issues like a manually deleted profile row) -// 403 — profile exists but status is unverified, pending, or rejected -// -// Returns void on success. The caller proceeds without needing to inspect a -// return value — if this function returns without throwing, the owner is verified. -// -// The `client` parameter defaults to the shared pool. Callers that need to run -// this check inside a transaction pass their checked-out client. In practice -// neither service currently calls this inside a transaction (the verification -// check precedes the transaction), but the parameter makes the function -// consistent with every other utility in this directory. + export const assertPgOwnerVerified = async (userId, client = pool) => { const { rows } = await client.query( `SELECT verification_status diff --git a/src/db/utils/roommateCompatibility.js b/src/db/utils/roommateCompatibility.js new file mode 100644 index 0000000..3d52817 --- /dev/null +++ b/src/db/utils/roommateCompatibility.js @@ -0,0 +1,69 @@ +// src/db/utils/roommateCompatibility.js +import { pool } from "../client.js"; +import { logger } from "../../logger/index.js"; + +export const scoreUsersForUser = async (requestingUserId, candidateIds, client = pool) => { + if (!candidateIds.length) return {}; + + try { + // Step 1 — intersection: pairs where BOTH users have the same key+value. + const { rows: intersectRows } = await client.query( + `SELECT + up_b.user_id, + COUNT(*)::int AS shared_count + FROM user_preferences up_a + JOIN user_preferences up_b + ON up_b.preference_key = up_a.preference_key + AND up_b.preference_value = up_a.preference_value + WHERE up_a.user_id = $1 + AND up_b.user_id = ANY($2::uuid[]) + AND up_b.user_id <> $1 + GROUP BY up_b.user_id`, + [requestingUserId, candidateIds], + ); + + // Step 2 — individual preference counts for union computation. + // FIX: pass a single array $1 for ANY($1::uuid[]) — previously the code + // spread [requestingUserId, ...candidateIds] which bound multiple positional + // params while the SQL only had one placeholder ($1), causing a DB error. + const allIds = [requestingUserId, ...candidateIds]; + const { rows: countRows } = await client.query( + `SELECT user_id, COUNT(*)::int AS pref_count + FROM user_preferences + WHERE user_id = ANY($1::uuid[]) + GROUP BY user_id`, + [allIds], + ); + + const myCount = countRows.find((r) => r.user_id === requestingUserId)?.pref_count ?? 0; + const countMap = Object.fromEntries(countRows.map((r) => [r.user_id, r.pref_count])); + const sharedMap = Object.fromEntries(intersectRows.map((r) => [r.user_id, r.shared_count])); + + return candidateIds.reduce((acc, id) => { + const shared = sharedMap[id] ?? 0; + const theirCount = countMap[id] ?? 0; + const union = myCount + theirCount - shared; + acc[id] = union === 0 ? 0 : Math.round((shared / union) * 100); + return acc; + }, {}); + } catch (err) { + logger.error( + { err, requestingUserId, candidateCount: candidateIds.length }, + "scoreUsersForUser: DB error computing compatibility scores — returning empty scores", + ); + return {}; + } +}; + +export const hasPreferences = async (userId, client = pool) => { + try { + const { rows } = await client.query( + `SELECT EXISTS (SELECT 1 FROM user_preferences WHERE user_id = $1) AS has_prefs`, + [userId], + ); + return rows[0].has_prefs === true; + } catch (err) { + logger.error({ err, userId }, "hasPreferences: DB error checking user preferences — returning false"); + return false; + } +}; diff --git a/src/db/utils/spatial.js b/src/db/utils/spatial.js index 2e47fee..6fb66a5 100644 --- a/src/db/utils/spatial.js +++ b/src/db/utils/spatial.js @@ -1,6 +1,6 @@ -// src/db/utils/spatial.js -// -// Proximity search is now performed inline inside listing.service.js -// (searchListings) using COALESCE(l.location, p.location) with a geography -// cast so that pg_room and hostel_bed listings inherit their parent property's -// coordinates. No standalone helper is needed here. + + + + + + diff --git a/src/logger/index.js b/src/logger/index.js index b2290d9..ad0d685 100644 --- a/src/logger/index.js +++ b/src/logger/index.js @@ -1,11 +1,9 @@ -// src/logger/index.js import pino from "pino"; import { config } from "../config/env.js"; export const logger = pino({ level: config.NODE_ENV === "production" ? "info" : "debug", - // pino-pretty only in development — production needs raw JSON for log aggregators transport: config.NODE_ENV !== "production" ? { diff --git a/src/middleware/authenticate.js b/src/middleware/authenticate.js index d9a126c..a62a71c 100644 --- a/src/middleware/authenticate.js +++ b/src/middleware/authenticate.js @@ -1,5 +1,3 @@ -// src/middleware/authenticate.js - import jwt from "jsonwebtoken"; import { config } from "../config/env.js"; import { AppError } from "./errorHandler.js"; @@ -12,27 +10,24 @@ const INACTIVE_STATUSES = new Set(["suspended", "banned", "deactivated"]); const ACCESS_TTL_SECONDS = parseTtlSeconds(config.JWT_EXPIRES_IN, 15 * 60); const REFRESH_TTL_SECONDS = parseTtlSeconds(config.JWT_REFRESH_EXPIRES_IN, 7 * 24 * 60 * 60); -// Cookie options are module-scope constants because a cookie set with a given -// sameSite/secure configuration must be cleared or replaced with the exact same -// flags — mismatched options cause browsers to treat them as different cookies. -const ACCESS_COOKIE_OPTIONS = { +const IS_PROD = config.NODE_ENV === "production"; + +export const ACCESS_COOKIE_OPTIONS = { httpOnly: true, - secure: config.NODE_ENV === "production", - sameSite: "strict", + secure: IS_PROD, + sameSite: IS_PROD ? "none" : "lax", maxAge: ACCESS_TTL_SECONDS * 1000, }; -const REFRESH_COOKIE_OPTIONS = { + +export const REFRESH_COOKIE_OPTIONS = { httpOnly: true, - secure: config.NODE_ENV === "production", - sameSite: "strict", + secure: IS_PROD, + sameSite: IS_PROD ? "none" : "lax", maxAge: REFRESH_TTL_SECONDS * 1000, }; const refreshTokenKey = (userId, sid) => `refreshToken:${userId}:${sid}`; -// Extracts the access token from req.cookies.accessToken (priority) or the -// Authorization: Bearer header. Returns { token, source } or null. -// Cookie takes priority so browser clients always use the secure HttpOnly path. const extractToken = (req) => { const cookieToken = req.cookies?.accessToken; if (cookieToken) { @@ -47,23 +42,12 @@ const extractToken = (req) => { return null; }; -// Attempts a silent refresh when the access token cookie is expired. -// Uses verifyRefreshTokenPayload (which migrates legacy tokens that lack a sid) -// rather than raw jwt.verify, so users with pre-sid tokens are not forced to -// re-login when their access token expires. -// -// On success: issues a new access token and rotates the refresh token via CAS, -// sets replacement cookies, and returns { userId, sid }. -// On any failure: returns null — the caller will respond with 401. const attemptSilentRefresh = async (req, res) => { const refreshToken = req.cookies?.refreshToken; if (!refreshToken) return null; let refreshPayload; try { - // verifyRefreshTokenPayload handles legacy tokens (no sid) by migrating them - // to the per-session key scheme. Raw jwt.verify would reject those tokens, - // forcing a re-login unnecessarily. refreshPayload = await verifyRefreshTokenPayload(refreshToken); } catch { return null; @@ -73,13 +57,11 @@ const attemptSilentRefresh = async (req, res) => { return null; } - // Verify the token is still stored in Redis (not revoked via logout/revokeSession). const storedToken = await redis.get(refreshTokenKey(refreshPayload.userId, refreshPayload.sid)); if (!storedToken || storedToken !== refreshToken) { return null; } - // Load fresh user state — the refresh token payload can be up to 7 days old. const { rows: roleRows } = await pool.query(`SELECT role_name FROM user_roles WHERE user_id = $1`, [ refreshPayload.userId, ]); @@ -123,9 +105,6 @@ const attemptSilentRefresh = async (req, res) => { return { userId: refreshPayload.userId, sid: refreshPayload.sid }; }; -// Verifies the access token, loads the user from DB, and attaches req.user. -// Cookie-source expired tokens trigger a silent refresh (browser UX). -// Header-source expired tokens return 401 immediately (Android handles refresh explicitly). export const authenticate = async (req, res, next) => { try { const extracted = extractToken(req); diff --git a/src/middleware/authorize.js b/src/middleware/authorize.js index 66cfd60..1385b65 100644 --- a/src/middleware/authorize.js +++ b/src/middleware/authorize.js @@ -1,17 +1,5 @@ -// src/middleware/authorize.js - import { AppError } from "./errorHandler.js"; -// Role gate — must run after authenticate (req.user must exist). -// Usage: router.get('/queue', authenticate, authorize('admin'), controller.fn) -// -// The role parameter is validated immediately when authorize(role) is called — -// at route registration time, not per-request. This means a misconfigured route -// (e.g. authorize() or authorize(undefined)) throws at startup rather than -// silently making every request to that route return 403 at runtime. -// -// If req.user is missing entirely it means authorize() was placed on a route -// without authenticate() — that is a programming error, not an auth failure. export const authorize = (role) => { if (!role || typeof role !== "string" || !role.trim()) { throw new Error( @@ -25,9 +13,6 @@ export const authorize = (role) => { return next(new AppError("authenticate middleware must run before authorize", 500)); } - // Defensive guard: findUserById always returns COALESCE(..., '{}') so roles - // is always an array, but guard here anyway to prevent a future refactor from - // causing a silent TypeError instead of a clear 403. if (!Array.isArray(req.user.roles) || !req.user.roles.includes(role)) { return next(new AppError("Forbidden", 403)); } diff --git a/src/middleware/contactRevealGate.js b/src/middleware/contactRevealGate.js index a8d09de..8fefdf5 100644 --- a/src/middleware/contactRevealGate.js +++ b/src/middleware/contactRevealGate.js @@ -1,67 +1,14 @@ -// src/middleware/contactRevealGate.js -// -// ─── CONTACT REVEAL ACCESS POLICY ──────────────────────────────────────────── -// -// Two-tier access model: -// -// VERIFIED USERS (authenticated + isEmailVerified === true) -// → Unlimited reveals. Full contact bundle (email + whatsapp_phone). -// -// GUESTS / UNVERIFIED USERS -// → Up to 10 email-only reveals in a 30-day rolling window. On the 11th -// attempt the middleware short-circuits with CONTACT_REVEAL_LIMIT_REACHED. -// -// ─── COUNTING STRATEGY ─────────────────────────────────────────────────────── -// -// Primary counter: Redis, keyed on a SHA-256 fingerprint of IP + User-Agent. -// Fallback: HttpOnly cookie mirror count for Redis-unavailable scenarios. -// -// ─── QUOTA TIMING — INTERCEPT BEFORE HEADERS ARE SENT ──────────────────────── -// -// The original implementation incremented the Redis counter and wrote the cookie -// inside a `res.on("finish")` listener. The `finish` event fires AFTER the -// response has been completely flushed to the network, at which point -// `res.setHeader()` (called internally by `res.cookie()`) silently fails or -// throws ERR_HTTP_HEADERS_SENT — the cookie is never actually delivered to the -// browser, making the quota unenforceable. -// -// The fix wraps the three response-ending methods `res.json`, `res.send`, and -// `res.end` so that quota charging and cookie writing happen BEFORE the original -// method is invoked, while headers are still open. The wrapper is idempotent — -// once chargeQuota has run it unsets itself so it cannot fire twice even if -// downstream code calls both `res.json` and `res.end`. -// -// We only charge quota when `res.statusCode` is 2xx at call time (i.e. the -// controller produced a successful reveal). Non-2xx responses (404 user not -// found, 500 server error) do not consume a slot from the caller's allowance. -// -// ─── ATOMICITY ─────────────────────────────────────────────────────────────── -// -// The previous INCR → conditional EXPIRE pattern was non-atomic: a process -// crash between the two calls could leave the key with no TTL, making it -// permanent. This version uses a Lua script that performs INCR and SET-TTL-IF-NEW -// atomically in a single round-trip. -// -// ─── COLLISION MONITORING ──────────────────────────────────────────────────── -// -// IP+UA fingerprints can collide in shared-NAT environments. We emit a structured -// warning when the counter crosses a configurable threshold (default 50) so -// operators can investigate. - import crypto from "crypto"; import { redis } from "../cache/client.js"; import { logger } from "../logger/index.js"; const COOKIE_NAME = "contactRevealAnonCount"; const MAX_FREE_REVEALS = 10; -const TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days, fixed from first reveal +const TTL_SECONDS = 30 * 24 * 60 * 60; const LOGIN_REDIRECT_PATH = "/login/signup"; -// Emit a warning when a single fingerprint exceeds this count. const HIGH_COUNT_WARNING_THRESHOLD = 50; -// Lua script: atomically INCR the key and set TTL only on the first increment. -// Returns the new count (integer). const INCR_WITH_INITIAL_TTL_SCRIPT = ` local count = redis.call("INCR", KEYS[1]) if count == 1 then @@ -102,40 +49,13 @@ const limitReachedResponse = (res) => loginRedirect: LOGIN_REDIRECT_PATH, }); -// ─── Response-interception helper ──────────────────────────────────────────── -// -// Wraps res.json, res.send, and res.end so that chargeQuota() runs synchronously -// (for cookie writes) and asynchronously (for Redis) BEFORE the original method -// flushes the response. The wrapper is one-shot: after the first call it removes -// itself to prevent double-charging if the controller invokes multiple -// response-ending methods (which is unusual but possible with some error paths). -// -// Why wrap all three? Express's res.json calls res.send which calls res.end, but -// some middleware and error handlers call res.end directly. Wrapping all three -// ensures we never miss a response regardless of how it was terminated. const installPreResponseHook = (res, safeCookieCount, fingerprint, redisKey) => { let charged = false; - // chargeQuota attempts to increment the Redis counter and set the cookie. - // It is called from within the wrapped response methods while headers are - // still open, so res.cookie() works correctly. - // - // The function is intentionally synchronous for the cookie write (which is - // a pure in-process header mutation) and fire-and-forget for the Redis EVAL - // (which requires a network round-trip). Setting the cookie with the - // optimistic new count (safeCookieCount + 1) before the Redis result returns - // is correct because: (a) the cookie is only a best-effort pre-check that - // avoids a Redis round-trip for obviously-over-limit callers, and (b) the - // Redis counter is the authoritative source — a later request will read it - // and correct any discrepancy. const chargeQuota = (res) => { if (charged) return; charged = true; - // Optimistic cookie write using the current known count + 1. This is - // done synchronously while headers are still open. If Redis returns a - // different authoritative count, the cookie will be corrected on the - // next successful reveal. res.cookie(COOKIE_NAME, String(safeCookieCount + 1), { httpOnly: true, secure: process.env.NODE_ENV === "production", @@ -143,10 +63,6 @@ const installPreResponseHook = (res, safeCookieCount, fingerprint, redisKey) => maxAge: TTL_SECONDS * 1000, }); - // Fire-and-forget Redis increment. If Redis is available, update the - // authoritative counter and overwrite the cookie with the real count. - // We do not await this because we are inside a response method and - // cannot delay the response for a network call. if (redis?.isOpen) { redis .eval(INCR_WITH_INITIAL_TTL_SCRIPT, { @@ -173,9 +89,6 @@ const installPreResponseHook = (res, safeCookieCount, fingerprint, redisKey) => }, "contactRevealGate: quota charged", ); - // We cannot update the cookie here since the response has already - // been sent. The Redis-authoritative count will be read on the next - // request's pre-check and will self-correct any optimistic drift. }) .catch((redisErr) => { logger.warn( @@ -186,9 +99,6 @@ const installPreResponseHook = (res, safeCookieCount, fingerprint, redisKey) => } }; - // Wrap the three response-ending methods. Each wrapper checks statusCode at - // call time: only 2xx responses are charged. The original method is always - // called regardless of whether charging happened. const wrapMethod = (methodName) => { const original = res[methodName].bind(res); res[methodName] = (...args) => { @@ -196,8 +106,7 @@ const installPreResponseHook = (res, safeCookieCount, fingerprint, redisKey) => if (code >= 200 && code < 300) { chargeQuota(res); } - // Restore originals before calling to prevent infinite recursion if - // the original method itself calls another wrapped method. + res.json = originalJson; res.send = originalSend; res.end = originalEnd; @@ -205,8 +114,6 @@ const installPreResponseHook = (res, safeCookieCount, fingerprint, redisKey) => }; }; - // Capture original references before any wrapping occurs so the restore - // inside each wrapper points to the genuine originals, not another wrapper. const originalJson = res.json.bind(res); const originalSend = res.send.bind(res); const originalEnd = res.end.bind(res); @@ -217,16 +124,11 @@ const installPreResponseHook = (res, safeCookieCount, fingerprint, redisKey) => }; export const contactRevealGate = async (req, res, next) => { - // ── Tier 1: verified user — unlimited, full bundle ──────────────────────── if (req.user?.isEmailVerified === true) { req.contactReveal = { emailOnly: false, verified: true }; return next(); } - // ── Tier 2: guest / unverified — quota-gated, email only ───────────────── - - // Read the cookie count for a quick pre-check. If the cookie already shows - // the limit has been reached we can short-circuit without touching Redis. const cookieCount = Number.parseInt(req.cookies?.[COOKIE_NAME] ?? "0", 10); const safeCookieCount = Number.isFinite(cookieCount) ? cookieCount : 0; @@ -235,11 +137,6 @@ export const contactRevealGate = async (req, res, next) => { return limitReachedResponse(res); } - // ── Redis pre-check (read only, no increment yet) ───────────────────────── - // - // Before allowing the request through, check whether the caller has already - // exhausted their quota. We do NOT increment here — incrementing happens only - // after a successful 2xx response via the pre-response hook below. const fingerprint = anonFingerprint(req); const redisKey = `contactRevealAnon:${fingerprint}`; @@ -256,7 +153,6 @@ export const contactRevealGate = async (req, res, next) => { return limitReachedResponse(res); } } catch (redisErr) { - // Redis unavailable — fall through to cookie-only enforcement. logger.warn( { err: redisErr.message }, "contactRevealGate: Redis pre-check unavailable — falling back to cookie-only enforcement", @@ -264,16 +160,8 @@ export const contactRevealGate = async (req, res, next) => { } } - // ── Install pre-response hook ───────────────────────────────────────────── - // - // The hook wraps res.json / res.send / res.end so that quota charging and - // cookie writing happen BEFORE headers are flushed. This replaces the - // previous res.on("finish") approach which called res.cookie() after headers - // were already sent (ERR_HTTP_HEADERS_SENT). installPreResponseHook(res, safeCookieCount, fingerprint, redisKey); - // Attach the gate context for the controller. emailOnly=true for all - // guests and unverified users — the controller and service respect this. req.contactReveal = { emailOnly: true, verified: false }; return next(); }; diff --git a/src/middleware/errorHandler.js b/src/middleware/errorHandler.js index 8f82bb4..c686cb9 100644 --- a/src/middleware/errorHandler.js +++ b/src/middleware/errorHandler.js @@ -1,5 +1,3 @@ -// src/middleware/errorHandler.js - import { logger } from "../logger/index.js"; import { MAX_UPLOAD_SIZE_BYTES, UPLOAD_FIELD_NAME } from "../config/constants.js"; @@ -22,13 +20,11 @@ export class AppError extends Error { } } -// eslint-disable-next-line no-unused-vars export const errorHandler = (err, req, res, next) => { if (res.headersSent) { return next(err); } - // Zod v4 validation errors — format all issues into field/message pairs. if (err.name === "ZodError") { logger.warn( { @@ -51,8 +47,6 @@ export const errorHandler = (err, req, res, next) => { }); } - // Multer upload errors. Each branch logs the specific Multer error code and - // message so upload failures are diagnosable without reading raw stack traces. if (err.name === "MulterError") { if (err.code === "LIMIT_FILE_SIZE") { logger.warn( @@ -89,7 +83,6 @@ export const errorHandler = (err, req, res, next) => { }); } - // Unknown Multer error — log the full code and message so it is not lost. logger.warn( { req: { method: req.method, url: req.url }, @@ -112,7 +105,6 @@ export const errorHandler = (err, req, res, next) => { }); } - // PostgreSQL constraint errors. if (err.code === "23505") return res.status(409).json({ status: "error", message: "A record with this value already exists" }); if (err.code === "23503") @@ -120,7 +112,6 @@ export const errorHandler = (err, req, res, next) => { if (err.code === "23514") return res.status(400).json({ status: "error", message: "Data failed a database constraint check" }); - // JWT errors. if (err.name === "JsonWebTokenError") return res.status(401).json({ status: "error", message: "Invalid token" }); if (err.name === "TokenExpiredError") return res.status(401).json({ status: "error", message: "Token has expired" }); diff --git a/src/middleware/guestListingGate.js b/src/middleware/guestListingGate.js index 80b7e56..7524cc1 100644 --- a/src/middleware/guestListingGate.js +++ b/src/middleware/guestListingGate.js @@ -1,50 +1,10 @@ -// src/middleware/guestListingGate.js -// -// ─── GUEST BROWSING POLICY ──────────────────────────────────────────────────── -// -// Real SaaS housing platforms (NoBroker, Housing.com, Zillow) allow unauthenticated -// users to browse listings freely. The gate is placed on VALUE EXTRACTION: -// - Contact reveal → contactRevealGate enforces quota -// - Saving a listing → requires authentication (student role) -// - Expressing interest → requires authentication (student role) -// - Compatibility score → omitted for guests (no user preferences available) -// -// Blocking browsing itself creates friction at the top of the funnel and hurts -// conversion — users who cannot see listings will not sign up to see them. -// -// ─── WHAT THIS MIDDLEWARE DOES ─────────────────────────────────────────────── -// -// For guest requests (req.user absent after optionalAuthenticate): -// 1. Silently caps `limit` in req.query to GUEST_MAX_LISTINGS_PER_REQUEST (20). -// The guest sees at most 20 items per page, regardless of what they sent. -// This is the same as the default limit for authenticated users, so in -// practice it only matters if a guest tries to request more. -// -// For authenticated requests: passes through untouched. -// -// ─── WHAT THIS MIDDLEWARE DOES NOT DO ──────────────────────────────────────── -// -// - No Redis counters per fingerprint -// - No filter-action quota (guests can filter freely — that's the product) -// - No hard blocking of browsing -// -// These omissions are intentional. Filter quotas are friction. Real platforms -// use soft login prompts (triggered client-side after N views) rather than -// server-side hard blocks on browsing. The contact reveal gate already handles -// the one piece of PII that actually needs protection. - const GUEST_MAX_LISTINGS_PER_REQUEST = 20; export const guestListingGate = (req, res, next) => { - // Authenticated users pass through without any modification. if (req.user) { return next(); } - // Guest: silently enforce the per-request item cap. - // req.query was already written by the validate() middleware as an own - // data property (see validate.js), so direct assignment is safe here. - // We only cap — never increase — so a guest asking for 5 items still gets 5. const requestedLimit = typeof req.query.limit === "number" ? req.query.limit diff --git a/src/middleware/optionalAuthenticate.js b/src/middleware/optionalAuthenticate.js index 1cba223..fa23bc9 100644 --- a/src/middleware/optionalAuthenticate.js +++ b/src/middleware/optionalAuthenticate.js @@ -1,5 +1,3 @@ -// src/middleware/optionalAuthenticate.js - import jwt from "jsonwebtoken"; import { config } from "../config/env.js"; import { findUserById } from "../db/utils/auth.js"; diff --git a/src/middleware/rateLimiter.js b/src/middleware/rateLimiter.js index b927d5d..0713d16 100644 --- a/src/middleware/rateLimiter.js +++ b/src/middleware/rateLimiter.js @@ -1,27 +1,9 @@ -// src/middleware/rateLimiter.js -// -// All rate limiters live here — one import location for every route file. -// -// ─── DISTRIBUTED STORE ─────────────────────────────────────────────────────── -// -// Uses rate-limit-redis so counters are shared across all Node instances. -// passOnStoreError: true on all limiters — Redis unavailability degrades to no -// rate limiting rather than blocking all requests. -// -// ─── SHUTDOWN ──────────────────────────────────────────────────────────────── -// -// The dedicated Redis client opened here is never used elsewhere, so it must -// be explicitly closed during graceful shutdown. Export closeRateLimitRedisClient -// and call it in server.js before process.exit(0). Without this the socket stays -// open and Node will not exit cleanly. - import rateLimit from "express-rate-limit"; import { RedisStore } from "rate-limit-redis"; import { createClient } from "redis"; import { config } from "../config/env.js"; import { logger } from "../logger/index.js"; -// ─── Dedicated Redis client for rate limiting ───────────────────────────────── const rateLimitRedisClient = createClient({ url: config.REDIS_URL, socket: { @@ -54,30 +36,21 @@ rateLimitRedisClient.connect().catch((err) => { ); }); -// ─── Close helper for graceful shutdown ─────────────────────────────────────── -// -// Call this from server.js during shutdown (before process.exit) so the socket -// is released and Node exits cleanly. Errors are caught and logged; this function -// never throws so it cannot interrupt the shutdown sequence. export const closeRateLimitRedisClient = async () => { try { - if (rateLimitRedisClient.isOpen) { - await rateLimitRedisClient.quit(); - logger.info("Rate limit Redis client closed"); - } + await rateLimitRedisClient.quit(); + logger.info("Rate limit Redis client closed"); } catch (err) { logger.error({ err: err.message }, "Rate limit Redis: error during close — ignoring"); } }; -// ─── Shared Redis store factory ─────────────────────────────────────────────── const makeRedisStore = (prefix) => new RedisStore({ sendCommand: (...args) => rateLimitRedisClient.sendCommand(args), prefix, }); -// ─── OTP send — strictest ──────────────────────────────────────────────────── export const otpLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5, @@ -91,7 +64,6 @@ export const otpLimiter = rateLimit({ }, }); -// ─── Auth endpoints — register / login / refresh ───────────────────────────── export const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10, @@ -105,10 +77,6 @@ export const authLimiter = rateLimit({ }, }); -// ─── Public rating reads — anti-enumeration / anti-scraping ──────────────── -// Applied on unauthenticated public endpoints under /ratings/user/:userId and -// /ratings/property/:propertyId to reduce aggressive scraping while keeping -// normal browsing traffic unaffected. export const publicRatingsLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 120, diff --git a/src/middleware/requireAdmin.js b/src/middleware/requireAdmin.js new file mode 100644 index 0000000..9deb212 --- /dev/null +++ b/src/middleware/requireAdmin.js @@ -0,0 +1,20 @@ +// src/middleware/requireAdmin.js +// Composite guard: email verified AND role === 'admin'. +// Use as: router.get('/route', authenticate, ...requireAdmin, handler) + +import { authorize } from "./authorize.js"; +import { AppError } from "./errorHandler.js"; + +const assertEmailVerified = (req, res, next) => { + if (!req.user) { + // 401 — the caller is unauthenticated, not a server misconfiguration. + return next(new AppError("Authentication required", 401)); + } + if (!req.user.isEmailVerified) { + return next(new AppError("Email verification required", 403)); + } + next(); +}; + +// Export as an array so callers spread it: `...requireAdmin` +export const requireAdmin = [assertEmailVerified, authorize("admin")]; diff --git a/src/middleware/upload.js b/src/middleware/upload.js index 13c83a5..1d30d11 100644 --- a/src/middleware/upload.js +++ b/src/middleware/upload.js @@ -1,5 +1,3 @@ -// src/middleware/upload.js - import multer from "multer"; import path from "path"; import crypto from "crypto"; @@ -7,29 +5,19 @@ import fs from "fs/promises"; import { AppError } from "./errorHandler.js"; import { MAX_UPLOAD_SIZE_BYTES } from "../config/constants.js"; -// Maps each allowed MIME type to its valid file extensions. -// Used to catch obvious mismatches like a .txt file claiming image/jpeg. const MIME_TO_EXTENSIONS = { "image/jpeg": [".jpg", ".jpeg"], "image/png": [".png"], "image/webp": [".webp"], }; -// Derived from MIME_TO_EXTENSIONS so a new entry in the map automatically -// becomes an allowed type — no second list to keep in sync. const ALLOWED_MIME_TYPES = new Set(Object.keys(MIME_TO_EXTENSIONS)); const storage = multer.diskStorage({ - destination: async (_req, _file, cb) => { - try { - // Ensure the staging directory exists before Multer tries to write. - // recursive: true makes this a no-op if the directory already exists, - // so it's safe to call on every upload without a prior existence check. - await fs.mkdir("uploads/staging", { recursive: true }); - cb(null, "uploads/staging"); - } catch (err) { - cb(err); - } + destination: (_req, _file, cb) => { + fs.mkdir("uploads/staging", { recursive: true }) + .then(() => cb(null, "uploads/staging")) + .catch((err) => cb(err)); }, filename: (_req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase() || ".jpg"; @@ -43,9 +31,6 @@ const fileFilter = (_req, file, cb) => { return cb(new AppError(`Unsupported file type: ${file.mimetype}. Accepted types: JPEG, PNG, WebP`, 400)); } - // A client can lie about mimetype. Cross-check the file extension against - // what's expected for the claimed MIME type to catch obvious mismatches - // (e.g. a .txt file with Content-Type: image/jpeg). const ext = path.extname(file.originalname).toLowerCase(); const allowedExts = MIME_TO_EXTENSIONS[file.mimetype]; if (!allowedExts.includes(ext)) { diff --git a/src/middleware/validate.js b/src/middleware/validate.js index 72f5bd7..6427a8a 100644 --- a/src/middleware/validate.js +++ b/src/middleware/validate.js @@ -1,22 +1,3 @@ -// src/middleware/validate.js -// -// validate() wraps a Zod schema and returns an Express middleware. -// Usage: router.post('/register', validate(registerSchema), authController.register) -// -// After successful parse, result.data is written back to req so that Zod -// coercions, defaults, and transformations are visible to downstream handlers. -// -// ─── EXPRESS 5 COMPATIBILITY ────────────────────────────────────────────────── -// -// In Express 5, req.query is defined as a non-writable getter on the prototype, -// which means a direct assignment (`req.query = ...`) throws a TypeError in -// strict mode. We use Object.defineProperty to replace the getter with an -// own data property, which is legal and works in both Express 4 and Express 5. -// -// We only redefine when result.data.query is actually present (not undefined) -// to avoid clobbering Express's lazy query-string parser for routes whose -// schemas don't include a `query` field. - export const validate = (schema) => (req, res, next) => { const result = schema.safeParse({ body: req.body, @@ -28,13 +9,9 @@ export const validate = (schema) => (req, res, next) => { return next(result.error); } - // req.body and req.params are plain writable properties — direct assignment is fine. req.body = result.data.body ?? req.body; req.params = result.data.params ?? req.params; - // req.query may be a non-writable getter in Express 5, so we use - // Object.defineProperty to safely replace it with an own data property. - // Only do this when Zod actually produced a query shape (not undefined). if (result.data.query != null) { Object.defineProperty(req, "query", { value: result.data.query, diff --git a/src/routes/amenities.js b/src/routes/amenities.js new file mode 100644 index 0000000..29ef569 --- /dev/null +++ b/src/routes/amenities.js @@ -0,0 +1,21 @@ +import { Router } from "express"; +import { pool } from "../db/client.js"; + +export const amenitiesRouter = Router(); + +amenitiesRouter.get("/", async (req, res, next) => { + try { + const { rows } = await pool.query( + `SELECT + amenity_id AS "amenityId", + name, + category, + icon_name AS "iconName" + FROM amenities + ORDER BY category, name`, + ); + res.json({ status: "success", data: { items: rows } }); + } catch (err) { + next(err); + } +}); diff --git a/src/routes/auth.js b/src/routes/auth.js index f25139b..4897d9e 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -1,5 +1,3 @@ -// src/routes/auth.js - import { Router } from "express"; import { authenticate } from "../middleware/authenticate.js"; import { validate } from "../middleware/validate.js"; @@ -18,22 +16,11 @@ import * as authController from "../controllers/auth.controller.js"; export const authRouter = Router(); -// Rate limiter runs first — before validation and before any DB work — -// so abusive traffic is dropped at the router edge with zero downstream cost. authRouter.post("/register", authLimiter, validate(registerSchema), authController.register); authRouter.post("/login", authLimiter, validate(loginSchema), authController.login); -// /logout does NOT require authenticate because a client may call it with an -// expired access token — the whole point of logout is to revoke the refresh -// token from the HttpOnly cookie even when the access token is already expired. -// The controller reads the refresh token from req.body or req.cookies and the -// auth service validates it directly. A 401 from the access token must not -// block the user from logging out. authRouter.post("/logout", validate(logoutCurrentSchema), authController.logout); -// /logout/current DOES require authenticate: this variant is explicitly tied -// to the currently authenticated session (the sid in the access token) so a -// valid access token is required to identify which session to revoke. authRouter.post("/logout/current", authenticate, validate(logoutCurrentSchema), authController.logout); authRouter.post("/logout/all", authLimiter, authenticate, authController.logoutAll); @@ -46,16 +33,10 @@ authRouter.delete( authController.revokeSession, ); -// refreshSchema now accepts an optional body — browser clients send no body and -// carry the token in the HttpOnly cookie. authRouter.post("/refresh", authLimiter, validate(refreshSchema), authController.refresh); -// OTP send: stricter limit — each request triggers an outbound email. -// OTP verify: authenticate ensures a valid token is present; no extra limiter -// needed here because the service-layer attempt counter is the throttle. authRouter.post("/otp/send", otpLimiter, authenticate, authController.sendOtp); authRouter.post("/otp/verify", authenticate, validate(otpVerifySchema), authController.verifyOtp); authRouter.get("/me", authenticate, authController.me); -// ─── Google OAuth callback ──────────────────────────────────────────────────── authRouter.post("/google/callback", authLimiter, validate(googleCallbackSchema), authController.googleCallback); diff --git a/src/routes/connection.js b/src/routes/connection.js index c965393..498c8cc 100644 --- a/src/routes/connection.js +++ b/src/routes/connection.js @@ -1,27 +1,3 @@ -// src/routes/connection.js -// -// Mounted at /api/v1/connections. -// -// ─── ROUTE REGISTRATION ORDER ──────────────────────────────────────────────── -// Express matches routes in registration order. The static segment /me must be -// registered BEFORE the parameterised segment /:connectionId, otherwise a GET -// to /connections/me would match /:connectionId with connectionId = "me", fail -// UUID validation in the schema, and return a 400 instead of the dashboard. -// -// This is the same ordering discipline applied in listing.js (/me/saved before -// /:listingId) and interest.js (/me before /:interestId). -// -// ─── ROUTE SURFACE ─────────────────────────────────────────────────────────── -// -// GET /connections/me — user's connection dashboard feed -// GET /connections/:connectionId — single connection detail -// POST /connections/:connectionId/confirm — flip caller's confirmation flag -// -// There is intentionally no POST /connections — connections are created -// exclusively by the interest service inside the accepted-transition transaction. -// Exposing a creation endpoint would allow connections to be created outside the -// trust pipeline, which would undermine the anti-fake-review guarantee. - import { Router } from "express"; import { authenticate } from "../middleware/authenticate.js"; import { validate } from "../middleware/validate.js"; @@ -30,20 +6,8 @@ import * as connectionController from "../controllers/connection.controller.js"; export const connectionRouter = Router(); -// ─── Static routes first ────────────────────────────────────────────────────── - -// GET /api/v1/connections/me — the user's full connection history. -// No role restriction — both students and PG owners have connections. The -// service queries WHERE (initiator_id = $1 OR counterpart_id = $1) so the -// authenticated user always sees only their own connections. connectionRouter.get("/me", authenticate, validate(getMyConnectionsSchema), connectionController.getMyConnections); -// ─── Parameterised routes after all static routes ───────────────────────────── - -// GET /api/v1/connections/:connectionId — single connection detail. -// No role restriction — both parties need this endpoint (to check the other's -// confirmation status, to get the listing summary, etc.). Third parties get -// 404 enforced in the service via WHERE clause, not a 403. connectionRouter.get( "/:connectionId", authenticate, @@ -51,10 +15,6 @@ connectionRouter.get( connectionController.getConnection, ); -// POST /api/v1/connections/:connectionId/confirm — flip caller's confirmation flag. -// No role restriction — both the student (initiator) and the PG owner -// (counterpart) confirm via the same endpoint. The service resolves which -// flag belongs to the caller from the row itself. connectionRouter.post( "/:connectionId/confirm", authenticate, diff --git a/src/routes/health.js b/src/routes/health.js index f9b8e3b..cbf035d 100644 --- a/src/routes/health.js +++ b/src/routes/health.js @@ -1,5 +1,3 @@ -// src/routes/health.js - import { Router } from "express"; import { pool } from "../db/client.js"; import { redis } from "../cache/client.js"; @@ -9,34 +7,17 @@ export const healthRouter = Router(); const PROBE_TIMEOUT_MS = 3000; -// ─── Timeout error detection ────────────────────────────────────────────────── -// -// Checks whether an error represents a timeout rather than a connectivity or -// other failure. A string-match on err.message is brittle because error messages -// are not part of any stable contract — they differ across Node.js versions, -// pg versions, and Redis client versions, and they can change without notice. -// -// We check in priority order: -// 1. Well-known error codes (ETIMEDOUT, ECONNABORTED) — the most reliable signal. -// 2. Well-known error names (TimeoutError) — used by some promise-based libraries. -// 3. Instance checks against built-in timeout error types — future-proofing. -// 4. Case-insensitive regex on the message — last resort for anything else. const isTimeoutError = (err) => { if (!err) return false; - // Explicit error codes set by the OS or Node.js networking layer. if (err.code === "ETIMEDOUT" || err.code === "ECONNABORTED" || err.code === "ESOCKETTIMEDOUT") { return true; } - // Error name used by some promise-based timeout wrappers and newer runtimes. if (err.name === "TimeoutError" || err.name === "AbortError") { return true; } - // Fall back to a case-insensitive pattern match on the message as a last - // resort. This catches the message produced by our own withTimeout() helper - // ("... probe timed out after ...") as well as any third-party timeout messages. if (typeof err.message === "string" && /timed?\s*out/i.test(err.message)) { return true; } @@ -44,10 +25,6 @@ const isTimeoutError = (err) => { return false; }; -// ─── withTimeout ────────────────────────────────────────────────────────────── -// -// Races a promise against a timeout and clears the timer either way to prevent -// accumulating live timer handles across high-frequency health-check calls. const withTimeout = (promise, label) => { let timeoutId; @@ -63,12 +40,6 @@ const withTimeout = (promise, label) => { return Promise.race([guardedPromise, timeoutPromise]); }; -// GET /api/v1/health -// Returns 200 if server, database, and Redis are all reachable. -// Returns 503 if any dependency is degraded or timed out. -// -// Error details are logged server-side and never exposed in the response body -// to avoid leaking internal topology (hostnames, ports, SSL details, etc.). healthRouter.get("/", async (req, res) => { const health = { status: "ok", diff --git a/src/routes/index.js b/src/routes/index.js index ab82fa2..e446e33 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -1,5 +1,3 @@ -// src/routes/index.js - import { Router } from "express"; import { healthRouter } from "./health.js"; import { authRouter } from "./auth.js"; @@ -12,12 +10,14 @@ import { connectionRouter } from "./connection.js"; import { notificationRouter } from "./notification.js"; import { ratingRouter } from "./rating.js"; import { preferencesRouter } from "./preferences.js"; +import { amenitiesRouter } from "./amenities.js"; +import { rentIndexRouter } from "./rentIndex.js"; +import { savedSearchRouter } from "./savedSearch.js"; import { testUtilsRouter } from "./testUtils.js"; +import { verificationRouter } from "./verification.js"; +import { reportRouter } from "./report.js"; import { config } from "../config/env.js"; -// All feature routers are imported and mounted here as phases are built. -// Pattern: import → router.use('/path', featureRouter) - export const rootRouter = Router(); rootRouter.use("/health", healthRouter); @@ -31,9 +31,14 @@ rootRouter.use("/connections", connectionRouter); rootRouter.use("/notifications", notificationRouter); rootRouter.use("/ratings", ratingRouter); rootRouter.use("/preferences", preferencesRouter); +rootRouter.use("/amenities", amenitiesRouter); +rootRouter.use("/rent-index", rentIndexRouter); +rootRouter.use("/saved-searches", savedSearchRouter); +rootRouter.use("/verification", verificationRouter); +rootRouter.use("/reports", reportRouter); + if (config.NODE_ENV !== "production") { rootRouter.use("/test-utils", testUtilsRouter); - // Log at startup so it's always visible in the terminal that this is active import("../logger/index.js").then(({ logger }) => { logger.warn("⚠️ Test utility routes are mounted — not for production use"); }); diff --git a/src/routes/interest.js b/src/routes/interest.js index 42a76db..9d7c5c3 100644 --- a/src/routes/interest.js +++ b/src/routes/interest.js @@ -1,32 +1,3 @@ -// src/routes/interest.js -// -// Top-level interest router, mounted at /api/v1/interests. -// -// This router handles endpoints scoped to the interest request itself rather -// than to a parent listing: -// -// GET /interests/me — student's own interest dashboard -// GET /interests/:interestId — single interest request detail -// PATCH /interests/:interestId/status — trigger a state machine transition -// -// The listing-scoped routes (POST + GET on a specific listing's interests) -// live in src/routes/listing.js as child resource routes under /:listingId. -// Splitting them this way avoids forcing the student dashboard through the -// listing router where listingId is a required path segment. -// -// ─── ROUTE ORDER NOTE ──────────────────────────────────────────────────────── -// Express matches routes in registration order. Static segments must come before -// parameterised segments that share the same URL prefix. Concretely: -// -// GET /me — registered FIRST (static segment) -// GET /:interestId — registered SECOND (param segment) -// PATCH /:interestId/status — registered THIRD (param + static child) -// -// If /:interestId were registered before /me, a GET /me request would match the -// param route with interestId = "me", which would then fail UUID validation and -// return a 400 instead of hitting the dashboard handler. Correct order prevents -// this entirely. - import { Router } from "express"; import { authenticate } from "../middleware/authenticate.js"; import { authorize } from "../middleware/authorize.js"; @@ -40,11 +11,6 @@ import * as interestController from "../controllers/interest.controller.js"; export const interestRouter = Router(); -// ─── Static routes first ────────────────────────────────────────────────────── - -// GET /api/v1/interests/me — student's own interest request dashboard. -// Students only: a PG owner does not send interest requests, they receive them. -// If a PG owner also holds a student role, they access this as their student persona. interestRouter.get( "/me", authenticate, @@ -53,21 +19,8 @@ interestRouter.get( interestController.getMyInterestRequests, ); -// ─── Parameterised routes after all static routes ───────────────────────────── - -// GET /api/v1/interests/:interestId — single interest request detail. -// No role restriction at the route level — both the student who sent the request -// and the poster who received it need this endpoint. The service enforces that -// the caller is one of the two parties, returning 404 for any third party -// (existence is not confirmed to unauthorised callers). interestRouter.get("/:interestId", authenticate, validate(interestParamsSchema), interestController.getInterestRequest); -// PATCH /api/v1/interests/:interestId/status — trigger a state machine transition. -// No role restriction at the route level — both students (withdraw) and posters -// (accept/decline) use this endpoint. The service determines the actor's role -// by comparing the caller's userId against sender_id and poster_id on the row, -// then validates the transition against the ALLOWED_TRANSITIONS table. -// A caller who is neither party gets a 404 — existence not confirmed. interestRouter.patch( "/:interestId/status", authenticate, diff --git a/src/routes/listing.js b/src/routes/listing.js index 9fd010b..51dff52 100644 --- a/src/routes/listing.js +++ b/src/routes/listing.js @@ -1,23 +1,4 @@ // src/routes/listing.js -// -// ─── AUTH POLICY ───────────────────────────────────────────────────────────── -// -// READ routes (GET / and GET /:listingId): -// optionalAuthenticate → guestListingGate → validate → controller -// -// Guests can browse freely. optionalAuthenticate resolves req.user if a -// valid token is present; guestListingGate caps the response size for guests. -// The service omits compatibility scoring when req.user is absent. -// -// WRITE routes (POST, PUT, PATCH, DELETE) and saved listings: -// authenticate (required) — unchanged behaviour. -// -// ─── ROUTE REGISTRATION ORDER NOTE ─────────────────────────────────────────── -// Static path segments must be registered BEFORE parameterised segments that -// share the same prefix, or the parameterised route will shadow them. -// /me/saved and / (search) are registered first; /:listingId comes after. -// ───────────────────────────────────────────────────────────────────────────── - import { Router } from "express"; import { authenticate } from "../middleware/authenticate.js"; import { optionalAuthenticate } from "../middleware/optionalAuthenticate.js"; @@ -34,6 +15,9 @@ import { saveListingSchema, savedListingsSchema, } from "../validators/listing.validators.js"; +import { createInterestSchema, getListingInterestsSchema } from "../validators/interest.validators.js"; +import * as interestController from "../controllers/interest.controller.js"; + import * as listingController from "../controllers/listing.controller.js"; import { upload } from "../middleware/upload.js"; import { @@ -44,12 +28,11 @@ import { } from "../validators/photo.validators.js"; import * as photoController from "../controllers/photo.controller.js"; import { UPLOAD_FIELD_NAME } from "../config/constants.js"; +import { renewListingHandler } from "../controllers/listingRenewal.controller.js"; +import { getListingAnalyticsHandler } from "../controllers/listingAnalytics.controller.js"; export const listingRouter = Router(); -// ─── Static routes first — must precede /:listingId ────────────────────────── - -// Saved listings: auth required (student only) listingRouter.get( "/me/saved", authenticate, @@ -58,10 +41,6 @@ listingRouter.get( listingController.getSavedListings, ); -// Search listings: guests allowed -// Chain: optionalAuthenticate → validate → guestListingGate → controller -// validate runs before guestListingGate so that the limit field is already -// coerced to a number (by Zod) when guestListingGate reads it. listingRouter.get( "/", optionalAuthenticate, @@ -70,12 +49,8 @@ listingRouter.get( listingController.searchListings, ); -// Create listing: auth required listingRouter.post("/", authenticate, validate(createListingSchema), listingController.createListing); -// ─── Parameterised routes — after all static routes ────────────────────────── - -// Get single listing: guests allowed listingRouter.get( "/:listingId", optionalAuthenticate, @@ -84,7 +59,6 @@ listingRouter.get( listingController.getListing, ); -// Write routes: auth required listingRouter.put("/:listingId", authenticate, validate(updateListingSchema), listingController.updateListing); listingRouter.delete("/:listingId", authenticate, validate(listingParamsSchema), listingController.deleteListing); @@ -96,7 +70,9 @@ listingRouter.patch( listingController.updateListingStatus, ); -// ─── Child resource routes ──────────────────────────────────────────────────── +listingRouter.post("/:listingId/renew", authenticate, validate(listingParamsSchema), renewListingHandler); + +listingRouter.get("/:listingId/analytics", authenticate, validate(listingParamsSchema), getListingAnalyticsHandler); listingRouter.get( "/:listingId/preferences", @@ -128,9 +104,7 @@ listingRouter.delete( listingController.unsaveListing, ); -// ─── Photo routes (child resource of /:listingId) ──────────────────────────── - -listingRouter.get("/:listingId/photos", authenticate, validate(uploadPhotoSchema), photoController.getPhotos); +listingRouter.get("/:listingId/photos", authenticate, validate(listingParamsSchema), photoController.getPhotos); listingRouter.post( "/:listingId/photos", @@ -161,11 +135,6 @@ listingRouter.put( photoController.reorderPhotos, ); -// ─── Interest request routes (child resource of /:listingId) ───────────────── - -import { createInterestSchema, getListingInterestsSchema } from "../validators/interest.validators.js"; -import * as interestController from "../controllers/interest.controller.js"; - listingRouter.post( "/:listingId/interests", authenticate, diff --git a/src/routes/notification.js b/src/routes/notification.js index 2347d8e..4d80a53 100644 --- a/src/routes/notification.js +++ b/src/routes/notification.js @@ -1,12 +1,3 @@ -// src/routes/notification.js -// -// All three routes require authentication — notifications are always personal. -// No role restriction: both students and PG owners receive notifications. -// -// Route order note: /unread-count and /mark-read are both static paths so -// there is no parameterised segment ambiguity to worry about here, unlike -// the connection and interest routers. - import { Router } from "express"; import { authenticate } from "../middleware/authenticate.js"; import { validate } from "../middleware/validate.js"; @@ -15,14 +6,8 @@ import * as notificationController from "../controllers/notification.controller. export const notificationRouter = Router(); -// GET /api/v1/notifications -// Paginated notification feed. Optional isRead filter, keyset cursor pagination. notificationRouter.get("/", authenticate, validate(getFeedSchema), notificationController.getFeed); -// GET /api/v1/notifications/unread-count -// Bell badge count — runs on every page load, covered by the partial index. notificationRouter.get("/unread-count", authenticate, notificationController.getUnreadCount); -// POST /api/v1/notifications/mark-read -// Body: { all: true } or { notificationIds: [uuid, ...] } notificationRouter.post("/mark-read", authenticate, validate(markReadSchema), notificationController.markRead); diff --git a/src/routes/pgOwner.js b/src/routes/pgOwner.js index 5d17659..4d02103 100644 --- a/src/routes/pgOwner.js +++ b/src/routes/pgOwner.js @@ -1,44 +1,57 @@ -// src/routes/pgOwner.js - import { Router } from "express"; import { authenticate } from "../middleware/authenticate.js"; import { optionalAuthenticate } from "../middleware/optionalAuthenticate.js"; import { contactRevealGate } from "../middleware/contactRevealGate.js"; import { authorize } from "../middleware/authorize.js"; import { validate } from "../middleware/validate.js"; +import { upload } from "../middleware/upload.js"; +import { UPLOAD_FIELD_NAME } from "../config/constants.js"; import { getPgOwnerParamsSchema, updatePgOwnerSchema } from "../validators/pgOwner.validators.js"; import { submitDocumentSchema } from "../validators/verification.validators.js"; import * as pgOwnerController from "../controllers/pgOwner.controller.js"; import * as verificationController from "../controllers/verification.controller.js"; +import * as profilePhotoController from "../controllers/profilePhoto.controller.js"; +import { AppError } from "../middleware/errorHandler.js"; export const pgOwnerRouter = Router(); +const requireSelf = (req, res, next) => { + if (req.user?.userId !== req.params.userId) { + return next(new AppError("Forbidden", 403)); + } + next(); +}; + pgOwnerRouter.get("/:userId/profile", authenticate, validate(getPgOwnerParamsSchema), pgOwnerController.getProfile); -// Contact reveal is POST rather than GET for two reasons: -// 1. GET requests can be prefetched by browsers and cached by intermediaries, -// risking PII leakage via caches or browser history even before the user -// intends to reveal the contact. -// 2. POST semantics correctly model the intent: the caller is performing an -// action (consuming a quota slot and disclosing PII) rather than merely -// reading a resource. -// -// Cache-Control: no-store is set inline here so the response is never stored by -// the browser or any intermediate proxy, regardless of what the client or CDN -// defaults to. -// -// validate runs before contactRevealGate so that malformed UUIDs in the path -// are rejected before the gate increments the caller's reveal quota. +pgOwnerRouter.put( + "/:userId/photo", + authenticate, + authorize("pg_owner"), + requireSelf, + validate(getPgOwnerParamsSchema), + upload.single(UPLOAD_FIELD_NAME), + profilePhotoController.uploadPgOwnerPhoto, +); + +pgOwnerRouter.delete( + "/:userId/photo", + authenticate, + authorize("pg_owner"), + requireSelf, + validate(getPgOwnerParamsSchema), + profilePhotoController.deletePgOwnerPhoto, +); + pgOwnerRouter.post( "/:userId/contact/reveal", optionalAuthenticate, validate(getPgOwnerParamsSchema), - contactRevealGate, (req, res, next) => { - // Prevent any caching of the PII response by browsers, CDNs, or proxies. res.setHeader("Cache-Control", "no-store"); next(); }, + contactRevealGate, pgOwnerController.revealContact, ); diff --git a/src/routes/preferences.js b/src/routes/preferences.js index 9c43fd7..4494017 100644 --- a/src/routes/preferences.js +++ b/src/routes/preferences.js @@ -1,5 +1,3 @@ -// src/routes/preferences.js - import { Router } from "express"; import { authenticate } from "../middleware/authenticate.js"; import * as preferencesController from "../controllers/preferences.controller.js"; diff --git a/src/routes/property.js b/src/routes/property.js index 4cadef2..d8eed3f 100644 --- a/src/routes/property.js +++ b/src/routes/property.js @@ -1,5 +1,3 @@ -// src/routes/property.js - import { Router } from "express"; import { authenticate } from "../middleware/authenticate.js"; import { authorize } from "../middleware/authorize.js"; @@ -14,28 +12,8 @@ import * as propertyController from "../controllers/property.controller.js"; export const propertyRouter = Router(); -// ─── Read routes — any authenticated user ───────────────────────────────────── -// -// GET /:propertyId is intentionally readable by any authenticated user. -// Students need to view property details when evaluating a PG listing — -// the property record is the "building profile" behind the listing card. -// No ownership check here; that check only applies to write operations. propertyRouter.get("/:propertyId", authenticate, validate(propertyParamsSchema), propertyController.getProperty); -// ─── PG owner routes ────────────────────────────────────────────────────────── -// -// All write operations and the owner's own listing are restricted to the -// pg_owner role. authorize('pg_owner') fires at the middleware layer before -// any validation or DB work — a student JWT is rejected immediately. -// -// The service adds a second, independent layer: assertOwnerVerified checks -// verification_status, and the WHERE owner_id = $N clause on UPDATE/DELETE -// ensures a pg_owner cannot act on another owner's property. Two guards, -// two different failure modes, neither redundant. - -// GET / — "my properties" dashboard list. pg_owner only because there is no -// meaningful use case for a student to browse all properties without going -// through listings. This is the management view, not a discovery view. propertyRouter.get( "/", authenticate, diff --git a/src/routes/rating.js b/src/routes/rating.js index c0a8bd4..b20062e 100644 --- a/src/routes/rating.js +++ b/src/routes/rating.js @@ -1,20 +1,3 @@ -// src/routes/rating.js -// -// Mounted at /api/v1/ratings. -// -// ─── ROUTE REGISTRATION ORDER ──────────────────────────────────────────────── -// Static segments (me/given) must come before parameterised segments. -// See original file header for detailed rationale. -// -// ─── ROUTE SURFACE ─────────────────────────────────────────────────────────── -// -// GET /ratings/me/given — authenticated -// GET /ratings/user/:userId — public -// GET /ratings/property/:propertyId — public -// GET /ratings/connection/:connectionId — authenticated -// POST /ratings — authenticated -// POST /ratings/:ratingId/report — authenticated (Phase 4) - import { Router } from "express"; import { authenticate } from "../middleware/authenticate.js"; import { publicRatingsLimiter } from "../middleware/rateLimiter.js"; @@ -32,13 +15,8 @@ import * as reportController from "../controllers/report.controller.js"; export const ratingRouter = Router(); -// ─── Static routes first ────────────────────────────────────────────────────── - ratingRouter.get("/me/given", authenticate, validate(getMyGivenRatingsSchema), ratingController.getMyGivenRatings); -// ─── Parameterised routes after all static routes ───────────────────────────── - -// Public — no authenticate middleware ratingRouter.get( "/user/:userId", publicRatingsLimiter, @@ -62,15 +40,4 @@ ratingRouter.get( ratingRouter.post("/", authenticate, validate(submitRatingSchema), ratingController.submitRating); -// ─── Phase 4: Report a rating ───────────────────────────────────────────────── -// -// POST /api/v1/ratings/:ratingId/report -// Any authenticated user who is a party to the connection the rating references -// can file a report. The service enforces the party-membership check and rejects -// the reporter with a 404 if they are not a party (existence not leaked). -// -// A partial unique index on (reporter_id, rating_id) WHERE status = 'open' -// prevents duplicate open reports from the same person on the same rating. -// A resolved report does not block re-reporting (partial index covers only open -// rows), which allows the admin to re-open a second review cycle if needed. ratingRouter.post("/:ratingId/report", authenticate, validate(submitReportSchema), reportController.submitReport); diff --git a/src/routes/rentIndex.js b/src/routes/rentIndex.js new file mode 100644 index 0000000..c7fde7d --- /dev/null +++ b/src/routes/rentIndex.js @@ -0,0 +1,11 @@ +// src/routes/rentIndex.js + +import { Router } from "express"; +import { validate } from "../middleware/validate.js"; +import { getRentIndexSchema } from "../validators/rentIndex.validators.js"; +import * as rentIndexController from "../controllers/rentIndex.controller.js"; + +export const rentIndexRouter = Router(); + +// Public — no auth required. Guests viewing a listing should see rent context. +rentIndexRouter.get("/", validate(getRentIndexSchema), rentIndexController.getRentIndex); diff --git a/src/routes/report.js b/src/routes/report.js new file mode 100644 index 0000000..376d00f --- /dev/null +++ b/src/routes/report.js @@ -0,0 +1,29 @@ +// src/routes/report.js +// Report routes: +// GET /reports/queue — Admin: paginated open report queue +// PATCH /reports/:reportId/resolve — Admin: resolve a report +// +// NOTE: POST /ratings/:ratingId/report (submitReport) lives on the ratings router +// because reports are always scoped to a rating. + +import { Router } from "express"; +import { authenticate } from "../middleware/authenticate.js"; +import { requireAdmin } from "../middleware/requireAdmin.js"; +import { validate } from "../middleware/validate.js"; +import { getReportQueueSchema, resolveReportSchema } from "../validators/report.validators.js"; +import * as rc from "../controllers/report.controller.js"; + +export const reportRouter = Router(); + +// GET /reports/queue — paginated list of open reports for admin review +reportRouter.get("/queue", authenticate, ...requireAdmin, validate(getReportQueueSchema), rc.getReportQueue); + +// PATCH /reports/:reportId/resolve — admin marks a report as resolved +// Body: { resolution: 'resolved_kept' | 'resolved_removed', adminNotes?: string } +reportRouter.patch( + "/:reportId/resolve", + authenticate, + ...requireAdmin, + validate(resolveReportSchema), + rc.resolveReport, +); diff --git a/src/routes/roommate.js b/src/routes/roommate.js new file mode 100644 index 0000000..6717fc6 --- /dev/null +++ b/src/routes/roommate.js @@ -0,0 +1,61 @@ +// src/routes/roommate.js + +import { Router } from "express"; +import { authenticate } from "../middleware/authenticate.js"; +import { authorize } from "../middleware/authorize.js"; +import { validate } from "../middleware/validate.js"; +import { AppError } from "../middleware/errorHandler.js"; +import { + getRoommateFeedSchema, + updateRoommateProfileSchema, + blockTargetParamsSchema, +} from "../validators/roommate.validators.js"; +import * as roommateController from "../controllers/roommate.controller.js"; + +export const roommateRouter = Router(); + +// Middleware: authenticated user must match :userId param +const requireSelf = (req, res, next) => { + if (req.user?.userId !== req.params.userId) { + return next(new AppError("Forbidden", 403)); + } + next(); +}; + +// Feed — any authenticated student can browse +roommateRouter.get( + "/roommates", + authenticate, + authorize("student"), + validate(getRoommateFeedSchema), + roommateController.getFeed, +); + +// Opt-in toggle — own profile only +roommateRouter.put( + "/:userId/roommate-profile", + authenticate, + authorize("student"), + requireSelf, + validate(updateRoommateProfileSchema), + roommateController.updateRoommateProfile, +); + +// Block / unblock — :userId must be the authenticated user +roommateRouter.post( + "/:userId/block/:targetUserId", + authenticate, + authorize("student"), + requireSelf, + validate(blockTargetParamsSchema), + roommateController.blockUser, +); + +roommateRouter.delete( + "/:userId/block/:targetUserId", + authenticate, + authorize("student"), + requireSelf, + validate(blockTargetParamsSchema), + roommateController.unblockUser, +); diff --git a/src/routes/savedSearch.js b/src/routes/savedSearch.js new file mode 100644 index 0000000..bfe9d3c --- /dev/null +++ b/src/routes/savedSearch.js @@ -0,0 +1,18 @@ +// src/routes/savedSearch.js + +import { Router } from "express"; +import { authenticate } from "../middleware/authenticate.js"; +import { validate } from "../middleware/validate.js"; +import { + createSavedSearchSchema, + savedSearchParamsSchema, + updateSavedSearchSchema, +} from "../validators/savedSearch.validators.js"; +import * as savedSearchController from "../controllers/savedSearch.controller.js"; + +export const savedSearchRouter = Router(); + +savedSearchRouter.get("/", authenticate, savedSearchController.list); +savedSearchRouter.post("/", authenticate, validate(createSavedSearchSchema), savedSearchController.create); +savedSearchRouter.patch("/:searchId", authenticate, validate(updateSavedSearchSchema), savedSearchController.update); +savedSearchRouter.delete("/:searchId", authenticate, validate(savedSearchParamsSchema), savedSearchController.remove); diff --git a/src/routes/student.js b/src/routes/student.js index 5a48a45..0853e96 100644 --- a/src/routes/student.js +++ b/src/routes/student.js @@ -1,10 +1,16 @@ // src/routes/student.js +// +// IMPORTANT — mount order: +// roommateRouter is mounted FIRST (before /:userId routes) so Express does +// not capture the literal string "roommates" as a :userId param. import { Router } from "express"; import { authenticate } from "../middleware/authenticate.js"; import { optionalAuthenticate } from "../middleware/optionalAuthenticate.js"; import { contactRevealGate } from "../middleware/contactRevealGate.js"; import { validate } from "../middleware/validate.js"; +import { upload } from "../middleware/upload.js"; +import { UPLOAD_FIELD_NAME } from "../config/constants.js"; import { getStudentParamsSchema, updateStudentSchema, @@ -12,36 +18,40 @@ import { updateStudentPreferencesSchema, } from "../validators/student.validators.js"; import * as studentController from "../controllers/student.controller.js"; +import * as profilePhotoController from "../controllers/profilePhoto.controller.js"; +import { roommateRouter } from "./roommate.js"; export const studentRouter = Router(); +// ── Roommate sub-router (no :userId prefix — its own routes carry /:userId) ── +// Must be registered before any /:userId routes below. +studentRouter.use(roommateRouter); + +// ── Profile ─────────────────────────────────────────────────────────────────── studentRouter.get("/:userId/profile", authenticate, validate(getStudentParamsSchema), studentController.getProfile); studentRouter.put("/:userId/profile", authenticate, validate(updateStudentSchema), studentController.updateProfile); -// ─── Cache-Control: no-store ordering ──────────────────────────────────────── -// -// The no-store header must be set BEFORE contactRevealGate runs, not after. -// If the header middleware runs after the gate, any 429 (CONTACT_REVEAL_LIMIT_REACHED) -// short-circuit response produced by the gate would be sent WITHOUT the header, -// potentially allowing the browser or an intermediate proxy to cache a response -// that carries a loginRedirect URL — a PII-adjacent value that must not be cached. -// -// Ordering rationale for the full chain: -// 1. optionalAuthenticate — resolves req.user if a valid token is present -// 2. validate — rejects malformed UUIDs before the gate can -// increment the anonymous quota counter (a loop of -// invalid-UUID requests must not burn quota) -// 3. no-store header — set HERE, before the gate, so every response from -// this route (200, 404, 429, 500) carries the header -// 4. contactRevealGate — enforces quota; may short-circuit with 429 -// 5. studentController.revealContact — fetches and returns the contact bundle +// ── Photo ───────────────────────────────────────────────────────────────────── +studentRouter.put( + "/:userId/photo", + authenticate, + validate(getStudentParamsSchema), + upload.single(UPLOAD_FIELD_NAME), + profilePhotoController.uploadStudentPhoto, +); + +studentRouter.delete( + "/:userId/photo", + authenticate, + validate(getStudentParamsSchema), + profilePhotoController.deleteStudentPhoto, +); + +// ── Contact reveal ──────────────────────────────────────────────────────────── studentRouter.get( "/:userId/contact/reveal", optionalAuthenticate, validate(getStudentParamsSchema), - // Set Cache-Control before the gate so even the gate's 429 short-circuit - // carries no-store. This mirrors the same protection on the PG owner reveal - // route, which also sets no-store before its gate middleware. (req, res, next) => { res.setHeader("Cache-Control", "no-store"); next(); @@ -50,6 +60,7 @@ studentRouter.get( studentController.revealContact, ); +// ── Preferences ─────────────────────────────────────────────────────────────── studentRouter.get( "/:userId/preferences", authenticate, diff --git a/src/routes/testUtils.js b/src/routes/testUtils.js index 32592d8..48cc4f3 100644 --- a/src/routes/testUtils.js +++ b/src/routes/testUtils.js @@ -1,29 +1,11 @@ -// src/routes/testUtils.js -// -// Development-only utility endpoints for testing purposes. -// This entire router is ONLY mounted when NODE_ENV !== 'production'. -// It is never reachable in a deployed environment. - import { Router } from "express"; import { redis } from "../cache/client.js"; import { logger } from "../logger/index.js"; export const testUtilsRouter = Router(); -// POST /api/v1/test-utils/reset-rate-limits -// -// Deletes all rate limiter keys from Redis for the requesting IP address. -// This lets Postman tests run the auth/OTP flows repeatedly without hitting -// the 10-requests-per-15-minutes ceiling that the real limiters enforce. -// -// Why scan instead of direct delete? Because the rate-limit-redis library -// appends the IP to the prefix, and we don't want to hardcode that format. -// Scanning for all keys matching "rl:*" and deleting them is more resilient -// to any future prefix changes in rateLimiter.js. testUtilsRouter.post("/reset-rate-limits", async (req, res, next) => { try { - // KEYS is fine in development (small dataset, no performance concern). - // Never use KEYS in production — it blocks the Redis event loop. const keys = await redis.keys("rl:*"); if (keys.length === 0) { @@ -34,8 +16,6 @@ testUtilsRouter.post("/reset-rate-limits", async (req, res, next) => { }); } - // DEL accepts multiple keys in a single command — more efficient than - // looping and calling DEL once per key. await redis.del(keys); logger.info({ deletedKeys: keys, count: keys.length }, "testUtils: rate limit keys cleared"); @@ -44,7 +24,7 @@ testUtilsRouter.post("/reset-rate-limits", async (req, res, next) => { status: "success", message: `Rate limits cleared`, deletedCount: keys.length, - deletedKeys: keys, // helpful to see exactly what was wiped + deletedKeys: keys, }); } catch (err) { next(err); diff --git a/src/routes/verification.js b/src/routes/verification.js new file mode 100644 index 0000000..2066243 --- /dev/null +++ b/src/routes/verification.js @@ -0,0 +1,16 @@ +// src/routes/verification.js + +import { Router } from "express"; +import { authenticate } from "../middleware/authenticate.js"; +import { authorize } from "../middleware/authorize.js"; +import { requireAdmin } from "../middleware/requireAdmin.js"; +import * as vc from "../controllers/verification.controller.js"; + +export const verificationRouter = Router(); + +verificationRouter.post("/submit", authenticate, authorize("pg_owner"), vc.submitDocument); + +// ── Admin routes ─────────────────────────────────────────────────────────────── +verificationRouter.get("/queue", authenticate, ...requireAdmin, vc.getVerificationQueue); +verificationRouter.patch("/:requestId/approve", authenticate, ...requireAdmin, vc.approveRequest); +verificationRouter.patch("/:requestId/reject", authenticate, ...requireAdmin, vc.rejectRequest); diff --git a/src/server.js b/src/server.js index 0d0a5de..fa1a811 100644 --- a/src/server.js +++ b/src/server.js @@ -1,9 +1,3 @@ -// src/server.js -// -// Bootstrap: connects PostgreSQL, Redis, all BullMQ workers, the CDC outbox -// drainer, and cron maintenance jobs, then starts the HTTP server. Tears -// everything down cleanly in dependency-reverse order on SIGINT/SIGTERM. - import "./config/env.js"; import { app } from "./app.js"; @@ -20,6 +14,8 @@ import { closeRateLimitRedisClient } from "./middleware/rateLimiter.js"; import { registerListingExpiryCron } from "./cron/listingExpiry.js"; import { registerExpiryWarningCron } from "./cron/expiryWarning.js"; import { registerHardDeleteCleanupCron } from "./cron/hardDeleteCleanup.js"; +import { registerRentIndexRefreshCron } from "./cron/rentIndexRefresh.js"; +import { registerSavedSearchAlertCron } from "./cron/savedSearchAlert.js"; const start = async () => { try { @@ -29,49 +25,27 @@ const start = async () => { await connectRedis(); logger.info("Redis connected"); - // ── BullMQ workers ──────────────────────────────────────────────────── - // All workers start after Redis is ready — BullMQ uses Redis as its - // backing store. Each returns a handle used for clean shutdown. const mediaWorker = startMediaWorker(); const notificationWorker = startNotificationWorker(); const emailWorker = startEmailWorker(); - // ── CDC outbox drainer ──────────────────────────────────────────────── - // The verification event worker polls the verification_event_outbox table - // (written by the Postgres trigger trg_verification_status_changed) and - // dispatches in-app notifications + emails for verification status changes. - // Crucially, this worker fires regardless of whether the DB change came - // from the API or from a direct SQL script — the trigger doesn't care. const verificationEventWorker = startVerificationEventWorker(); - // ── Cron jobs ───────────────────────────────────────────────────────── - // Registered after Redis and DB are confirmed healthy. Each register* - // function returns a node-cron ScheduledTask for clean task.stop() on - // shutdown. - const cronTasks = [registerListingExpiryCron(), registerExpiryWarningCron(), registerHardDeleteCleanupCron()]; + // CHANGE this block inside start(): + const cronTasks = [ + registerListingExpiryCron(), + registerExpiryWarningCron(), + registerHardDeleteCleanupCron(), + registerRentIndexRefreshCron(), + registerSavedSearchAlertCron(), + ]; const server = app.listen(config.PORT, () => { logger.info(`Server running on port ${config.PORT} [${config.NODE_ENV}]`); }); - // ── Re-entrancy guard ───────────────────────────────────────────────── let isShuttingDown = false; - // Shutdown order: - // 1. Stop accepting new HTTP connections (server.close) - // 2. Stop cron so no new maintenance work starts - // 3. Await HTTP drain completion - // 4. Close BullMQ workers (drain in-flight jobs) - // 5. Stop the CDC outbox drainer (polling loop) - // 6. Close BullMQ queue registry (Redis connections) - // 7. Close rate-limit Redis client - // 8. Close PostgreSQL pool - // 9. Close main Redis connection - // 10. Exit - // - // Cron is stopped before awaiting HTTP drain (step 2 before step 3) to - // close the window where a cron job could fire and enqueue new BullMQ - // jobs after the workers have already started closing. const shutdown = (signal) => async () => { if (isShuttingDown) { logger.warn(`${signal} received again during shutdown — ignoring duplicate signal`); @@ -82,18 +56,15 @@ const start = async () => { logger.info(`${signal} received — shutting down gracefully`); let serverCloseFailed = false; - // Force exit if graceful shutdown takes longer than 10 seconds. setTimeout(() => { logger.fatal("Shutdown timeout exceeded — forcing exit"); process.exit(1); }, 10_000).unref(); - // Step 1: begin draining HTTP connections. const serverClosePromise = new Promise((resolve, reject) => { server.close((err) => (err ? reject(err) : resolve())); }); - // Step 2: stop cron jobs immediately. for (const task of cronTasks) { try { task.stop(); @@ -103,7 +74,6 @@ const start = async () => { } logger.info("Cron jobs stopped"); - // Step 3: await HTTP drain. try { await serverClosePromise; logger.info("HTTP server closed"); @@ -112,7 +82,6 @@ const start = async () => { logger.error({ err }, "Error closing HTTP server — continuing shutdown cleanup"); } - // Step 4: close BullMQ workers. for (const [name, worker] of [ ["Media", mediaWorker], ["Notification", notificationWorker], @@ -126,7 +95,6 @@ const start = async () => { } } - // Step 5: stop the CDC outbox drainer (synchronous — just clears the interval). try { verificationEventWorker.close(); logger.info("Verification event worker stopped"); @@ -134,7 +102,6 @@ const start = async () => { logger.error({ err }, "Error stopping verification event worker"); } - // Step 6: close BullMQ queue registry. try { await closeAllQueues(); logger.info("BullMQ queues closed"); @@ -142,7 +109,6 @@ const start = async () => { logger.error({ err }, "Error closing BullMQ queues"); } - // Step 7: close rate-limit Redis client. try { await closeRateLimitRedisClient(); logger.info("Rate-limit Redis client closed"); @@ -150,7 +116,6 @@ const start = async () => { logger.error({ err }, "Error closing rate-limit Redis client"); } - // Step 8: close PostgreSQL pool. try { await pool.end(); logger.info("PostgreSQL pool closed"); @@ -158,7 +123,6 @@ const start = async () => { logger.error({ err }, "Error closing PostgreSQL pool"); } - // Step 9: close main Redis connection. try { await redis.close(); logger.info("Redis connection closed"); diff --git a/src/services/auth.service.js b/src/services/auth.service.js index e614f08..e8700c2 100644 --- a/src/services/auth.service.js +++ b/src/services/auth.service.js @@ -1,5 +1,3 @@ -// src/services/auth.service.js - import bcrypt from "bcryptjs"; import jwt from "jsonwebtoken"; import crypto from "crypto"; @@ -55,8 +53,6 @@ const refreshTokenKey = (userId, sid) => `refreshToken:${userId}:${sid}`; const legacyRefreshKey = (userId) => `refreshToken:${userId}`; const userSessionsKey = (userId) => `userSessions:${userId}`; -export const parseTtlSeconds_EXPORTED = parseTtlSeconds; - const buildTokenResponse = (userId, sid, email, roles, isEmailVerified) => { const accessToken = issueAccessToken(userId, email, roles, sid); const refreshToken = issueRefreshToken(userId, sid); @@ -102,29 +98,6 @@ export const casRefreshToken = async ( return result === 1; }; -// Verifies the refresh token JWT and returns { userId, sid }. -// -// Legacy tokens (issued before per-session keys) lack a sid. When detected, -// this function reads the old per-user key (userSessionsKey), generates a fresh -// sid, migrates to the per-session scheme atomically via MULTI/EXEC, and deletes -// the legacy key. -// -// RACE WINDOW (legacy path only — acceptable by design): -// There is a TOCTOU gap between reading legacyToken via -// redis.get(legacyRefreshKey(userId)) -// and the subsequent MULTI/EXEC that writes the new per-session key, removes -// the old legacyKey, and rotates incomingRefreshToken to newRefreshToken. -// A concurrent request with the same incomingRefreshToken could also read -// legacyToken and pass the comparison, then both issue new session IDs. The -// worst outcome is two valid sessions derived from one legacy token — the next -// call to verifyRefreshTokenPayload on either will use the modern per-session -// path and succeed normally. This race is acceptable because: -// 1. Legacy tokens are a transitional artefact being phased out; new logins -// always produce per-session tokens with a sid. -// 2. Making the legacy migration fully atomic would require a Lua script that -// duplicates the rotation logic for a shrinking population of tokens. -// 3. Worst case is a harmless duplicate session, not data loss or privilege -// escalation. The next refresh on either session will CAS-rotate correctly. export const verifyRefreshTokenPayload = async (incomingRefreshToken) => { let payload; try { @@ -137,12 +110,10 @@ export const verifyRefreshTokenPayload = async (incomingRefreshToken) => { throw new AppError("Refresh token payload is invalid", 401); } - // Modern path — token already has a sid. if (payload.sid) { return { userId: payload.userId, sid: payload.sid }; } - // Legacy path — token was minted without a sid. Attempt fallback migration. logger.warn( { userId: payload.userId }, "verifyRefreshTokenPayload: legacy token without sid — attempting migration", @@ -198,8 +169,6 @@ export const verifyRefreshTokenPayload = async (incomingRefreshToken) => { return { userId: payload.userId, sid: newSid }; }; -// Sync variant used by logoutCurrent — migration is not needed there because -// logout only needs to validate and identify the session to revoke. const _verifyRefreshTokenPayloadSync = (incomingRefreshToken) => { let payload; try { @@ -503,18 +472,8 @@ export const sendOtp = async (userId, email) => { const otp = generateOtp(); const hash = await bcrypt.hash(otp, 10); - // Store the OTP hash and reset the attempt counter atomically. - // This must complete before enqueuing the email — if Redis is down here, - // we throw immediately and the email is never queued (correct: there is no - // OTP to verify). If Redis goes down between setEx and enqueueEmail, the OTP - // is stored but no email is sent. The user can hit "resend" to create a fresh - // job. This is the accepted failure mode documented in emailQueue.js. await Promise.all([redis.setEx(`otp:${userId}`, OTP_TTL, hash), redis.del(`otpAttempts:${userId}`)]); - // Enqueue the email job — fire and forget. The worker handles SMTP delivery, - // retries on Brevo failures, and logs exhaustion at error level. - // We no longer await email delivery in the HTTP path, so the response returns - // in ~1ms (just the Redis write above) instead of waiting for the SMTP round-trip. enqueueEmail({ type: "otp", to: email, data: { otp } }); logger.info({ userId }, "OTP enqueued for delivery"); @@ -528,12 +487,11 @@ export const verifyOtp = async (userId, otp, ipAddress) => { const ipAttemptsKey = `ipAttempts:${ipAddress}`; let ipAttempts; try { - ipAttempts = await redis.incr(ipAttemptsKey); - - const ttl = await redis.ttl(ipAttemptsKey); - if (ttl < 0) { - await redis.expire(ipAttemptsKey, OTP_IP_WINDOW_SECONDS); - } + const multi = redis.multi(); + multi.incr(ipAttemptsKey); + multi.expire(ipAttemptsKey, OTP_IP_WINDOW_SECONDS, "NX"); + const results = await multi.exec(); + ipAttempts = results[0]; } catch (err) { logger.error({ err: err.message, userId, ipAddress }, "OTP verify IP limiter failed closed"); throw new AppError("OTP verification is temporarily unavailable", 429); @@ -605,7 +563,6 @@ export const googleOAuth = async ({ idToken, role, fullName, businessName }) => throw new AppError("Google account does not have a verified email address", 400); } - // Path 1: returning OAuth user const existingByGoogleId = await findUserByGoogleId(googleId); if (existingByGoogleId) { @@ -632,9 +589,6 @@ export const googleOAuth = async ({ idToken, role, fullName, businessName }) => return tokens; } - // Path 2: account linking — email-based account exists, no google_id yet. - // AND google_id IS NULL guards against concurrent linking races; a 23505 - // means a different account already claimed this googleId. const existingByEmail = await findUserByEmail(email); if (existingByEmail) { @@ -687,7 +641,6 @@ export const googleOAuth = async ({ idToken, role, fullName, businessName }) => return tokens; } - // Path 3: new user registration via Google. if (!role) { throw new AppError("Role is required for new account registration via Google", 400); } diff --git a/src/services/connection.service.js b/src/services/connection.service.js index b797695..bf3b02a 100644 --- a/src/services/connection.service.js +++ b/src/services/connection.service.js @@ -1,14 +1,8 @@ -// src/services/connection.service.js - import { pool } from "../db/client.js"; import { logger } from "../logger/index.js"; import { AppError } from "../middleware/errorHandler.js"; import { enqueueNotification } from "../workers/notificationQueue.js"; -// ─── Confirm connection ─────────────────────────────────────────────────────── -// Either party flips their own confirmation flag. When both flags become true, -// confirmation_status transitions to 'confirmed' in the same atomic UPDATE. -// Post-commit notifications are sent to both parties only on that transition. export const confirmConnection = async (callerId, connectionId) => { let client; let conn; @@ -18,9 +12,6 @@ export const confirmConnection = async (callerId, connectionId) => { client = await pool.connect(); await client.query("BEGIN"); - // Lock the row and verify party membership in one query. - // If the caller is not a party or the row does not exist, zero rows are - // returned and we throw 404 — existence is never leaked to non-parties. const { rows: prevRows } = await client.query( `SELECT confirmation_status FROM connections @@ -38,8 +29,6 @@ export const confirmConnection = async (callerId, connectionId) => { previousStatus = prevRows[0].confirmation_status; - // Single atomic UPDATE: flips the caller's flag and conditionally promotes - // confirmation_status to 'confirmed' when both flags become true. const { rows } = await client.query( `UPDATE connections SET @@ -74,9 +63,6 @@ export const confirmConnection = async (callerId, connectionId) => { [connectionId, callerId], ); - // The FOR UPDATE above already guarantees the row exists and the caller is - // a party, so rowCount === 0 here would indicate a concurrent hard-delete - // between SELECT and UPDATE — treat as 404. if (!rows.length) { await client.query("ROLLBACK"); throw new AppError("Connection not found", 404); @@ -88,9 +74,7 @@ export const confirmConnection = async (callerId, connectionId) => { if (client) { try { await client.query("ROLLBACK"); - } catch (_) { - // Ignore — connection may already be in an error state. - } + } catch (_) {} } throw err; } finally { @@ -136,10 +120,6 @@ export const confirmConnection = async (callerId, connectionId) => { }; }; -// ─── Get single connection (detail view) ───────────────────────────────────── -// Both parties can view full detail including each other's confirmation status. -// Third parties receive 404 — the WHERE clause never leaks resource existence. -// other_party_name uses only display names; email is never exposed as a fallback. export const getConnection = async (callerId, connectionId) => { const { rows } = await pool.query( `SELECT @@ -239,12 +219,9 @@ export const getConnection = async (callerId, connectionId) => { }; }; -// ─── Get my connections (dashboard feed) ───────────────────────────────────── -// Returns all connections the caller is a party to, newest first. -// other_party_name uses only display names; email is never exposed as a fallback. export const getMyConnections = async (userId, filters) => { const { confirmationStatus, connectionType, cursorTime, cursorId, limit: rawLimit = 20 } = filters; - const limit = Math.min(Math.max(1, rawLimit), 100); // Cap between 1 and 100 + const limit = Math.min(Math.max(1, rawLimit), 100); const clauses = [`(c.initiator_id = $1 OR c.counterpart_id = $1)`, `c.deleted_at IS NULL`]; const params = [userId]; diff --git a/src/services/email.service.js b/src/services/email.service.js index 984545e..2ce7a28 100644 --- a/src/services/email.service.js +++ b/src/services/email.service.js @@ -1,66 +1,3 @@ -// src/services/email.service.js -// -// ─── TRANSPORT ARCHITECTURE ─────────────────────────────────────────────────── -// -// This service supports two email providers, selected at startup by EMAIL_PROVIDER -// in the environment: -// -// "ethereal" — Nodemailer pointed at Ethereal's fake SMTP server. -// Used during local development. Every email is intercepted and -// never delivered to the real recipient. A preview URL is logged -// to the console so you can read the OTP without a real inbox. -// Set EMAIL_PROVIDER=ethereal in .env.local. -// -// "brevo" — Nodemailer pointed at Brevo's SMTP relay -// (smtp-relay.brevo.com:587 with STARTTLS). -// Used in production / staging. Emails are really delivered. -// Brevo's dashboard provides delivery logs and open tracking. -// Set EMAIL_PROVIDER=brevo in your production env file. -// -// Both providers use the same Nodemailer transport API. The rest of this file — -// sendOtpEmail, maskEmail, and the format guards — is identical for both. The -// factory function below just swaps the SMTP credentials underneath. -// -// ─── WHY NOT THE @getbrevo/brevo REST SDK? ─────────────────────────────────── -// -// Brevo publishes an official JS SDK that wraps their REST API. For sending a -// simple OTP email, the SMTP relay via Nodemailer is the better choice because: -// 1. Zero new dependencies — Nodemailer is already installed. -// 2. Identical code path — switching providers is a config change, not a -// code change. The SDK's API differs significantly from Nodemailer's. -// 3. Brevo's own docs recommend Nodemailer for Node.js SMTP relay: -// https://developers.brevo.com/docs/smtp-integration -// -// ─── BREVO CONNECTION SETTINGS ─────────────────────────────────────────────── -// -// Per Brevo's official SMTP relay documentation: -// Host: smtp-relay.brevo.com (hardcoded — never from env vars) -// Port: 587 (non-encrypted; STARTTLS upgrades it) -// secure: false (true is only for port 465 / raw SSL) -// Login: your BREVO_SMTP_LOGIN (e.g. xxxxx@smtp-brevo.com) -// Password: your BREVO_SMTP_KEY (starts with "xsmtpsib-...", NOT the API key) -// -// The host and port are intentionally NOT read from env vars. Brevo has exactly -// one SMTP relay endpoint and one recommended port. Making them configurable -// would only create opportunities for them to be set incorrectly in production. -// -// ─── BREVO SMTP KEY vs API KEY ──────────────────────────────────────────────── -// -// Brevo has two different credential types that look similar but serve different -// purposes: -// SMTP key — starts with "xsmtpsib-..." — used as the SMTP password here. -// API key — starts with "xkeysib-..." — used by the REST SDK / HTTP API. -// -// The cross-field guard in env.js detects the API key being used where the SMTP -// key is expected and exits with a clear error message at startup. -// -// ─── BREVO FROM ADDRESS ────────────────────────────────────────────────────── -// -// BREVO_SMTP_FROM must be an email address that is verified as a sender in your -// Brevo account (Senders & Domains section). Using an unverified address causes -// Brevo to reject the message with a 550 error. This address is what recipients -// see as the "From" field — it can differ from BREVO_SMTP_LOGIN. - import nodemailer from "nodemailer"; import { config } from "../config/env.js"; import { logger } from "../logger/index.js"; @@ -68,14 +5,6 @@ import { AppError } from "../middleware/errorHandler.js"; const activeEmailProvider = config.EMAIL_PROVIDER === "brevo" ? "brevo" : "ethereal"; -// ─── Mask email for safe logging ───────────────────────────────────────────── -// -// "priya@iitb.ac.in" → "p****@iitb.ac.in" -// -// Keeps the first character and the full domain so log correlation remains -// possible (you can identify the user) without exposing the full address in -// log aggregators or audit trails. This is especially important because OTP -// send events are high-frequency and will appear in every logging pipeline. const maskEmail = (email) => { const [local, domain] = email.split("@"); if (!domain) return "****"; @@ -83,13 +12,6 @@ const maskEmail = (email) => { return `${prefix}****@${domain}`; }; -// ─── Transport factory ──────────────────────────────────────────────────────── -// -// Creates and returns a Nodemailer transporter based on EMAIL_PROVIDER. Called -// once at module load time (see the `transport` singleton below) so the TCP -// connection setup cost is paid at startup, not on the first send call. -// Nodemailer reuses the underlying SMTP connection for subsequent calls when -// the server keeps it alive, which both Brevo and Ethereal do. const createEmailTransport = () => { if (config.EMAIL_PROVIDER === "brevo") { logger.info( @@ -104,26 +26,18 @@ const createEmailTransport = () => { ); return nodemailer.createTransport({ - // Brevo's documented SMTP relay host — hardcoded intentionally. - // See: https://developers.brevo.com/docs/smtp-integration host: "smtp-relay.brevo.com", - // Port 587 uses STARTTLS: the connection starts unencrypted and - // Nodemailer upgrades it automatically. secure must be false here. - // (Port 465 with secure: true is the alternative SSL-from-the-start path.) + port: 587, secure: false, auth: { - // BREVO_SMTP_LOGIN: your Brevo SMTP login (e.g. xxxxx@smtp-brevo.com) user: config.BREVO_SMTP_LOGIN, - // BREVO_SMTP_KEY: your Brevo SMTP key (xsmtpsib-...), NOT the API key + pass: config.BREVO_SMTP_KEY, }, }); } - // Default: Ethereal fake SMTP for local development. - // config.SMTP_HOST / SMTP_USER / SMTP_PASS come from .env.local. - // secure is derived from port: port 465 → true (raw SSL), all others → false. logger.info( { provider: "ethereal", @@ -136,8 +50,7 @@ const createEmailTransport = () => { return nodemailer.createTransport({ host: config.SMTP_HOST, port: config.SMTP_PORT, - // Ethereal uses port 587 with STARTTLS, so secure is false. - // Only set true if you explicitly use port 465 for raw SSL. + secure: config.SMTP_PORT === 465, auth: { user: config.SMTP_USER, @@ -146,39 +59,19 @@ const createEmailTransport = () => { }); }; -// ─── Singleton transport ────────────────────────────────────────────────────── -// -// Created once when this module is first imported. Re-creating it per call -// would open a new TCP connection on every OTP send, which is wasteful and slow. const transport = createEmailTransport(); logger.info({ provider: activeEmailProvider }, `Email provider selected at startup: ${activeEmailProvider}`); -// ─── Sender address resolver ────────────────────────────────────────────────── -// -// Returns the correct "From" address for the active provider. The Nodemailer -// `from` field accepts RFC 5322 format: "Display Name ". -// Using a display name makes the email look professional in the recipient's inbox -// ("Roomies" instead of a raw SMTP address). const getSenderAddress = () => { if (config.EMAIL_PROVIDER === "brevo") { - // BREVO_SMTP_FROM must be verified in your Brevo account's Senders section. - // Brevo rejects messages from unverified sender addresses with a 550 error. return `"Roomies" <${config.BREVO_SMTP_FROM}>`; } - // Ethereal: SMTP_FROM is any address — Ethereal intercepts everything. + return `"Roomies" <${config.SMTP_FROM}>`; }; -// ─── Send OTP email ─────────────────────────────────────────────────────────── -// -// Sends a 6-digit OTP to the given email address. The email is formatted as a -// professional transactional message with both plain-text and HTML versions. -// Nodemailer uses the HTML version for capable clients and falls back to plain -// text for clients that cannot render HTML. export const sendOtpEmail = async (to, otp) => { - // Input validation — the auth service validates before calling this, but - // guard here so this function is safe to call from any future context. if (!to || typeof to !== "string" || !to.includes("@")) { throw new AppError("Invalid recipient email address", 400); } @@ -193,18 +86,22 @@ export const sendOtpEmail = async (to, otp) => { const fromAddress = getSenderAddress(); logger.info({ to: maskedTo, provider: activeEmailProvider }, "Preparing OTP email with configured provider"); - + if (config.EMAIL_PROVIDER === "brevo-api") { + return sendViaBrevoAPI( + to, + "Your Roomies verification code", + `

Roomies

Find your perfect PG or roommate

Verify your email address

Use the code below to complete your verification. It expires in 10 minutes.

Your verification code

${otp}

Never share this code with anyone — Roomies will never ask for it.

This is an automated message from Roomies. Please do not reply.

`, + `Your Roomies verification code is: ${otp}\n\nThis code expires in 10 minutes. Do not share it with anyone.\n\nIf you did not request this code, you can safely ignore this email.`, + ); + } try { const info = await transport.sendMail({ from: fromAddress, to, subject: "Your Roomies verification code", - // Plain-text version: used by email clients that cannot render HTML and - // by screen readers. Always include this — it also improves spam scoring. + text: `Your Roomies verification code is: ${otp}\n\nThis code expires in 10 minutes. Do not share it with anyone.\n\nIf you did not request this code, you can safely ignore this email.`, - // HTML version: rendered by virtually all modern email clients. - // Uses inline styles for maximum compatibility — many email clients - // (including Gmail) strip