From 712ea4b5293afd09dacaf45508be834093baacd4 Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Mon, 20 Apr 2026 12:09:39 +0530 Subject: [PATCH 01/54] chore: update .gitignore to include .env.render and remove .env.test --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 5f04ba9..af5cb8b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,11 +2,12 @@ node_modules/ # Environment files — never commit any .env variant +# Environment files — never commit .env .env.local .env.azure +.env.render .env.*.local -.env.test # Local file uploads — stored on disk in dev, Azure Blob in prod uploads/ From 8e4eee2e34e6eb579d4b394597a24a220009fbf7 Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Mon, 20 Apr 2026 12:09:45 +0530 Subject: [PATCH 02/54] feat: add support for brevo-api email provider and enhance environment validation --- src/config/env.js | 30 ++++++++++++++++++++++++++++-- src/services/email.service.js | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/config/env.js b/src/config/env.js index 0839e2c..aaaab90 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 provides smtp on free tier , so we are using brevo http api key as 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/services/email.service.js b/src/services/email.service.js index 984545e..1b00870 100644 --- a/src/services/email.service.js +++ b/src/services/email.service.js @@ -679,3 +679,36 @@ export const sendVerificationPendingEmail = async (to, ownerName, businessName) throw new AppError("Failed to send verification pending email — try again shortly", 502); } }; + +// Brevo REST API transport — used when EMAIL_PROVIDER=brevo-api. +// Uses HTTPS (port 443), which is never blocked, unlike SMTP ports. +// Brevo API docs: https://developers.brevo.com/reference/send-transac-email +const sendViaBrevoAPI = async (to, subject, html, text) => { + const maskedTo = maskEmail(to); + logger.info({ to: maskedTo, provider: "brevo-api" }, "Sending email via Brevo REST API"); + + const response = await fetch("https://api.brevo.com/v3/smtp/email", { + method: "POST", + headers: { + "Content-Type": "application/json", + "api-key": config.BREVO_API_KEY, + }, + body: JSON.stringify({ + sender: { name: "Roomies", email: config.BREVO_SMTP_FROM }, + to: [{ email: to }], + subject, + htmlContent: html, + textContent: text, + }), + }); + + if (!response.ok) { + const errorBody = await response.json().catch(() => ({})); + logger.error({ to: maskedTo, status: response.status, error: errorBody }, "Brevo API: email send failed"); + throw new AppError("Failed to send email via Brevo API — try again shortly", 502); + } + + const result = await response.json(); + logger.info({ to: maskedTo, messageId: result.messageId }, "Brevo API: email sent successfully"); + return result.messageId; +}; From 2e761d57d752c065ab239ad9ada1cd518f588121 Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Mon, 20 Apr 2026 13:43:35 +0530 Subject: [PATCH 03/54] feat: implement Brevo API email provider for OTP and verification emails --- .gitignore | 2 +- package.json | 6 +----- src/services/email.service.js | 36 ++++++++++++++++++++++++++++++++++- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index af5cb8b..17f978e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ node_modules/ .env.azure .env.render .env.*.local - +env.render # Local file uploads — stored on disk in dev, Azure Blob in prod uploads/ 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/services/email.service.js b/src/services/email.service.js index 1b00870..33ce3c1 100644 --- a/src/services/email.service.js +++ b/src/services/email.service.js @@ -193,7 +193,14 @@ 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, @@ -329,6 +336,15 @@ export const sendVerificationApprovedEmail = async (to, ownerName, businessName) logger.info({ to: maskedTo, provider: activeEmailProvider }, "Preparing verification approved email"); + if (config.EMAIL_PROVIDER === "brevo-api") { + return sendViaBrevoAPI( + to, + "Your Roomies PG owner account has been verified", + `

Roomies

Account verified!

Hi ${ownerName}, your PG owner account for ${businessName ?? "your business"} has been reviewed and approved.

You can now create properties and post listings.

Go to Dashboard
`, + `Hi ${ownerName},\n\nGreat news! Your PG owner account for ${businessName ?? "your business"} has been verified.\n\nYou can now create properties and post listings.\n\n— The Roomies Team`, + ); + } + try { const info = await transport.sendMail({ from: fromAddress, @@ -447,6 +463,15 @@ export const sendVerificationRejectedEmail = async (to, ownerName, rejectionReas logger.info({ to: maskedTo, provider: activeEmailProvider }, "Preparing verification rejected email"); + if (config.EMAIL_PROVIDER === "brevo-api") { + return sendViaBrevoAPI( + to, + "Update on your Roomies verification request", + `

Roomies

Verification update

Hi ${ownerName}, we were unable to approve your verification request.

Reason

${reasonText}

You can submit a new verification request with updated documents from your account settings.

Resubmit Documents
`, + `Hi ${ownerName},\n\nWe were unable to approve your verification request.\n\nReason: ${reasonText}\n\nYou can resubmit with updated documents.\n\n— The Roomies Team`, + ); + } + try { const info = await transport.sendMail({ from: fromAddress, @@ -578,6 +603,15 @@ export const sendVerificationPendingEmail = async (to, ownerName, businessName) logger.info({ to: maskedTo, provider: activeEmailProvider }, "Preparing verification pending email"); + if (config.EMAIL_PROVIDER === "brevo-api") { + return sendViaBrevoAPI( + to, + "We received your Roomies verification documents", + `

Roomies

📋

Documents received

Hi ${ownerName}, we've received your verification documents for ${displayBusiness}.

Status

Under review

Our team will review your submission within 2–3 business days.

`, + `Hi ${ownerName},\n\nWe've received your verification documents for ${displayBusiness}.\n\nOur team will review within 2–3 business days.\n\n— The Roomies Team`, + ); + } + try { const info = await transport.sendMail({ from: fromAddress, From a455c74a7cb6b220045e1eb054a85802c0c3142e Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Mon, 20 Apr 2026 15:33:37 +0530 Subject: [PATCH 04/54] feat: add CI workflow for tier0 branch with Node.js setup and dependency installation --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7937245 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + branches: [tier0] + pull_request: + branches: [tier0] + +jobs: + check: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js 22 + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies + run: npm ci From 06b0ae1aa0cf49ba458920214fc7fd81fbdc2adc Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Mon, 20 Apr 2026 15:43:42 +0530 Subject: [PATCH 05/54] feat: add registration and OTP verification tests for students and PG owners in E2E test suite --- ...ll E2E Test Suite.postman_collection.json" | 541 +++++++++++++++++- 1 file changed, 540 insertions(+), 1 deletion(-) 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" index a18da2f..885e8d1 100644 --- "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" @@ -656,6 +656,531 @@ } }, "response": [] + }, + { + "name": "[1.14] Register Student 3", + "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\");", + "});", + "pm.test(\"Email is NOT auto-verified (gmail domain)\", () => {", + " const body = pm.response.json();", + " pm.expect(body.data.user.isEmailVerified).to.eql(false);", + "});", + "", + "const body = pm.response.json();", + "pm.environment.set(\"student3AccessToken\", body.data.accessToken);", + "pm.environment.set(\"student3RefreshToken\", body.data.refreshToken);", + "pm.environment.set(\"student3Id\", body.data.user.userId);", + "", + "console.log(\"student3Id:\", body.data.user.userId);", + "console.log(\"student3 isEmailVerified:\", body.data.user.isEmailVerified, \"— OTP flow will be needed\");" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n\t\"email\": \"rohit.verma@gmail.com\",\n\t\"password\": \"Test1234\",\n\t\"role\": \"student\",\n\t\"fullName\": \"Rohit Verma\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/auth/register", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "auth", + "register" + ] + } + }, + "response": [] + }, + { + "name": "[1.15] Register Student 4", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status 201\", () => pm.response.to.have.status(201));", + "pm.test(\"Returns access token\", () => {", + "\tconst body = pm.response.json();", + "\tpm.expect(body.data.accessToken).to.be.a(\"string\");", + "});", + "pm.test(\"Email is NOT auto-verified (gmail domain)\", () => {", + "\tconst body = pm.response.json();", + "\tpm.expect(body.data.user.isEmailVerified).to.eql(false);", + "});", + "", + "const body = pm.response.json();", + "pm.environment.set(\"student4AccessToken\", body.data.accessToken);", + "pm.environment.set(\"student4RefreshToken\", body.data.refreshToken);", + "pm.environment.set(\"student4Id\", body.data.user.userId);", + "", + "console.log(\"student4Id:\", body.data.user.userId);", + "console.log(\"student4 isEmailVerified:\", body.data.user.isEmailVerified, \"— OTP flow will be needed\");" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n\t\"email\": \"sumity1642@gmail.com\",\n\t\"password\": \"Test1234\",\n\t\"role\": \"student\",\n\t\"fullName\": \"Sumit Yadav\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/auth/register", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "auth", + "register" + ] + } + }, + "response": [] + }, + { + "name": "[1.16] Register Pg Owner 4", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status 201\", () => pm.response.to.have.status(201));", + "pm.test(\"Business name in response\", () => {", + "\t// Registration response returns user object, not profile — no business_name here.", + "\t// We confirm the pg_owner role is present instead.", + "\tpm.expect(body.data.user.roles).to.include(\"pg_owner\");", + "});", + "", + "const body = pm.response.json();", + "pm.environment.set(\"pgOwner4AccessToken\", body.data.accessToken);", + "pm.environment.set(\"pgOwner4RefreshToken\", body.data.refreshToken);", + "pm.environment.set(\"pgOwner4Id\", body.data.user.userId);", + "", + "console.log(\"pgOwner4Id:\", body.data.user.userId);" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n\t\"email\": \"gameking43221@gmail.com\",\n\t\"password\": \"Test1234\",\n\t\"role\": \"pg_owner\",\n\t\"fullName\": \"Game King\",\n\t\"businessName\": \"Game King Residency\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/auth/register", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "auth", + "register" + ] + } + }, + "response": [] + }, + { + "name": "[1.17] Login Student 4", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", + "", + "const body = pm.response.json();", + "pm.environment.set(\"student4AccessToken\", body.data.accessToken);", + "pm.environment.set(\"student4RefreshToken\", body.data.refreshToken);", + "", + "console.log(\"Student 4 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\": \"sumity1642@gmail.com\",\n\t\"password\": \"Test1234\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/auth/login", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "auth", + "login" + ] + } + }, + "response": [] + }, + { + "name": "[1.18] Send OTP — Student 4 (Brevo live test)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", + "pm.test(\"OTP sent message present\", () => {", + " pm.expect(pm.response.json().message).to.include(\"OTP\");", + "});", + "", + "console.log(\"=== BREVO TEST ===\");", + "console.log(\"If you used your own email: check your inbox for the OTP.\");", + "console.log(\"If you used rohit.verma@gmail.com: open Brevo dashboard → Transactional → Logs.\");", + "console.log(\"A successful send event confirms Brevo is wired correctly.\");", + "console.log(\"messageId should appear in your server terminal log at info level.\");" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{student4AccessToken}}", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/auth/otp/send", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "auth", + "otp", + "send" + ] + } + }, + "response": [] + }, + { + "name": "[1.19] Verify OTP Student 4", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", + "pm.test(\"Email verified message\", () => {", + "\tpm.expect(pm.response.json().message).to.include(\"verified\");", + "});", + "", + "console.log(\"Student 3 email is now verified — Brevo round-trip confirmed end-to-end.\");" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{student4RefreshToken}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"otp\": \"749828\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/auth/otp/verify", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "auth", + "otp", + "verify" + ] + } + }, + "response": [] + }, + { + "name": "[1.20] Login Student 3", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", + "", + "const body = pm.response.json();", + "pm.environment.set(\"student3AccessToken\", body.data.accessToken);", + "pm.environment.set(\"student3RefreshToken\", body.data.refreshToken);", + "", + "console.log(\"Student 3 token refreshed via login\");" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n\t\"email\": \"rohit.verma@gmail.com\",\n\t\"password\": \"Test1234\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/auth/login", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "auth", + "login" + ] + } + }, + "response": [] + }, + { + "name": "[1.21] Send OTP Student 3", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", + "pm.test(\"OTP sent message present\", () => {", + " pm.expect(pm.response.json().message).to.include(\"OTP\");", + "});", + "", + "console.log(\"=== BREVO TEST ===\");", + "console.log(\"If you used your own email: check your inbox for the OTP.\");", + "console.log(\"If you used rohit.verma@gmail.com: open Brevo dashboard → Transactional → Logs.\");", + "console.log(\"A successful send event confirms Brevo is wired correctly.\");", + "console.log(\"messageId should appear in your server terminal log at info level.\");" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{student3AccessToken}}", + "type": "text" + } + ], + "body": { + "mode": "formdata", + "formdata": [] + }, + "url": { + "raw": "{{baseUrl}}/auth/otp/send", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "auth", + "otp", + "send" + ] + } + }, + "response": [] + }, + { + "name": "[1.22] Verify Student 3 OTP", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", + "pm.test(\"Email verified message\", () => {", + "\tpm.expect(pm.response.json().message).to.include(\"verified\");", + "});", + "", + "console.log(\"Student 4 email is now verified — Brevo round-trip confirmed end-to-end.\");" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{student3AccessToken}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n\t\"otp\": \"427531\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/auth/otp/verify", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "auth", + "otp", + "verify" + ] + } + }, + "response": [] } ] }, @@ -1832,7 +2357,21 @@ "});", "pm.test(\"Business phone hidden for non-owner\", () => {", " pm.expect(pm.response.json().data.business_phone).to.be.null;", - "});" + "});", + "", + "// Visualization Script", + "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": {}, From 7940d27e959fe74c6e93a7f56e2466a30994a9d5 Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Mon, 20 Apr 2026 15:45:05 +0530 Subject: [PATCH 06/54] feat: update Node.js version to 24.15.0 in CI workflow --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7937245..e0883e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,10 +14,10 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Setup Node.js 22 + - name: Setup Node.js 24.15.0 uses: actions/setup-node@v4 with: - node-version: "22" + node-version: "24.15.0" cache: "npm" - name: Install dependencies From e1a6f2d901cf34555416d779ae7dfcfaee5678d5 Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Mon, 20 Apr 2026 19:34:23 +0530 Subject: [PATCH 07/54] Refactor code structure for improved readability and maintainability --- prompt1.md | 399 ++++++++++++++++++++++++++ prompt2.md | 806 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1205 insertions(+) create mode 100644 prompt1.md create mode 100644 prompt2.md diff --git a/prompt1.md b/prompt1.md new file mode 100644 index 0000000..d2d1600 --- /dev/null +++ b/prompt1.md @@ -0,0 +1,399 @@ +# Roomies Backend — Documentation Audit & Update Prompt + +## How to Use This Prompt + +This is a **chained instruction set**. The work is broken into focused phases, each of which references a specific +section of the codebase and the existing documentation. Complete the phases in order. Each phase produces output files +that are inputs for the next. + +The existing documentation lives under `docs/`. The authoritative source of truth for contracts is always the code: +`src/routes/`, `src/validators/`, `src/services/`, `src/middleware/`. + +--- + +## Phase 0 — Orientation (Read Before Doing Anything) + +Before writing a single line of documentation, internalise these rules. They govern every decision in every phase that +follows. + +**Rule 1 — Scenario-based JSON format is the canonical style.** Every endpoint must be documented as a series of named +scenarios. Each scenario has a title, a request contract (method, path, auth, headers, body), and a response block +showing the exact JSON. No prose descriptions of what a field "might" contain — show the actual shape. See the existing +`docs/api/auth.md` for the reference style. + +**Rule 2 — Chained files, not a monolith.** Documentation is split across files linked by hyperlinks. `docs/API.md` is +the front door. Feature docs live in `docs/api/`. No feature doc should grow beyond its own feature boundary. If a new +feature was added, create a new file and link it from `docs/API.md` and from `docs/README.md`. + +**Rule 3 — Code is truth, docs are its reflection.** If the service throws `new AppError("message", 404)`, the doc shows +a `404` response with `{ "status": "error", "message": "message" }`. Never infer or paraphrase the error body — copy it +exactly as the service constructs it. + +**Rule 4 — Status codes come from both routes and services.** Check the controller for the success status code +(`res.status(201)` vs `res.json()`), and check the service for every `AppError`. Also check +`src/middleware/errorHandler.js` for the PostgreSQL constraint codes (23505 → 409, 23503 → 409, 23514 → 400) that +produce implicit error responses. + +**Rule 5 — Document gates as first-class responses.** When a route has middleware that can short-circuit (e.g. +`contactRevealGate`, `authorize`, `guestListingGate`), those middleware responses are endpoint outcomes and must appear +as scenarios. Do not hide them in a footnote. + +--- + +## Phase 1 — Audit the Existing Feature Docs Against Current Code + +For each file listed below, open the doc and the corresponding source files side by side. For every discrepancy you +find, note it and fix it in the doc. The checklist below tells you exactly which source files to cross-reference. + +### 1.1 — `docs/api/auth.md` + +Cross-reference against: + +- `src/routes/auth.js` +- `src/validators/auth.validators.js` +- `src/services/auth.service.js` +- `src/controllers/auth.controller.js` + +Things to verify: + +- The `POST /auth/logout` route does **not** require `authenticate` middleware but `POST /auth/logout/current` does. + Confirm both are accurately described. +- The `DELETE /auth/sessions/:sid` route: confirm the scenario where revoking the **current** session also clears auth + cookies is documented. +- The `parseTtlSeconds` function in `auth.service.js` accepts numeric TTL values in addition to string formats — confirm + the JWT expiry behaviour description is still accurate. +- The `verifyRefreshTokenPayload` function has a legacy token migration path. The doc should note that legacy tokens + (issued before per-session keys) are transparently migrated on first use — callers never need to handle this. +- `POST /auth/google/callback`: three internal paths (returning user, account linking, new registration) — all three + scenarios must be present with their exact response shapes. + +### 1.2 — `docs/api/profiles-and-contact.md` + +Cross-reference against: + +- `src/routes/student.js` +- `src/routes/pgOwner.js` +- `src/validators/student.validators.js` +- `src/validators/pgOwner.validators.js` +- `src/services/student.service.js` +- `src/services/pgOwner.service.js` +- `src/middleware/contactRevealGate.js` + +Things to verify: + +- The student contact reveal route is `GET` but the PG owner contact reveal route is `POST`. Confirm this asymmetry is + documented with its rationale (POST prevents browser prefetch/cache of PII). +- Both reveal endpoints set `Cache-Control: no-store` before the gate runs. This header appears on **all** responses + from those routes — including 429 limit-reached responses. Confirm this is reflected in the scenarios. +- The gate's two-tier model: verified users get unlimited + full bundle; guests/unverified get 10 reveals + email-only. + The `contactRevealGate.js` middleware now uses a **pre-response hook** (wrapping `res.json`, `res.send`, `res.end`) to + charge quota after a successful 2xx response rather than using `res.on("finish")`. The doc doesn't need to describe + the implementation, but it must correctly state that quota is charged only on successful reveals. +- `GET /students/:userId/preferences` and `PUT /students/:userId/preferences` are mounted in `src/routes/student.js` — + confirm they are documented (they appear in `profiles-and-contact.md`). + +### 1.3 — `docs/api/listings.api.md` + +Cross-reference against: + +- `src/routes/listing.js` +- `src/validators/listing.validators.js` +- `src/services/listing.service.js` +- `src/middleware/guestListingGate.js` +- `src/services/listingLifecycle.js` + +Things to verify: + +- Guest access: `GET /listings` and `GET /listings/:listingId` are the only two endpoints that accept unauthenticated + requests. Confirm the doc clearly states this and shows a guest scenario for both. +- `compatibilityAvailable` field: the search response now includes both `compatibilityScore` (integer) and + `compatibilityAvailable` (boolean). The existing doc only shows `compatibilityScore`. Add `compatibilityAvailable` to + all search result item shapes. +- The `guestListingGate` middleware caps the `limit` query param to 20 for guests silently. Document this as part of the + guest access table. +- `EXPIRED_LISTING_MESSAGE` and `UNAVAILABLE_LISTING_MESSAGE` are constants in `listingLifecycle.js`. The exact strings + are `"Listing has expired and is no longer available"` and `"Listing is no longer available"`. Confirm the error + scenarios in the doc show these exact strings, not paraphrases. +- For `PUT /listings/:listingId`: the `422` scenario for updating location fields on a `pg_room` or `hostel_bed` listing + should show the actual message format: + `"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."` + (with the actual field names interpolated). +- The `PATCH /listings/:listingId/status` response does not include `rentPerMonth` or other listing fields — it only + returns `{ listingId, status }`. Confirm the doc shows the correct minimal shape. + +### 1.4 — `docs/api/interests.md` + +Cross-reference against: + +- `src/routes/interest.js` +- `src/routes/listing.js` (for the listing-scoped interest routes) +- `src/validators/interest.validators.js` +- `src/services/interest.service.js` + +Things to verify: + +- The `POST /listings/:listingId/interests` route requires `authorize("student")`. Confirm the doc correctly states this + is student-only. +- The accept transition response includes `whatsappLink` (can be `null` if the poster has no phone) and `listingFilled` + (boolean). Confirm both fields are in the scenario response. +- The `GET /listings/:listingId/interests` route returns `403` when the listing exists but the caller doesn't own it, + and `404` when the listing doesn't exist. These are two distinct error scenarios that must both be documented. +- The `createInterestRequest` service uses `ON CONFLICT ... DO NOTHING` on the partial unique index + `(sender_id, listing_id) WHERE status IN ('pending','accepted')`. This means re-sending an interest request after a + previous one was declined or withdrawn succeeds (it creates a new row). The doc should clarify this behaviour. + +### 1.5 — `docs/api/connections.md` + +Cross-reference against: + +- `src/routes/connection.js` +- `src/validators/connection.validators.js` +- `src/services/connection.service.js` + +Things to verify: + +- The `confirmConnection` service uses a single atomic `UPDATE` with a `CASE WHEN` block to flip the caller's flag and + promote `confirmation_status` to `'confirmed'` when both flags are true. The doc should reflect that this is a single + atomic operation, not two steps. +- `GET /connections/me` supports `confirmationStatus` and `connectionType` query filters. Confirm both are documented + with their valid enum values. + +### 1.6 — `docs/api/ratings-and-reports.md` + +Cross-reference against: + +- `src/routes/rating.js` +- `src/validators/rating.validators.js` +- `src/validators/report.validators.js` +- `src/services/rating.service.js` +- `src/services/report.service.js` + +Things to verify: + +- `GET /ratings/user/:userId` and `GET /ratings/property/:propertyId` are public (no auth). Confirm the doc does not + mention an auth requirement for these two endpoints. +- The `submitRating` zero-rowCount disambiguation: the service checks for three distinct failure cases (connection not + found/not party → 404, connection unconfirmed → 422, duplicate rating → 409). All three should be distinct scenarios. +- The `resolveReport` endpoint's `adminNotes` field: it is required when `resolution = "resolved_removed"` and optional + otherwise. This cross-field constraint must be visible in the doc. +- The `submitReport` service uses an atomic `INSERT ... SELECT ... FROM ratings JOIN connections` to enforce party + membership. The 404 message is `"Rating not found or you are not a party to this connection"`. Confirm this exact + string is shown. + +### 1.7 — `docs/api/notifications.md` + +Cross-reference against: + +- `src/routes/notification.js` +- `src/validators/notification.validators.js` +- `src/services/notification.service.js` +- `src/workers/notificationWorker.js` + +Things to verify: + +- The `POST /notifications/mark-read` validator uses `z.literal(true)` for the `all` field, meaning `{ all: false }` is + a validation error, not a no-op. The doc should show the validation error scenario for `all: false` (it currently only + shows the scenario for providing both modes simultaneously). +- The `NOTIFICATION_MESSAGES` map in `notificationWorker.js` is the source of truth for message text. Verify that the + notification type table in the doc matches the map exactly — including `listing_expired` (distinct from + `listing_expiring`), `listing_filled`, and `connection_requested` (currently PLANNED, no emitter yet). + +### 1.8 — `docs/api/health.md` + +Cross-reference against: + +- `src/routes/health.js` + +This file is likely accurate. Quick check: confirm the `503` degraded response shape uses `"timeout"` as a service +status string (not `"timed_out"` or any other variant) — sourced from `isTimeoutError()` in `health.js`. + +--- + +## Phase 2 — Document New or Undocumented Features + +The following features are either missing from the docs entirely or only partially covered. Create new sections or new +files as needed. + +### 2.1 — Admin Feature Doc (`docs/api/admin.md`) + +This file is referenced in `docs/API.md` and `docs/README.md` but check whether it contains full scenario-based +documentation for all admin endpoints. The admin surface is: + +**Verification queue** (in `src/routes/admin.js`, served by `src/services/verification.service.js`): + +- `GET /admin/verification-queue` — paginated, oldest-first +- `POST /admin/verification-queue/:requestId/approve` +- `POST /admin/verification-queue/:requestId/reject` + +**Report queue** (in `src/routes/admin.js`, served by `src/services/report.service.js`): + +- `GET /admin/report-queue` — paginated, oldest-first +- `PATCH /admin/reports/:reportId/resolve` + +All five endpoints share the same auth requirement: `authenticate` + `authorize('admin')` enforced at the router level. +Every request to any route under `/admin` that lacks a valid admin JWT produces a `403` before the route handler runs. + +For each endpoint, document every scenario the service can produce, including the concurrent-state `409` (e.g. approving +an already-resolved request), the `404` party check where applicable, and the cross-field validation error on +`resolveReport` (adminNotes required for `resolved_removed`). + +### 2.2 — Preferences Feature Doc (`docs/api/preferences.md`) + +This file exists but verify it is complete. The full preferences surface is: + +- `GET /preferences/meta` — returns the PREFERENCE_DEFINITIONS catalog (served by `src/services/preferences.service.js`) +- `GET /students/:userId/preferences` — owner-only, returns the student's current preference profile +- `PUT /students/:userId/preferences` — owner-only, full replace (empty array clears all) + +The `dedupePreferencesByKey` function in `src/config/preferences.js` applies last-write-wins when duplicate keys are +submitted. Document this as a named scenario: "Request with duplicate preference keys — last value wins, no error +returned." + +The allowed preference keys and values come from `PREFERENCE_DEFINITIONS` in `src/config/preferences.js`. The doc should +include the full current catalog so integrators don't need to call `/preferences/meta` just to know valid values. + +### 2.3 — Cron Job Behaviour in `docs/API.md` + +The "Background Jobs That Affect API Behavior" section in `docs/API.md` lists three cron jobs. Verify the descriptions +match the actual schedules and behaviour in the cron files: + +- `src/cron/listingExpiry.js` — runs at `0 2 * * *` (02:00 daily), transitions `active` listings to `expired` where + `expires_at < NOW()`, then bulk-expires pending interest requests. Overridable via `CRON_LISTING_EXPIRY` env var. +- `src/cron/expiryWarning.js` — runs at `0 1 * * *` (01:00 daily), enqueues `listing_expiring` notifications for + listings expiring within 7 days. Uses an idempotency key of format `expiry_warning:{listing_id}:{YYYY-MM-DD}` to + prevent duplicate warnings per calendar day. Overridable via `CRON_EXPIRY_WARNING`. +- `src/cron/hardDeleteCleanup.js` — runs at `0 4 * * 0` (04:00 every Sunday), hard-deletes soft-deleted rows older than + `SOFT_DELETE_RETENTION_DAYS` (default 90 days). Overridable via `CRON_HARD_DELETE`. + +The `SOFT_DELETE_RETENTION_DAYS` variable has a strict format requirement: plain decimal integer only (e.g. `"90"`, not +`"90days"` or `"-30"`). Document this in `docs/API.md` under the background jobs section. + +--- + +## Phase 3 — Update `docs/TechStack.md` for Recent Changes + +Cross-reference against `src/config/env.js` and `src/workers/emailWorker.js`. + +The `EMAIL_PROVIDER` enum now includes `"brevo-api"` as a third valid value alongside `"ethereal"` and `"brevo"`. The +TechStack doc's email section should describe all three options. + +The email delivery pipeline has moved from direct `sendMail()` calls in `auth.service.js` to a BullMQ queue +(`email-delivery`) processed by `src/workers/emailWorker.js`. Update the BullMQ queues table in `docs/TechStack.md` to +add: + +| Queue name | Worker file | Concurrency | Used for | +| ---------------- | ---------------- | ------------- | ------------------------------------------------------------- | +| `email-delivery` | `emailWorker.js` | 3 (I/O-bound) | OTP emails, verification status emails via CDC outbox drainer | + +Also document the new CDC outbox worker (`src/workers/verificationEventWorker.js`) which is not a BullMQ worker but a +`setInterval`-based polling loop. It reads from `verification_event_outbox` (written by the +`trg_verification_status_changed` Postgres trigger), processes events, and enqueues both in-app notifications and +emails. This should be explained in its own subsection under the Workers section. + +--- + +## Phase 4 — Update `docs/ImplementationPlan.md` + +This file tracks phase and branch status. The following updates are needed: + +**Phase 5 status correction:** `phase5/cron` is marked ✅ MERGED. Verify `phase5/admin` is still ⏳ NOT STARTED and that +the "What needs building" list accurately reflects what the `emailWorker.js` already provides (the email worker is done +— remove it from the "needs building" list) versus what is genuinely still missing (admin user management endpoints, +analytics endpoint, admin rating visibility endpoints). + +**Source file inventory additions:** The following files exist in the codebase and should be added to the inventory if +they are not already listed: + +- `src/workers/emailWorker.js` +- `src/workers/emailQueue.js` +- `src/workers/verificationEventWorker.js` +- `src/middleware/guestListingGate.js` + +**DB trigger reference table:** The `trg_verification_status_changed` trigger (introduced in migration 002) should be +added to the trigger table with its table, firing condition, and action described. + +--- + +## Phase 5 — Update `docs/Deployment.md` and Deployment Tier Docs + +The deployment docs are spread across `docs/Deployment.md` (the older combined guide) and the newer +`docs/deployment/tier0.md`, `docs/deployment/tier1.md`, `docs/deployment/tier2.md`. + +**Priority check for `docs/deployment/tier0.md`:** This is the most operationally relevant guide. Verify that it +accurately reflects the `brevo-api` email provider requirement (SMTP is blocked on Render free tier — this is already +addressed in Phase 1 of the tier0 guide with the code changes). Confirm that the env var `EMAIL_PROVIDER=brevo-api` is +correctly used throughout the setup instructions and that `BREVO_API_KEY` (not `BREVO_SMTP_KEY`) is what this mode +requires. + +**`TRUST_PROXY` in tier0 guide:** The Render deployment must set `TRUST_PROXY=1`. Verify the Render environment variable +table in Phase 7.2 of `docs/deployment/tier0.md` includes this row. + +**`NODE_ENV` in `.env.render`:** The current `.env.render` file has `NODE_ENV=development`. The tier0 guide Phase 7.2 +table correctly lists `NODE_ENV=production`. Add a callout in the guide noting that the `.env.render` file is for local +testing against live Tier 0 services and therefore uses `development`, whereas the actual Render dashboard environment +variable should be `production`. This distinction must be explicit to avoid confusion. + +--- + +## Phase 6 — Final Consistency Pass + +After completing all phases above, do a final sweep: + +**Check all hyperlinks.** Every cross-reference between doc files (e.g. `docs/API.md` → `docs/api/auth.md`) should +resolve correctly. If you created a new file, make sure it is linked from `docs/README.md` and from `docs/API.md`. + +**Check example UUIDs.** The conventions file (`docs/api/conventions.md`) defines canonical example UUIDs (student = +`11111111-...`, PG owner = `22222222-...`, etc.). Every scenario in every feature doc should use these consistent +example values, not random UUIDs. + +**Check the HTTP status code table.** `docs/API.md` section 20 has a table of status codes. Verify that `202` (photo +upload async) and `503` (service unavailable, including the queue-unavailable response from `photo.service.js`) are +present and described accurately. + +**Check the pagination section.** `docs/API.md` section 19 documents keyset pagination. Verify it mentions the +constraint that `cursorTime` and `cursorId` must always be provided together, and that providing one without the other +returns a `400`. + +--- + +## Deliverables Checklist + +When all phases are complete, the following should be true: + +- [ ] Every endpoint in `src/routes/` has at least one success scenario and all failure scenarios documented in a + `docs/api/` file. +- [ ] Every file in `docs/api/` has been reviewed against current source code and any stale scenarios corrected. +- [ ] `docs/README.md` links to every file in `docs/api/`. +- [ ] `docs/API.md` feature docs list is complete and all links resolve. +- [ ] `docs/TechStack.md` BullMQ queue table includes all three current workers. +- [ ] `docs/ImplementationPlan.md` source file inventory includes all current workers and middleware. +- [ ] `docs/deployment/tier0.md` correctly documents `EMAIL_PROVIDER=brevo-api`, `TRUST_PROXY=1`, and the `NODE_ENV` + distinction between the file and the Render dashboard. +- [ ] All cross-file hyperlinks resolve correctly. +- [ ] All scenario response bodies use the exact strings from the source code, not paraphrases. + +--- + +## Reference: Quick-Lookup Source Files + +Use this table when auditing a specific feature to know exactly which files to open: + +| Feature | Routes | Validators | Service | Controller | +| ------------------------ | -------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------- | +| Auth | `routes/auth.js` | `validators/auth.validators.js` | `services/auth.service.js` | `controllers/auth.controller.js` | +| Student profiles & prefs | `routes/student.js` | `validators/student.validators.js` | `services/student.service.js` | `controllers/student.controller.js` | +| PG owner profiles | `routes/pgOwner.js` | `validators/pgOwner.validators.js` | `services/pgOwner.service.js` | `controllers/pgOwner.controller.js` | +| Properties | `routes/property.js` | `validators/property.validators.js` | `services/property.service.js` | `controllers/property.controller.js` | +| Listings | `routes/listing.js` | `validators/listing.validators.js` | `services/listing.service.js` | `controllers/listing.controller.js` | +| Photos | `routes/listing.js` | `validators/photo.validators.js` | `services/photo.service.js` | `controllers/photo.controller.js` | +| Interests | `routes/interest.js`, `routes/listing.js` | `validators/interest.validators.js` | `services/interest.service.js` | `controllers/interest.controller.js` | +| Connections | `routes/connection.js` | `validators/connection.validators.js` | `services/connection.service.js` | `controllers/connection.controller.js` | +| Notifications | `routes/notification.js` | `validators/notification.validators.js` | `services/notification.service.js` | `controllers/notification.controller.js` | +| Ratings | `routes/rating.js` | `validators/rating.validators.js` | `services/rating.service.js` | `controllers/rating.controller.js` | +| Reports | `routes/rating.js` | `validators/report.validators.js` | `services/report.service.js` | `controllers/report.controller.js` | +| Verification (admin) | `routes/admin.js` | `validators/verification.validators.js` | `services/verification.service.js` | `controllers/verification.controller.js` | +| Preferences | `routes/preferences.js`, `routes/student.js` | `validators/preferences.validators.js` | `services/preferences.service.js` | `controllers/preferences.controller.js` | +| Contact reveal (gate) | `routes/student.js`, `routes/pgOwner.js` | — | `services/student.service.js`, `services/pgOwner.service.js` | `middleware/contactRevealGate.js` | +| Health | `routes/health.js` | — | — | — | +| Error shapes | — | — | — | `middleware/errorHandler.js` | +| Cron behaviour | — | — | `cron/listingExpiry.js`, `cron/expiryWarning.js`, `cron/hardDeleteCleanup.js` | — | diff --git a/prompt2.md b/prompt2.md new file mode 100644 index 0000000..ebb90c2 --- /dev/null +++ b/prompt2.md @@ -0,0 +1,806 @@ +# Roomies Backend — Documentation Audit & Comprehensive Update Prompt + +## Deployment Reality (Read This First — It Overrides All Doc References to Azure) + +The live production API is hosted on **Render**, not Azure. Every doc section that references Azure App Service as the +hosting layer is describing a _planned future migration_, not the current live environment. The real service topology as +of today is: + +| Component | Actual service | +| ------------- | ----------------------------------------------------------------------- | +| API host | Render free tier — `https://roomies-api.onrender.com` | +| API base URL | `https://roomies-api.onrender.com/api/v1` | +| Health check | `https://roomies-api.onrender.com/api/v1/health` | +| Database | Neon PostgreSQL 16 + PostGIS (pooler endpoint, `ap-southeast-1`) | +| Redis | Upstash — `rediss://...upstash.io:6379` (TLS, port 6379 not 6380) | +| Blob storage | Azure Blob Storage — `roomiesblob` account, `roomies-uploads` container | +| Email (prod) | `EMAIL_PROVIDER=brevo-api` (Brevo REST API, **not** SMTP relay) | +| Email (local) | `EMAIL_PROVIDER=brevo` (Brevo SMTP) or `EMAIL_PROVIDER=ethereal` | + +### Critical env-to-code divergence (must be resolved before any docs are finalised) + +The file `src/config/env.js` defines the `EMAIL_PROVIDER` enum as: + +```js +EMAIL_PROVIDER: z.enum(["ethereal", "brevo"], { ... }).default("ethereal") +``` + +Both `.env` (the root file used for quick local runs) and `.env.render` (the exact env vars loaded by the live Render +service) set `EMAIL_PROVIDER=brevo-api`. This value is **not in the enum** — Zod `safeParse` will fail, the startup +guard will `process.exit(1)`, and the server will never start. One of the following must be true and must be determined +before this audit continues: + +1. The server is crashing on startup on Render right now (most likely: the env var leaks past Zod because Render sets + env vars natively without loading a `.env` file, and the `.env.render` file is for local reference only — meaning the + Render dashboard vars may differ from the file). If so, document the dashboard values separately. +2. A code change has been made to `env.js` that accepts `"brevo-api"` but the change is not yet reflected in the + codebase provided. If so, document the three-value enum and the new provider branch throughout the TechStack and + Deployment docs. + +Either way, **every doc that touches `EMAIL_PROVIDER` must be updated** once this is resolved. The enum is referenced in +`docs/TechStack.md`, `docs/deployment/tier0.md`, `docs/Deployment.md`, and the environment variable tables in +`docs/API.md`. + +Additional env discrepancies that must be resolved: + +- `.env.render` sets `TRUST_PROXY=false`. The running API is behind Render's reverse proxy, which means `req.ip` + resolves to `::ffff:10.x.x.x` (Render's internal address) instead of the real client IP. The OTP IP-rate-limiter in + `auth.service.js` becomes effectively a no-op because every request looks like it comes from the same internal IP. The + correct value is `TRUST_PROXY=1`. Document this as both a security issue and a required configuration fix in the + Render docs. + +- `.env.render` sets `ALLOWED_ORIGINS=http://localhost:5173`. In production on Render, this means every credentialed + cross-origin request from any frontend domain other than `localhost:5173` is rejected by the CORS middleware guard in + `app.js`. Document the correct procedure for updating this when a frontend is deployed. + +- `.env.render` sets `NODE_ENV=production` (correct). The old tier-0 Azure doc said the `.env.render` file uses + `development` to allow local testing against live services. This is no longer accurate — the file is now the literal + copy of what Render runs. + +--- + +## How to Use This Prompt + +This is a **chained instruction set with real-world grounding**. Every phase produces or corrects doc files that the +next phase depends on. Complete the phases in strict order. When a phase references a scenario, write it as though a +real developer is integrating against the live API at `https://roomies-api.onrender.com/api/v1` — use realistic request +bodies, realistic error messages verbatim from the source code, and realistic response shapes. Never paraphrase an error +string from the service layer; copy it exactly. + +The source-of-truth hierarchy is: + +1. The actual source files in `src/` — always wins. +2. The env files as described above — governs deployment docs. +3. The existing docs in `docs/` — to be corrected where they diverge from (1) and (2). + +--- + +## Phase 0 — Governing Rules (Apply to Every Phase) + +Before writing a single line of documentation, internalise these rules. They govern every decision in every phase that +follows. + +**Rule 1 — Scenario-based JSON is the only acceptable format for endpoint documentation.** Every endpoint must be +documented as a series of named scenarios. Each scenario carries: a plain-English title that describes the real-world +situation triggering it; the full request contract (method, path, auth requirement, relevant headers, validated query +params, and exact body shape); and the response block showing the complete JSON. Response bodies must match what the +code actually returns — check the controller's `res.status().json()` call and the service's return value. Never say a +field "might" contain something; show what it actually contains. + +**Rule 2 — Code is truth; docs are its shadow.** If `auth.service.js` throws +`new AppError("Incorrect OTP — 4 attempts remaining", 400)`, the doc shows `400` with +`{ "status": "error", "message": "Incorrect OTP — 4 attempts remaining" }`. The `4` is not a placeholder — it is the +actual string constructed by the service at runtime, and the doc must use the same template. The same applies to every +`AppError` in every service. + +**Rule 3 — Gates are first-class endpoint outcomes.** When a route chain is +`optionalAuthenticate → validate → guestListingGate → controller`, the gate's short-circuit responses are as real as the +controller's success response. Document them as named scenarios with their own request context (e.g. "guest sends +request without a token") and response shape. Never hide gate behaviour in a footnote. + +**Rule 4 — Status codes come from both the controller and the service.** The controller determines the success code +(`res.status(201).json(...)` vs `res.json(...)`). The service determines every error code via `AppError`. The global +error handler in `src/middleware/errorHandler.js` determines what PostgreSQL constraint codes map to: `23505 → 409`, +`23503 → 409`, `23514 → 400`. All three sources must be checked for every endpoint's full scenario list. + +**Rule 5 — Real-world completeness means every path through the code is a scenario.** A service function that has three +different `throw new AppError(...)` calls represents three different failure scenarios. Each one gets its own named +scenario block. The scenario name should describe the real-world condition, not the technical mechanism — "Poster tries +to accept an already-accepted request" rather than "ON CONFLICT fires on interest_requests". + +**Rule 6 — Cross-references must be hyperlinks, not prose.** Every time a doc says "see the auth flow" it must link to +`./auth.md`. Every time it references a pagination pattern it must link to the conventions doc or the pagination section +of `API.md`. Every time a new file is created it must be linked from `docs/README.md` and `docs/API.md`. +Cross-references are a navigability contract, not a stylistic choice. + +**Rule 7 — Example values are canonical and must be consistent.** The file `docs/api/conventions.md` defines canonical +UUIDs. Every scenario in every feature doc uses those exact UUIDs. No scenario invents a new UUID. This rule exists so +that a developer reading multiple feature docs while building an integration flow sees the same identifiers everywhere +and can mentally compose the scenarios into a coherent sequence. + +**Rule 8 — The paise rule is a documentation contract, not just an implementation detail.** Every endpoint that involves +`rentPerMonth` or `depositAmount` must explicitly state in its request contract table that values are accepted and +returned in rupees, even though the database stores paise. A developer who does not know this will send `850000` +expecting ₹8,500 and get ₹8.5M rent on a listing. + +--- + +## Phase 1 — Audit Every Existing Feature Doc Against Current Source + +For each subsection, open the doc file and the listed source files simultaneously. For every discrepancy, note it, +correct it in place, and add whatever scenarios are missing. The subsections below tell you exactly where to look and +what to verify. + +### 1.1 — `docs/api/auth.md` + +Source files: `src/routes/auth.js`, `src/validators/auth.validators.js`, `src/services/auth.service.js`, +`src/controllers/auth.controller.js`. + +Verify and correct each of the following: + +**Logout route asymmetry.** `POST /auth/logout` does not require `authenticate` middleware — it reads the refresh token +from `req.body.refreshToken ?? req.cookies?.refreshToken` and calls `logoutByRefreshToken`. `POST /auth/logout/current` +does require `authenticate` — it reads `req.user.sid` from the JWT to identify the exact session to revoke. The doc must +show both endpoints, and the `/logout/current` doc must clearly state the access-token requirement. + +**Revoking the current session clears cookies.** In `auth.controller.js`, `revokeSession` calls `clearAuthCookies(res)` +when `req.user.sid === sid`. This means deleting your own session via `DELETE /auth/sessions/:sid` also clears the +browser's `accessToken` and `refreshToken` cookies. This side-effect is a real-world outcome that must appear as a named +scenario: "Caller revokes their current session — cookies are cleared in the same response." + +**`parseTtlSeconds` accepts numeric values.** The function accepts `number`, digit-only string, and `"15m"`/`"7d"` +format strings. The doc's description of JWT expiry behaviour must reflect that `JWT_EXPIRES_IN=900` (a plain integer) +is valid and means 900 seconds, not 900 milliseconds. + +**Legacy token migration.** `verifyRefreshTokenPayload` transparently migrates tokens that were issued before +per-session keys were introduced — they lack a `sid` field. When a legacy token arrives, the function generates a new +`sid`, writes the per-session key, deletes the legacy key, and returns normally. Callers never see a migration-related +error. The doc must include a note in the "Integrator Notes" section stating that legacy tokens are migrated +transparently on first use — callers do not need to handle this. + +**Google OAuth three paths.** `POST /auth/google/callback` has three distinct internal branches with distinct real-world +meanings: + +- Path 1 (returning user, found by `google_id`): user previously signed in with Google. +- Path 2 (account linking, found by email, `google_id IS NULL`): user has an existing email/password account and is + linking it to Google for the first time. +- Path 3 (new registration): brand-new user, no existing account by email or Google ID. + +All three must appear as named scenarios with the exact response shape. Path 2's scenario title should be "Existing +email/password account linked to Google for the first time." Path 3 has sub-scenarios for `student` and `pg_owner` roles +because the required body fields differ. + +The failure scenarios for the Google callback endpoint that must all be present: + +- `role` missing on new registration → `400` +- `fullName` missing on new registration → `400` +- `businessName` missing when role is `pg_owner` → `400` +- Google token invalid or expired → `401` +- Google account email not verified → `400` +- Google account already linked to a different user → `409` +- Local account already linked to a different Google account → `409` +- Google OAuth not configured on the server (no `GOOGLE_CLIENT_ID` env var) → `503` + +**OTP verify scenarios are incomplete in most versions of this doc.** Verify all of these are present as distinct named +scenarios with exact message strings from `auth.service.js`: + +- Correct OTP on first attempt → `200` with `"Email verified successfully"` +- Wrong OTP with attempts remaining → `400` with the dynamic `"Incorrect OTP — N attempts remaining"` string +- Wrong OTP on the fifth attempt → `429` with `"Too many incorrect attempts — request a new OTP"` +- OTP expired or never sent → `400` with `"OTP has expired or was never sent — request a new one"` +- IP rate limit tripped → `429` with `"Too many OTP verification attempts from this IP — please wait 15 minutes"` +- Redis unavailable (fail-closed) → `429` with `"OTP verification is temporarily unavailable"` + +### 1.2 — `docs/api/profiles-and-contact.md` + +Source files: `src/routes/student.js`, `src/routes/pgOwner.js`, `src/validators/student.validators.js`, +`src/validators/pgOwner.validators.js`, `src/services/student.service.js`, `src/services/pgOwner.service.js`, +`src/middleware/contactRevealGate.js`. + +Verify and correct each of the following: + +**HTTP method asymmetry.** The student contact reveal route is `GET /students/:userId/contact/reveal`. The PG owner +contact reveal route is `POST /pg-owners/:userId/contact/reveal`. This asymmetry is intentional — `POST` prevents +browser prefetch and intermediate proxy caching of PII responses. The doc must state this rationale explicitly, not just +note the difference. + +**`Cache-Control: no-store` appears on all responses from these routes, not just successes.** In +`src/routes/student.js`, the `no-store` header middleware runs before `contactRevealGate`. In `src/routes/pgOwner.js`, +it also runs before the gate. This means the `429` limit-reached response also carries `Cache-Control: no-store`. Every +scenario for both reveal endpoints — including the `429` — must show `Cache-Control: no-store` in the response headers +section. + +**Gate charges quota only on successful 2xx reveals.** The `contactRevealGate` uses a pre-response hook (wrapping +`res.json`, `res.send`, `res.end`) to increment the Redis counter only when the response status is 2xx. A `404` (user +not found) does not consume a reveal slot. The doc must state this clearly: "Quota is charged only when the reveal is +successful (2xx response). A 404 or 500 does not consume a free reveal." + +**Two-tier model details.** Verified users (authenticated + `isEmailVerified === true`) get unlimited reveals and the +full contact bundle. Guests and unverified authenticated users get at most 10 reveals per 30-day rolling window and +receive only the email — never `whatsapp_phone`. The doc must show the exact response shape for both tiers, and the +shapes must differ only in the presence/absence of `whatsapp_phone`. + +**Preferences routes are mounted in `student.js`.** `GET /students/:userId/preferences` and +`PUT /students/:userId/preferences` are on the student router, not a separate preferences router. Both are owner-only +(caller must match `:userId`). The `PUT` uses the `dedupePreferencesByKey` function from `src/config/preferences.js`, +which implements last-write- wins when duplicate `preferenceKey` values appear in the same request. The doc must include +a named scenario: "Request with duplicate preference keys — last value for each key wins, no error returned." The +example body should send `smoking` twice with different values. + +### 1.3 — `docs/api/listings.api.md` + +Source files: `src/routes/listing.js`, `src/validators/listing.validators.js`, `src/services/listing.service.js`, +`src/middleware/guestListingGate.js`, `src/services/listingLifecycle.js`. + +Verify and correct each of the following: + +**Guest access is a first-class policy that must be documented in a prominent table.** `GET /listings` and +`GET /listings/:listingId` are the only two endpoints that accept unauthenticated requests. All other listing endpoints +return `401` for guests. The doc must include a table at the top of the file that shows this policy at a glance: + +| Caller | `GET /listings` | `GET /listings/:listingId` | Save | Interest | Compatibility | +| ------------- | ---------------- | -------------------------- | --------------- | --------------- | ------------------- | +| Guest | ✅ max 20 items | ✅ full detail | ❌ 401 | ❌ 401 | ❌ always 0 | +| Authenticated | ✅ max 100 items | ✅ full detail | ✅ student only | ✅ student only | ✅ when prefs exist | + +**`compatibilityAvailable` is missing from the existing doc.** The search result item shape now includes two related +fields: `compatibilityScore` (integer, the count of matching preference pairs) and `compatibilityAvailable` (boolean, +true only when both the requesting user has saved preferences AND the listing has at least one preference set). For +guests, both are always `0` and `false`. For authenticated users with no preferences, `compatibilityScore` is `0` and +`compatibilityAvailable` is `false`. Add `compatibilityAvailable` to every search result item shape in the doc. + +**`guestListingGate` silently caps the `limit` param to 20 for guests.** The middleware in +`src/middleware/guestListingGate.js` rewrites `req.query.limit` to `20` if a guest requests more. The cap is silent — no +error, no warning header. Document this behaviour in the guest access table: "Guests receive at most 20 items per +request regardless of the `limit` query parameter." + +**Lifecycle error message strings must be exact.** `src/services/listingLifecycle.js` exports: + +```js +export const EXPIRED_LISTING_MESSAGE = "Listing has expired and is no longer available"; +export const UNAVAILABLE_LISTING_MESSAGE = "Listing is no longer available"; +``` + +These exact strings appear in `422` responses whenever an operation is attempted on an expired or non-active listing. +Verify every expired/unavailable scenario in the doc uses these exact strings, not paraphrases like "listing is expired" +or "listing unavailable". + +**`PUT /listings/:listingId` location-field rejection message must be exact.** When a caller tries to update `city`, +`latitude`, or any other property-owned location field on a `pg_room` or `hostel_bed` listing, the service throws: + +``` +`Location fields (${forbiddenFields.join(", ")}) cannot be updated on a ${listingType} listing — ` + +`they are inherited from the parent property. Update the property's address instead.` +``` + +The doc's `422` scenario must use the exact interpolated format with actual field names. The example scenario should +show a request that sends both `city` and `latitude`, and the message should read: +`"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."` + +**`PATCH /listings/:listingId/status` returns only `{ listingId, status }`.** The service function `updateListingStatus` +returns `{ listingId, status: newStatus }`. It does not return `rentPerMonth`, `title`, or any other listing field. The +existing doc may show a richer response shape — verify it is trimmed to match exactly. + +**The status transition table must include the full terminal-state rules:** + +- `active → filled` ✅ +- `active → deactivated` ✅ +- `deactivated → active` ✅ (only if listing has not expired) +- `filled → *` ❌ terminal, no transitions out +- `expired → *` ❌ terminal, set only by cron — not exposed as a valid target in `updateListingStatusSchema` + +### 1.4 — `docs/api/interests.md` + +Source files: `src/routes/interest.js`, `src/routes/listing.js`, `src/validators/interest.validators.js`, +`src/services/interest.service.js`. + +Verify and correct each of the following: + +**`POST /listings/:listingId/interests` is student-only.** The route applies `authorize("student")` middleware. The doc +must state this explicitly in the request contract and include a named failure scenario: "Non-student role (e.g. PG +owner) attempts to send interest" → `403` with `"Forbidden"`. + +**Accept transition response must include both `whatsappLink` and `listingFilled`.** When a poster accepts an interest +request via `PATCH /interests/:interestId/status`, the `_acceptInterestRequest` service function returns: + +```js +{ interestRequestId, studentId, listingId, status: "accepted", connectionId, whatsappLink, listingFilled } +``` + +The `whatsappLink` is `null` when the poster has no phone number on file. The `listingFilled` boolean is `true` when +this acceptance exhausted the listing's `total_capacity`. Both fields must appear in the scenario response with their +types and null-conditions documented. + +**`GET /listings/:listingId/interests` has two distinct 403/404 scenarios.** In `getInterestRequestsForListing`, the +service first checks whether the listing exists at all, then whether the caller owns it. These produce different +responses: + +- Listing exists, but caller is not the owner → `403` with `"You do not own this listing"` +- Listing does not exist → `404` with `"Listing not found"` + +Both must be distinct named scenarios. The current doc may merge them or show only one. + +**Re-sending after decline is allowed.** The `createInterestRequest` service uses +`ON CONFLICT (sender_id, listing_id) WHERE status IN ('pending', 'accepted') DO NOTHING`. This partial unique index only +blocks duplicate active requests. A student whose previous interest was `declined` or `withdrawn` can successfully send +a new request — the `ON CONFLICT` clause does not match those terminal states, so a fresh `INSERT` succeeds. The doc +must include a named scenario: "Student re-sends interest after their previous request was declined — new request is +created successfully." + +### 1.5 — `docs/api/connections.md` + +Source files: `src/routes/connection.js`, `src/validators/connection.validators.js`, +`src/services/connection.service.js`. + +Verify and correct each of the following: + +**`confirmConnection` uses a single atomic UPDATE.** The service does not perform two separate database operations (flip +flag, then check if both are true). It issues a single `UPDATE` with a `CASE WHEN` block that simultaneously flips the +caller's confirmation flag and conditionally promotes `confirmation_status` to `'confirmed'` if both flags are now true. +The doc must say: "The confirmation and the potential status promotion to `'confirmed'` happen in a single atomic +database operation." This matters for integrators who might worry about race conditions. + +**`GET /connections/me` supports `confirmationStatus` and `connectionType` filters.** Verify the doc shows both filters +with their full enum values: + +- `confirmationStatus`: `"pending"`, `"confirmed"`, `"denied"`, `"expired"` +- `connectionType`: `"student_roommate"`, `"pg_stay"`, `"hostel_stay"`, `"visit_only"` + +Both are optional. The absence of a filter returns all connections regardless of that dimension. + +### 1.6 — `docs/api/ratings-and-reports.md` + +Source files: `src/routes/rating.js`, `src/validators/rating.validators.js`, `src/validators/report.validators.js`, +`src/services/rating.service.js`, `src/services/report.service.js`. + +Verify and correct each of the following: + +**`GET /ratings/user/:userId` and `GET /ratings/property/:propertyId` are public.** Both routes apply +`publicRatingsLimiter` but no `authenticate` middleware. The doc must not list any auth requirement for these two +endpoints, and must not say "Auth required." + +**`submitRating` zero-rowCount discrimination.** When the atomic `INSERT ... SELECT ... WHERE EXISTS` returns zero rows, +the service performs a follow-up query to distinguish three cases: + +1. Connection not found or caller not a party → `404` with `"Connection not found"` +2. Connection exists but `confirmation_status !== 'confirmed'` → `422` with + `"Ratings can only be submitted for confirmed connections"` +3. Duplicate rating (ON CONFLICT fired) → `409` with + `"You have already submitted a rating for this connection and reviewee"` + +All three must be distinct named scenarios. The `422` and `409` cases are especially easy to conflate but represent +entirely different real-world situations. + +**`resolveReport` cross-field constraint.** `adminNotes` is required when `resolution = "resolved_removed"` and optional +when `resolution = "resolved_kept"`. This is enforced both in `src/validators/report.validators.js` (Zod `.refine()`) +and in `src/services/report.service.js` (service-layer guard). The doc must show a named failure scenario: "Admin +submits `resolved_removed` without `adminNotes`" → `400` validation error with +`"adminNotes is required when resolution is resolved_removed"`. + +**`submitReport` party-membership check exact error string.** The service uses: + +```js +throw new AppError("Rating not found or you are not a party to this connection", 404); +``` + +This exact string must appear in the `404` scenario. The intentional vagueness of "or you are not a party" is the +privacy-preserving design — the response never confirms whether the rating exists to an unauthorised caller. + +### 1.7 — `docs/api/notifications.md` + +Source files: `src/routes/notification.js`, `src/validators/notification.validators.js`, +`src/services/notification.service.js`, `src/workers/notificationWorker.js`. + +Verify and correct each of the following: + +**`all: false` is a validation error, not a no-op.** The `markReadSchema` uses `z.literal(true)` for the `all` field. +Sending `{ "all": false }` fails Zod validation and returns `400` before the service is ever called. The doc must +include a named scenario: "Client sends `{ all: false }`" → `400` validation error. This is distinct from the "both +modes supplied simultaneously" scenario. The expected error body is a standard Zod validation error with a field error +on `body.all`. + +**Notification type table must exactly match `NOTIFICATION_MESSAGES` in `notificationWorker.js`.** Cross-check every key +in the map against the table in the doc. The complete current list is: `interest_request_received`, +`interest_request_accepted`, `interest_request_declined`, `interest_request_withdrawn`, `connection_confirmed`, +`rating_received`, `listing_expiring`, `listing_expired`, `listing_filled`, `verification_approved`, +`verification_rejected`, `new_message`, `connection_requested`. + +Note the distinction between `listing_expiring` (fired by the `expiryWarning` cron, 7 days before expiry) and +`listing_expired` (fired by the `listingExpiry` cron, at the moment of expiry). Both must be in the table with correct +trigger descriptions. + +`connection_requested` is marked PLANNED in the worker — no emitter exists in the codebase. The doc must label it +PLANNED and state that no code currently enqueues this type. + +### 1.8 — `docs/api/health.md` + +Source file: `src/routes/health.js`. + +Verify that the `503` degraded response shape uses `"timeout"` as the service status string for timed-out probes. The +`isTimeoutError()` function in `health.js` checks for `ETIMEDOUT`, `ECONNABORTED`, `ESOCKETTIMEDOUT`, `TimeoutError`, +`AbortError`, and a regex `/timed?\s*out/i` on the message. When any of those conditions match, the service status is +set to `"timeout"`. When they do not match, the service status is `"unhealthy"`. The doc must show scenarios for both +`"timeout"` and `"unhealthy"` for both database and Redis. + +--- + +## Phase 2 — Document Missing or Incomplete Features + +### 2.1 — `docs/api/admin.md` + +This file is referenced from `docs/API.md` and `docs/README.md`. Verify it contains full scenario-based documentation +for all five admin endpoints, and add whatever is missing. + +All five endpoints share `authenticate + authorize('admin')` enforced at the router level. Any request to any route +under `/admin` without a valid admin JWT short-circuits with `403` before the route handler runs. This gate response +must appear as a first-class scenario at the top of this file. + +**Verification queue endpoints:** + +`GET /admin/verification-queue` — paginated oldest-first. The anti-starvation ordering (oldest first, not newest) is +deliberate and must be noted in the doc. Success scenario shows the full item shape including `business_name`, +`owner_full_name`, `verification_status`, and `email`. + +`POST /admin/verification-queue/:requestId/approve` — approves the request, sets `verification_status = 'verified'` on +the profile. The service uses `AND status = 'pending'` in the `UPDATE` as a concurrency guard. If a concurrent +approval/rejection already resolved the request, `rowCount === 0` and the service throws +`AppError("Verification request not found or already resolved", 409)`. This concurrent-resolution scenario must be a +named failure scenario. The success response is `{ requestId, status: "verified" }`. + +`POST /admin/verification-queue/:requestId/reject` — requires `rejectionReason` in the body (enforced by +`rejectRequestSchema`). `adminNotes` is optional. Same concurrent-resolution `409` applies. Success response is +`{ requestId, status: "rejected" }`. + +**Report queue endpoints:** + +`GET /admin/report-queue` — paginated oldest-first. Each item carries the full rating content, reporter profile, +reviewer profile, and reviewee profile so the admin can decide without a second request. The response shape is complex — +document it fully. + +`PATCH /admin/reports/:reportId/resolve` — two resolutions: `"resolved_removed"` (hides the rating, triggers the +`update_rating_aggregates` DB trigger) and `"resolved_kept"` (closes the report without touching the rating). Document: + +- `adminNotes` is required for `"resolved_removed"` → `400` when missing +- Report not found or already resolved → `409` +- Successful resolution → `200` with `{ reportId, resolution, ratingId }` + +**CDC pipeline link.** When a verification request is approved or rejected, the trigger +`trg_verification_status_changed` fires and writes to `verification_event_outbox`. The `verificationEventWorker` picks +this up and enqueues both an in-app notification and a transactional email. Document this chain in the admin doc as a +note: "Approval and rejection decisions trigger asynchronous side effects — an in-app notification and a transactional +email are enqueued within approximately 5 seconds via the CDC outbox worker." + +### 2.2 — `docs/api/preferences.md` + +Verify the file covers all three surfaces completely. + +`GET /preferences/meta` — returns the `preferenceMetadata` object from `src/config/preferences.js`. The full current +catalog (7 keys: `smoking`, `food_habit`, `sleep_schedule`, `alcohol`, `cleanliness_level`, `noise_tolerance`, +`guest_policy`) must appear in the doc's success response so integrators do not need to call the endpoint just to learn +the valid values. When the catalog changes in `preferences.js`, this doc section must be updated in the same commit. + +`GET /students/:userId/preferences` — owner-only (Zod params schema validates UUID; service enforces +`requestingUserId === targetUserId`). Returns an array of `{ preferenceKey, preferenceValue }` objects. Empty array for +users who have not set any preferences — not `null`, not `404`. + +`PUT /students/:userId/preferences` — full-replace semantics. Empty array clears all preferences. The +`dedupePreferencesByKey` function applies last-write-wins when duplicate keys are submitted. A request body like: + +```json +{ + "preferences": [ + { "preferenceKey": "smoking", "preferenceValue": "non_smoker" }, + { "preferenceKey": "smoking", "preferenceValue": "smoker" } + ] +} +``` + +results in a single `smoking: smoker` row (last value wins), and the response returns only that one entry. Name this +scenario "Duplicate preference key submitted — last value wins, no error." + +### 2.3 — Background Jobs in `docs/API.md` + +The "Background Jobs That Affect API Behavior" section must describe all three cron jobs with exact schedule +expressions, overridable env var names, and the `SOFT_DELETE_RETENTION_DAYS` format constraint. Use the following +verified details from the source code: + +`src/cron/listingExpiry.js` — schedule: `0 2 * * *` (02:00 daily server time), env override: `CRON_LISTING_EXPIRY`. +Transitions `active` listings where `expires_at < NOW()` to `expired` and bulk-expires pending interest requests on +those listings in the same transaction. Also enqueues `listing_expired` notifications for affected posters post-commit. + +`src/cron/expiryWarning.js` — schedule: `0 1 * * *` (01:00 daily server time), env override: `CRON_EXPIRY_WARNING`. +Enqueues `listing_expiring` notifications for listings expiring within 7 days. Uses a durable INSERT with +`ON CONFLICT (idempotency_key) DO NOTHING` inside the transaction — the idempotency key format is +`expiry_warning:{listing_id}:{YYYY-MM-DD}` (UTC date). This guarantees exactly one notification per listing per calendar +day regardless of how many times the cron runs. + +`src/cron/hardDeleteCleanup.js` — schedule: `0 4 * * 0` (04:00 every Sunday), env override: `CRON_HARD_DELETE`. +Hard-deletes rows with `deleted_at < NOW() - RETENTION_DAYS`. Retention overridable via `SOFT_DELETE_RETENTION_DAYS`. +Format requirement: must be a plain decimal integer with no prefix, suffix, or sign (e.g. `"90"`, not `"90days"`, not +`"-30"`, not `"1e2"`). The strict `/^[0-9]+$/` parse is intentional — `parseInt()` would silently accept `"90days"` as +90, and we want a warning log and fallback to 90 instead of silent misinterpretation. + +--- + +## Phase 3 — Update `docs/TechStack.md` + +### 3.1 — EMAIL_PROVIDER three-value enum + +Once the `brevo-api` discrepancy described in the Deployment Reality section above is resolved, update `TechStack.md` to +describe all three valid `EMAIL_PROVIDER` values: + +- `"ethereal"` — Nodemailer pointed at Ethereal's fake SMTP server (local dev only). Requires `SMTP_HOST`, `SMTP_PORT`, + `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`. Prints a preview URL to the console instead of actually delivering email. +- `"brevo"` — Nodemailer pointed at `smtp-relay.brevo.com:587` (STARTTLS). Requires `BREVO_SMTP_LOGIN`, + `BREVO_SMTP_KEY`, `BREVO_SMTP_FROM`. Real email delivery. +- `"brevo-api"` — Brevo REST API (`/v3/smtp/email`). Requires `BREVO_API_KEY` and `BREVO_SMTP_FROM`. Used on Render + because Render's free tier blocks outbound SMTP connections. + +The `BREVO_SMTP_KEY` vs `BREVO_API_KEY` distinction must be clearly stated: the SMTP key starts with `xsmtpsib-` and is +used only with `EMAIL_PROVIDER=brevo`; the API key starts with `xkeysib-` and is used only with +`EMAIL_PROVIDER=brevo-api`. + +### 3.2 — BullMQ queue table + +Update the BullMQ queues table to include all three current queues: + +| Queue name | Worker file | Concurrency | Used for | +| ----------------------- | ----------------------- | -------------- | ---------------------------------------------------------------- | +| `media-processing` | `mediaProcessor.js` | 1 (CPU-bound) | Sharp compression, storage upload, DB URL update, cover election | +| `notification-delivery` | `notificationWorker.js` | 10 (I/O-bound) | Notification INSERT with idempotency key ON CONFLICT DO NOTHING | +| `email-delivery` | `emailWorker.js` | 3 (I/O-bound) | OTP emails, verification status emails via CDC outbox pipeline | + +### 3.3 — CDC outbox worker subsection + +Add a new subsection under the Workers section for the verification event worker. It must explain: + +- This is not a BullMQ worker. It is a `setInterval`-based polling loop (5-second interval). +- It reads from `verification_event_outbox`, which is populated by the Postgres trigger + `trg_verification_status_changed` (defined in migration `002_verification_event_outbox.sql`). +- It uses `SELECT ... FOR UPDATE SKIP LOCKED` to allow safe concurrent operation across multiple instances without + double-processing. +- On processing an event, it ensures `pg_owner_profiles.verification_status` is consistent with the event (a profile + consistency guard for direct-SQL changes), enqueues an in-app notification, and enqueues a transactional email. +- Failed events are retried up to `MAX_ATTEMPTS = 5` times; permanently failed events have `processed_at` set so they no + longer block the queue, and the error is recorded in `error_message` for operator inspection. +- It is started in `server.js` after Redis and Postgres are confirmed healthy, and stopped during graceful shutdown via + `verificationEventWorker.close()`. + +--- + +## Phase 4 — Update `docs/ImplementationPlan.md` + +### 4.1 — Phase 5 status correction + +`phase5/cron` is marked ✅ MERGED. `phase5/admin` remains ⏳ NOT STARTED. + +Update the "What needs building" list under `phase5/admin` to reflect current reality: + +- The email delivery worker (`emailWorker.js`, `emailQueue.js`) is **done** — remove it from the needs-building list. + Mark it ✅ in the source file inventory. +- The verification event CDC worker (`verificationEventWorker.js`) is **done** — add it to the source file inventory if + not present. It belongs between the cron files and the BullMQ workers. +- The admin user management endpoints (`GET /admin/users`, `PATCH /admin/users/:userId/status`) are **not started**. +- The admin analytics endpoint (`GET /admin/analytics/platform`) is **not started**. +- The admin rating visibility endpoints (`GET /admin/ratings`, `PATCH /admin/ratings/:ratingId/visibility`) are **not + started**. + +### 4.2 — Source file inventory additions + +Add the following files to the inventory with appropriate status marks and one-line descriptions: + +- `src/workers/emailWorker.js` — ✅ BullMQ worker for async email delivery; concurrency 3; handles `otp`, + `verification_approved`, `verification_rejected`, `verification_pending` types +- `src/workers/emailQueue.js` — ✅ Fire-and-forget enqueue helper for `email-delivery` queue; mirrors + `notificationQueue.js` pattern +- `src/workers/verificationEventWorker.js` — ✅ setInterval polling loop (5s); reads `verification_event_outbox`; + dispatches notifications + emails for verification status changes; SELECT FOR UPDATE SKIP LOCKED for concurrency + safety +- `src/middleware/guestListingGate.js` — ✅ Silently caps `limit` to 20 for unauthenticated requests on `GET /listings` + and `GET /listings/:listingId` + +### 4.3 — DB trigger reference table addition + +Add the following trigger to the trigger reference table: + +| Trigger | Table | Fires on | Action | +| --------------------------------- | ----------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `trg_verification_status_changed` | `verification_requests` | AFTER UPDATE OF `status` | Inserts into `verification_event_outbox` for `verified`, `rejected`, or `pending` transitions; the `verificationEventWorker` polls this outbox | + +--- + +## Phase 5 — Update Deployment Documentation + +The current active deployment target is **Render**, not Azure. The deployment docs need to reflect this reality. +`docs/Deployment.md` covers the Azure migration plan (future state). `docs/deployment/tier0.md` covers the Render +deployment (current state). This phase focuses on tier0 since that is what is running right now. + +### 5.1 — `docs/deployment/tier0.md` — Email provider + +The Render free tier blocks outbound SMTP on port 587. This means `EMAIL_PROVIDER=brevo` (which uses Nodemailer SMTP) +will fail silently or with connection errors. The correct provider for Render is `EMAIL_PROVIDER=brevo-api`, which calls +the Brevo REST API over HTTPS. Verify this is reflected everywhere in the tier0 guide: + +- The environment variable table in Phase 7.2 of the guide must list `EMAIL_PROVIDER=brevo-api`. +- Any section that references `BREVO_SMTP_KEY` or `BREVO_SMTP_LOGIN` for Render must be updated to reference + `BREVO_API_KEY` instead — the API provider does not use SMTP credentials. +- `BREVO_SMTP_FROM` (the sender address) is still used by the API provider and must remain. + +### 5.2 — `docs/deployment/tier0.md` — `TRUST_PROXY` setting + +The Render environment variable table must include `TRUST_PROXY=1`. Without this, `req.ip` resolves to Render's internal +load balancer IP rather than the real client IP, which breaks: + +- The OTP verify IP-level rate limiter (all users appear to come from the same IP) +- The contact reveal gate fingerprinting (all guest fingerprints collide) +- The `express-rate-limit` auth limiter IP keying + +This is a security-relevant misconfiguration, not a cosmetic issue. The doc must explain why `TRUST_PROXY=1` is +required, not just list it as a table row. + +The current `.env.render` file sets `TRUST_PROXY=false`. The tier0 guide must add a callout: "The `.env.render` file is +provided as a reference of what is currently deployed and has `TRUST_PROXY=false` — **this is a misconfiguration that +must be corrected** in the Render dashboard. The correct value for Render's proxy architecture is `TRUST_PROXY=1`." + +### 5.3 — `docs/deployment/tier0.md` — `NODE_ENV` clarification + +The `.env.render` file now has `NODE_ENV=production` (not `development`). Update any tier0 doc language that previously +said the file uses `development` for local testing. The current state is: both the local reference file and the Render +dashboard should use `NODE_ENV=production`. If you want a local file for testing against live Render services, that is a +separate `.env.render.local` file that you create manually and never commit. + +### 5.4 — `docs/deployment/tier0.md` — `ALLOWED_ORIGINS` production value + +The current `.env.render` file sets `ALLOWED_ORIGINS=http://localhost:5173`. This means no frontend other than a local +dev server can make credentialed cross-origin requests to the live API. The tier0 guide must include a callout +explaining: + +- The placeholder value `http://localhost:5173` is intentional for the initial deployment phase when no frontend is yet + deployed. +- When a frontend is deployed (e.g. to Vercel or Netlify), this value must be updated in the Render dashboard to the + production frontend URL (e.g. `https://roomies.vercel.app`). +- The value is a comma-separated list for multiple origins: `https://roomies.vercel.app,https://www.roomies.in`. + +### 5.5 — `docs/deployment/tier0.md` — Database and Redis reality + +The tier0 guide must document the actual service providers being used: + +- Database is **Neon PostgreSQL** (not Azure PostgreSQL). The connection string uses the pooler endpoint (`-pooler.` in + the hostname) which is required for Render's serverless-like instances to avoid exhausting connection limits. Direct + connections without `-pooler` may work locally but will exhaust Neon's connection quota under load. +- Redis is **Upstash** on port **6379** (not 6380). The `rediss://` scheme indicates TLS. The Upstash free tier is + sufficient for development but will hit the 500K command/month ceiling under sustained BullMQ polling. For production + load, upgrade to a paid Upstash plan. + +### 5.6 — Live API base URL in all deployment docs + +Add a banner at the top of `docs/deployment/tier0.md` and a note in `docs/API.md`: + +``` +Live API base URL: https://roomies-api.onrender.com/api/v1 +Health check: https://roomies-api.onrender.com/api/v1/health +``` + +This is the URL integrators should use when testing against the live deployment. The local development URL remains +`http://localhost:3000/api/v1`. + +--- + +## Phase 6 — Final Consistency Pass + +After completing all phases above, perform the following cross-cutting checks in order. + +### 6.1 — Hyperlink audit + +Every cross-reference in every doc file must be a working relative hyperlink. Specifically: + +- Every feature doc must have a link back to `./conventions.md` at or near the top. +- `docs/README.md` must link to every file in `docs/api/`. +- `docs/API.md` feature docs list must include every file in `docs/api/` and all links must resolve. +- Phase 5.6 live URL banners must be present. + +### 6.2 — UUID consistency audit + +The canonical UUIDs from `docs/api/conventions.md` must be used in every scenario in every feature doc. Verify no doc +invents a UUID that is not in the conventions file. If a new entity type needs a canonical example UUID (e.g. a report +ID or a session ID), add it to conventions.md and use it everywhere. + +The current canonical set: + +- Student: `11111111-1111-4111-8111-111111111111` +- PG owner: `22222222-2222-4222-8222-222222222222` +- Admin: `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` + +### 6.3 — HTTP status code table in `docs/API.md` + +Section 20 of `docs/API.md` must include these entries (add or correct as needed): + +- `202` — Accepted: async processing started (photo upload `POST /listings/:listingId/photos`) +- `503` — Service unavailable: dependency unhealthy (health endpoint degraded response) AND photo processing queue + temporarily unavailable (from `photo.service.js` when BullMQ enqueue fails) + +### 6.4 — Pagination constraint in `docs/API.md` + +Section 19 must explicitly state: "`cursorTime` and `cursorId` must be provided together or both omitted. Providing +exactly one of the two returns a `400` validation error." Include the exact validation error shape: + +```json +{ + "status": "error", + "message": "Validation failed", + "errors": [{ "field": "query.cursorTime", "message": "cursorTime and cursorId must be provided together" }] +} +``` + +### 6.5 — Paise rule consistency + +Every endpoint that involves `rentPerMonth` or `depositAmount` must include a note in its request contract table: +"Values are in rupees. The API accepts and returns rupees; internal storage uses paise (multiply by 100)." Verify this +note is present in `POST /listings`, `PUT /listings/:listingId`, `GET /listings` (search results), +`GET /listings/:listingId`, and `GET /listings/me/saved`. + +### 6.6 — `docs/API.md` stale inline content + +`docs/API.md` contains several inline endpoint descriptions that duplicate or contradict the feature docs (e.g. an +inline `POST /auth/google/callback` section, inline student/PG owner profile sections). These should be removed and +replaced with links to the feature docs. The API.md file's role is the "front door" — transport rules, shared response +envelopes, pagination, status codes, and links to feature docs. It is not a second copy of the feature docs. + +--- + +## Deliverables Checklist + +When all phases are complete, each of these statements must be true: + +- [ ] Every endpoint in `src/routes/` has at least one named success scenario and a named scenario for every + `throw new AppError(...)` and every PostgreSQL constraint code path in the service. +- [ ] The `EMAIL_PROVIDER=brevo-api` discrepancy is resolved in both code and docs. +- [ ] `docs/deployment/tier0.md` documents `EMAIL_PROVIDER=brevo-api`, `TRUST_PROXY=1` with explanation, the Neon + PostgreSQL connection requirements, and the live API URL. +- [ ] The `TRUST_PROXY=false` misconfiguration in `.env.render` is called out as a known issue requiring correction in + the Render dashboard. +- [ ] `docs/TechStack.md` BullMQ queue table includes all three queues. +- [ ] `docs/TechStack.md` describes the `verificationEventWorker` as a CDC outbox drainer. +- [ ] `docs/ImplementationPlan.md` source file inventory includes `emailWorker.js`, `emailQueue.js`, + `verificationEventWorker.js`, and `guestListingGate.js`. +- [ ] The `trg_verification_status_changed` trigger is in the DB trigger reference table. +- [ ] `docs/README.md` links to every file in `docs/api/`. +- [ ] All hyperlinks between doc files resolve correctly. +- [ ] All scenario response bodies use the exact error strings from the source code, not paraphrases. +- [ ] All scenarios use canonical UUIDs from `docs/api/conventions.md`. +- [ ] Every scenario involving rent/deposit states that values are in rupees. +- [ ] The live API URL `https://roomies-api.onrender.com/api/v1` appears in the appropriate deployment docs and is + consistent across all files that reference it. + +--- + +## Source File Quick-Reference Table + +| Feature | Routes | Validators | Service | Controller | +| ------------------------ | -------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------- | +| Auth | `routes/auth.js` | `validators/auth.validators.js` | `services/auth.service.js` | `controllers/auth.controller.js` | +| Student profiles & prefs | `routes/student.js` | `validators/student.validators.js` | `services/student.service.js` | `controllers/student.controller.js` | +| PG owner profiles | `routes/pgOwner.js` | `validators/pgOwner.validators.js` | `services/pgOwner.service.js` | `controllers/pgOwner.controller.js` | +| Properties | `routes/property.js` | `validators/property.validators.js` | `services/property.service.js` | `controllers/property.controller.js` | +| Listings | `routes/listing.js` | `validators/listing.validators.js` | `services/listing.service.js` | `controllers/listing.controller.js` | +| Photos | `routes/listing.js` | `validators/photo.validators.js` | `services/photo.service.js` | `controllers/photo.controller.js` | +| Interests | `routes/interest.js`, `routes/listing.js` | `validators/interest.validators.js` | `services/interest.service.js` | `controllers/interest.controller.js` | +| Connections | `routes/connection.js` | `validators/connection.validators.js` | `services/connection.service.js` | `controllers/connection.controller.js` | +| Notifications | `routes/notification.js` | `validators/notification.validators.js` | `services/notification.service.js` | `controllers/notification.controller.js` | +| Ratings | `routes/rating.js` | `validators/rating.validators.js` | `services/rating.service.js` | `controllers/rating.controller.js` | +| Reports | `routes/rating.js` | `validators/report.validators.js` | `services/report.service.js` | `controllers/report.controller.js` | +| Verification (admin) | `routes/admin.js` | `validators/verification.validators.js` | `services/verification.service.js` | `controllers/verification.controller.js` | +| Preferences | `routes/preferences.js`, `routes/student.js` | `validators/preferences.validators.js` | `services/preferences.service.js` | `controllers/preferences.controller.js` | +| Contact reveal gate | `routes/student.js`, `routes/pgOwner.js` | — | `services/student.service.js`, `services/pgOwner.service.js` | `middleware/contactRevealGate.js` | +| Health | `routes/health.js` | — | — | — | +| Error shapes | — | — | — | `middleware/errorHandler.js` | +| Cron behaviour | — | — | `cron/listingExpiry.js`, `cron/expiryWarning.js`, `cron/hardDeleteCleanup.js` | — | +| Email delivery | — | — | `services/email.service.js` | `workers/emailWorker.js` | +| CDC outbox | — | — | `workers/verificationEventWorker.js` | Trigger in `002_verification_event_outbox.sql` | From 7cff9133dda27fe3fe19fad17be5b662d936b5b6 Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Mon, 20 Apr 2026 22:41:07 +0530 Subject: [PATCH 08/54] FEATURE: Added docs in structred mode. --- docs/API.md | 1967 ++---------------------------- docs/Deployment.md | 349 +++--- docs/ImplementationPlan.md | 53 +- docs/README.md | 43 +- docs/TechStack.md | 53 +- docs/api/admin.md | 274 +++++ docs/api/auth.md | 588 ++++----- docs/api/connections.md | 174 +-- docs/api/conventions.md | 177 +-- docs/api/health.md | 99 +- docs/api/interests.md | 311 ++--- docs/api/listings.api.md | 2 + docs/api/notifications.md | 153 ++- docs/api/preferences.md | 125 +- docs/api/profiles-and-contact.md | 473 +++---- docs/api/properties.md | 285 +++-- docs/api/ratings-and-reports.md | 333 ++--- docs/deployment/tier0.md | 40 +- docs/deployment/tier2.md | 4 +- src/routes/pgOwner.js | 2 +- 20 files changed, 2126 insertions(+), 3379 deletions(-) create mode 100644 docs/api/admin.md diff --git a/docs/API.md b/docs/API.md index 6125ea6..921f222 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,89 +1,29 @@ # Roomies API Reference -`docs/API.md` is the API front door. The detailed endpoint-by-endpoint documentation lives in `docs/api/`. +This file is the API front door. Endpoint-by-endpoint contracts live in `docs/api/*`. ## Base URLs -- Local development: `http://localhost:3000/api/v1` -- Production: `https:///api/v1` +- Live API base URL: `https://roomies-api.onrender.com/api/v1` +- Live health check: `https://roomies-api.onrender.com/api/v1/health` +- Local base URL: `http://localhost:3000/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`. +## Feature Docs -When documenting or integrating a route with a gate, treat the gate response as a first-class endpoint outcome. +- [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) -## Common Response Envelopes +## Response Envelopes Success with data: @@ -94,15 +34,6 @@ Success with data: } ``` -Success with message: - -```json -{ - "status": "success", - "message": "Logged out" -} -``` - Operational error: ```json @@ -127,29 +58,38 @@ Validation error: } ``` -More examples live in [api/conventions.md](./api/conventions.md). +## Auth Transport Modes -## Roles and Access Model +### Cookie mode -- `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. +- Auth endpoints set `accessToken` and `refreshToken` as `HttpOnly` cookies. +- Browser clients receive a safe body (no raw token strings). +- Silent refresh is cookie-only. -## Pagination Conventions +### Bearer mode + +- Send `X-Client-Transport: bearer` on auth endpoints. +- Send `Authorization: Bearer ` on protected endpoints. +- Auth responses include raw access/refresh tokens in JSON. + +## Gate and Error Semantics -Most feed-style endpoints use keyset pagination. +- Route gates (`contactRevealGate`, role gates, `guestListingGate`) are first-class outcomes and documented in feature + docs. +- PostgreSQL error mapping in global error handler: + - `23505` → `409` + - `23503` → `409` + - `23514` → `400` + +## Pagination Conventions -Common query params: +Feed endpoints use keyset pagination with: - `limit` - `cursorTime` - `cursorId` -Typical response shape: +Typical shape: ```json { @@ -166,1787 +106,66 @@ Typical response shape: 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. +### Cursor pairing rule -**Errors:** `403` if not the profile owner. `400` if no valid fields provided. +`cursorTime` and `cursorId` must be sent together (or both omitted). Supplying only one returns `400`. ---- - -## 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`): +Example: ```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" + "message": "Validation failed", + "errors": [ + { + "field": "query.cursorTime", + "message": "cursorTime and cursorId must be provided together" + } + ] } ``` -`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 +## Background Jobs That Affect API Behavior -| 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"` | +### Listing expiry + +- File: `src/cron/listingExpiry.js` +- Default schedule: `0 2 * * *` +- Env override: `CRON_LISTING_EXPIRY` +- Behavior: expires active listings where `expires_at < NOW()`, bulk-expires pending interests, enqueues + `listing_expired` notifications. + +### Expiry warning + +- File: `src/cron/expiryWarning.js` +- Default schedule: `0 1 * * *` +- Env override: `CRON_EXPIRY_WARNING` +- Behavior: enqueues `listing_expiring` notifications for listings expiring within 7 days. +- Idempotency key format: `expiry_warning:{listing_id}:{YYYY-MM-DD}` + +### Hard-delete cleanup + +- File: `src/cron/hardDeleteCleanup.js` +- Default schedule: `0 4 * * 0` +- Env override: `CRON_HARD_DELETE` +- Behavior: hard-deletes old soft-deleted rows. +- Retention env var: `SOFT_DELETE_RETENTION_DAYS` (default `90`). Must be plain decimal integer only (for example + `"90"`). + +## HTTP Status Codes + +| Code | Meaning | +| ----- | ------------------------------------------------------------------------------------------------------------------------ | +| `200` | Success | +| `201` | Created | +| `202` | Accepted (async processing started; for example photo upload) | +| `400` | Validation or malformed request | +| `401` | Missing/invalid/expired authentication | +| `403` | Forbidden | +| `404` | Resource not found (includes privacy-preserving not-found patterns) | +| `409` | Conflict (duplicates, concurrent transitions, already-resolved states) | +| `413` | Payload too large | +| `422` | Semantic business-rule violation | +| `429` | Rate-limited / gate-limited | +| `500` | Internal server error | +| `503` | Service unavailable (dependency unhealthy/timeouts, and temporary queue-unavailable paths such as photo enqueue failure) | diff --git a/docs/Deployment.md b/docs/Deployment.md index 0fecfcb..64aaf7f 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 @@ -299,7 +315,7 @@ ENV_FILE=.env.render DATABASE_URL=postgresql://neondb_owner:PASSWORD@ep-ENDPOINT.ap-southeast-1.aws.neon.tech/neondb?sslmode=require # Upstash Redis -REDIS_URL=rediss://default:YOUR_UPSTASH_PASSWORD@YOUR-ENDPOINT.upstash.io:6380 +REDIS_URL=rediss://default:YOUR_UPSTASH_PASSWORD@YOUR-ENDPOINT.upstash.io:6379 # JWT JWT_SECRET=YOUR_GENERATED_JWT_SECRET @@ -349,13 +365,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 +381,28 @@ 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 | +| `REDIS_URL` | `rediss://default:PASSWORD@ENDPOINT.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` | +| `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. #### 6.3 Deploy @@ -422,7 +439,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 +478,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 +543,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 +579,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 +621,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 +645,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 +663,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 +699,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 +715,30 @@ 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` | `XXXXX.upstash.io:6379` | +| 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` | --- @@ -746,29 +768,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 index 28538c5..0fb0780 100644 --- a/docs/ImplementationPlan.md +++ b/docs/ImplementationPlan.md @@ -24,10 +24,11 @@ is stable and every endpoint is manually verified with real HTTP requests.** - 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 +- 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 --- @@ -150,7 +151,8 @@ Conversion happens only in `src/services/listing.service.js` — never in valida ``` src/ - server.js ✅ Starts media + notification workers; registers cron jobs; graceful shutdown + server.js ✅ Starts media + notification + email workers + verificationEvent worker; + 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 @@ -172,10 +174,17 @@ src/ 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 + guestListingGate.js ✅ Guest cap for listing browse (`limit` silently capped at 20) 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 + workers/ + mediaProcessor.js ✅ BullMQ worker: image compression + storage upload + DB URL update + notificationWorker.js ✅ BullMQ worker (`notification-delivery`), concurrency 10 + emailWorker.js ✅ BullMQ worker (`email-delivery`), concurrency 3; handles otp + verification emails + emailQueue.js ✅ Enqueue helper for `email-delivery` queue + verificationEventWorker.js ✅ CDC outbox drainer (5s polling, FOR UPDATE SKIP LOCKED, retries up to 5) services/ auth.service.js ✅ register, login, logout (current + all), refresh, sendOtp, verifyOtp, googleOAuth, listSessions, revokeSession — all paths end at buildTokenResponse @@ -229,9 +238,7 @@ src/ 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 + pgOwner.js ✅ GET + PUT /:userId/profile; POST /:userId/contact/reveal; POST /:userId/documents 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 @@ -526,8 +533,8 @@ context and resolves reports, optionally hiding the rating and triggering automa ### `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). +`src/controllers/report.controller.js`. Modifications to `src/routes/rating.js` (added `POST /:ratingId/report`). Admin +queue/resolve controller + service paths exist, but the admin router is not yet mounted in `src/routes/index.js`. `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 @@ -545,8 +552,8 @@ equivalent. `adminNotes` required when `resolution = 'resolved_removed'` (enforc ``` POST /api/v1/ratings/:ratingId/report -GET /api/v1/admin/report-queue -PATCH /api/v1/admin/reports/:reportId/resolve +GET /api/v1/admin/report-queue (planned route mount) +PATCH /api/v1/admin/reports/:reportId/resolve (planned route mount) ``` --- @@ -576,13 +583,10 @@ 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. +Files to create: `src/routes/admin.js` and route wiring in `src/routes/index.js` for admin-only endpoints. **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 @@ -591,6 +595,12 @@ visibility management, email worker, and analytics. - `GET /admin/analytics/platform` — DAU, new signups, active listings count, connections formed (computed fresh, no caching) +**Already completed and present in source:** + +- `src/workers/emailWorker.js` +- `src/workers/emailQueue.js` +- `src/workers/verificationEventWorker.js` + --- ## Phase 6 — Real-Time ⏳ DEFERRED @@ -605,11 +615,12 @@ for cross-instance fanout — required for Azure App Service multi-instance depl ## 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 | +| 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 | +| `trg_verification_status_changed` | `verification_requests` | AFTER UPDATE OF `status` | Inserts verification lifecycle rows into `verification_event_outbox` (`verified`, `rejected`, `pending`) for CDC processing | Application code never manually updates `average_rating`, `rating_count`, `location`, or `updated_at`. diff --git a/docs/README.md b/docs/README.md index f15dd71..b554860 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,29 +4,36 @@ This folder contains the maintained backend documentation for the Roomies API an ## 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 +- [roomies_project_plan.md](./roomies_project_plan.md) — product goals, persona model, and phase status +- [ImplementationPlan.md](./ImplementationPlan.md) — implementation inventory, conventions, route surface, and codebase + status +- [API.md](./API.md) — API entrypoint, transport rules, shared response conventions, and links to feature docs +- [TechStack.md](./TechStack.md) — runtime, database, queue, storage, validation, and operational decisions +- [Deployment.md](./Deployment.md) — broader deployment guidance +- [deployment/tier0.md](./deployment/tier0.md) — current Render/Neon/Upstash production baseline +- [deployment/tier1.md](./deployment/tier1.md) — first scale-up tier +- [deployment/tier2.md](./deployment/tier2.md) — higher-scale architecture 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 +- [api/conventions.md](./api/conventions.md) — shared response envelopes, auth failures, pagination, and example + conventions +- [api/auth.md](./api/auth.md) — registration, login, refresh, OTP, sessions, logout, and Google OAuth flows +- [api/profiles-and-contact.md](./api/profiles-and-contact.md) — student and PG owner profiles, contact reveal, and + verification document submission +- [api/properties.md](./api/properties.md) — property CRUD and ownership rules +- [api/listings.api.md](./api/listings.api.md) — listing search, CRUD, lifecycle changes, saved listings, preferences, + photo flows, and listing-scoped interests +- [api/interests.md](./api/interests.md) — interest request creation, dashboards, and transition state machine +- [api/connections.md](./api/connections.md) — connection dashboard, detail, and two-sided confirmation +- [api/notifications.md](./api/notifications.md) — notification feed, unread count, and mark-read modes +- [api/ratings-and-reports.md](./api/ratings-and-reports.md) — ratings, public reputation views, and rating report + submission +- [api/preferences.md](./api/preferences.md) — preferences metadata and student preferences endpoints +- [api/admin.md](./api/admin.md) — admin surface scenarios (and current mount status) +- [api/health.md](./api/health.md) — dependency health probe behavior ## Maintenance Rules diff --git a/docs/TechStack.md b/docs/TechStack.md index e848f10..5c76571 100644 --- a/docs/TechStack.md +++ b/docs/TechStack.md @@ -18,7 +18,7 @@ | 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 | +| Email | `nodemailer` | ^8.0.3 | Email transport layer for Ethereal/Brevo SMTP + Brevo API-backed sends | | 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 | @@ -129,6 +129,7 @@ is the normal case for most deployments. | ----------------------- | ----------------------- | ------------- | --------------------------------------------------------------------- | | `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 | +| `email-delivery` | `emailWorker.js` | 3 (I/O-bound) | OTP + verification lifecycle emails via async queue delivery | **Job options (notification jobs):** `attempts: 5`, `backoff: { type: 'exponential', delay: 2000 }`, `removeOnComplete: 100`, `removeOnFail: 200`. @@ -137,9 +138,24 @@ is the normal case for most deployments. 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. +**Startup/shutdown:** Workers are started in `server.js` after Redis connects. On shutdown, `mediaWorker.close()`, +`notificationWorker.close()`, and `emailWorker.close()` are called before `closeAllQueues()` — workers drain in-flight +jobs before queue connections are torn down. + +### Verification CDC outbox worker (non-BullMQ) + +`src/workers/verificationEventWorker.js` is a `setInterval` polling loop (5s), not a BullMQ worker. + +- Reads from `verification_event_outbox` (written by Postgres trigger `trg_verification_status_changed` from migration + `002_verification_event_outbox.sql`). +- Uses `SELECT ... FOR UPDATE SKIP LOCKED` for safe multi-instance concurrent processing without double-processing. +- On each event, enforces profile consistency (`pg_owner_profiles.verification_status`), then enqueues: + - in-app notification (`notification-delivery`) + - transactional email (`email-delivery`) +- Retries failures up to `MAX_ATTEMPTS = 5`. +- On permanent failure, stores `error_message`, sets `processed_at`, and stops retrying that row. +- Started in `server.js` after DB/Redis health checks and stopped on graceful shutdown via + `verificationEventWorker.close()`. --- @@ -268,15 +284,26 @@ enqueues BullMQ job → response sent → worker picks up job → Sharp processe --- -## Infrastructure — Microsoft Azure (Production) +## Infrastructure — Current Production Stack + +| Service | Usage | +| ------------------ | ----------------------------------------------------------------------------- | +| Render | Live API host (`https://roomies-api.onrender.com`) | +| Neon PostgreSQL | Managed PostgreSQL 16 (+ PostGIS), pooler endpoint used in `DATABASE_URL` | +| Upstash Redis | Managed Redis over TLS (`rediss://...:6379`) for app cache + BullMQ | +| Azure Blob Storage | Photo storage (`AZURE_STORAGE_CONNECTION_STRING` + `AZURE_STORAGE_CONTAINER`) | +| Brevo API / SMTP | Email delivery with provider switch via `EMAIL_PROVIDER` | + +### `EMAIL_PROVIDER` modes + +- `ethereal`: local fake SMTP testing (`SMTP_*` + `SMTP_FROM`) +- `brevo`: Brevo SMTP relay (`BREVO_SMTP_LOGIN`, `BREVO_SMTP_KEY`, `BREVO_SMTP_FROM`) +- `brevo-api`: Brevo REST API (`BREVO_API_KEY`, `BREVO_SMTP_FROM`) — used on Render free tier where SMTP is blocked + +Credential prefix reminder: -| 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` | +- SMTP key: starts with `xsmtpsib-` (for `EMAIL_PROVIDER=brevo`) +- API key: starts with `xkeysib-` (for `EMAIL_PROVIDER=brevo-api`) --- @@ -288,4 +315,4 @@ enqueues BullMQ job → response sent → worker picks up job → Sharp processe | 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 | +| Email | Provider switch: Ethereal (dev), Brevo SMTP, or Brevo API | diff --git a/docs/api/admin.md b/docs/api/admin.md new file mode 100644 index 0000000..158fcb5 --- /dev/null +++ b/docs/api/admin.md @@ -0,0 +1,274 @@ +# Admin API + +Shared conventions: [conventions.md](./conventions.md) + +## Current implementation status + +`/admin` routes are **not mounted** in `src/routes/index.js` in this workspace, so live calls to `/api/v1/admin/*` +currently return `404`. + +This file documents the intended scenario contracts already backed by: + +- `src/services/verification.service.js` +- `src/services/report.service.js` +- `src/validators/verification.validators.js` +- `src/validators/report.validators.js` +- `src/controllers/verification.controller.js` +- `src/controllers/report.controller.js` + +When the admin router is mounted with `authenticate + authorize("admin")`, the scenarios below apply. + +## Router-level admin gate + +### Scenario: caller is not an admin + +Status: `403` + +```json +{ + "status": "error", + "message": "Forbidden" +} +``` + +--- + +## `GET /admin/verification-queue` + +Oldest-first paginated queue of pending PG owner verification requests. + +### Scenario: success + +Status: `200` + +```json +{ + "status": "success", + "data": { + "items": [ + { + "request_id": "99999999-9999-4999-8999-999999999999", + "user_id": "22222222-2222-4222-8222-222222222222", + "document_type": "owner_id", + "document_url": "https://roomiesblob.blob.core.windows.net/roomies-uploads/verifications/owner-id.webp", + "submitted_at": "2026-04-11T10:00:00.000Z", + "business_name": "Sunrise PG", + "owner_full_name": "Rohan Mehta", + "verification_status": "pending", + "email": "owner@sunrisepg.in" + } + ], + "nextCursor": null + } +} +``` + +### Scenario: partial cursor supplied + +Status: `400` + +```json +{ + "status": "error", + "message": "Validation failed", + "errors": [ + { + "field": "query.cursorTime", + "message": "cursorTime and cursorId must be provided together" + } + ] +} +``` + +--- + +## `POST /admin/verification-queue/:requestId/approve` + +### Scenario: request approved + +Status: `200` + +```json +{ + "status": "success", + "data": { + "requestId": "99999999-9999-4999-8999-999999999999", + "status": "verified" + } +} +``` + +### Scenario: request already resolved concurrently + +Status: `409` + +```json +{ + "status": "error", + "message": "Verification request not found or already resolved" +} +``` + +--- + +## `POST /admin/verification-queue/:requestId/reject` + +### Scenario: request rejected + +Status: `200` + +```json +{ + "status": "success", + "data": { + "requestId": "99999999-9999-4999-8999-999999999999", + "status": "rejected" + } +} +``` + +### Scenario: rejection reason missing + +Status: `400` + +```json +{ + "status": "error", + "message": "Validation failed", + "errors": [ + { + "field": "body.rejectionReason", + "message": "Rejection reason is required" + } + ] +} +``` + +### Scenario: request already resolved concurrently + +Status: `409` + +```json +{ + "status": "error", + "message": "Verification request not found or already resolved" +} +``` + +--- + +## `GET /admin/report-queue` + +Oldest-first paginated queue of open rating reports. + +### Scenario: success + +Status: `200` + +```json +{ + "status": "success", + "data": { + "items": [ + { + "reportId": "99999999-9999-4999-8999-999999999999", + "reporterId": "11111111-1111-4111-8111-111111111111", + "ratingId": "88888888-8888-4888-8888-888888888888", + "reason": "fake", + "explanation": "The review does not match the actual stay.", + "status": "open", + "submittedAt": "2026-04-11T10:00:00.000Z", + "rating": { + "overallScore": 1, + "cleanlinessScore": 1, + "communicationScore": 1, + "reliabilityScore": 1, + "valueScore": 1, + "comment": "Fake review", + "revieweeType": "user", + "revieweeId": "22222222-2222-4222-8222-222222222222", + "isVisible": true, + "createdAt": "2026-04-10T10:00:00.000Z", + "reviewer": { + "fullName": "Priya Sharma", + "profilePhotoUrl": null + }, + "reviewee": { + "fullName": "Rohan Mehta", + "profilePhotoUrl": null + } + }, + "reporter": { + "fullName": "Priya Sharma", + "profilePhotoUrl": null + } + } + ], + "nextCursor": null + } +} +``` + +--- + +## `PATCH /admin/reports/:reportId/resolve` + +### Scenario: resolve and remove rating + +Request: + +```json +{ + "resolution": "resolved_removed", + "adminNotes": "Evidence confirmed. Rating removed." +} +``` + +Status: `200` + +```json +{ + "status": "success", + "data": { + "reportId": "99999999-9999-4999-8999-999999999999", + "resolution": "resolved_removed", + "ratingId": "88888888-8888-4888-8888-888888888888" + } +} +``` + +### Scenario: `resolved_removed` without `adminNotes` + +Status: `400` + +```json +{ + "status": "error", + "message": "Validation failed", + "errors": [ + { + "field": "body.adminNotes", + "message": "adminNotes is required when resolution is resolved_removed" + } + ] +} +``` + +### Scenario: report already resolved + +Status: `409` + +```json +{ + "status": "error", + "message": "Report not found or already resolved" +} +``` + +## CDC side-effects note + +When verification status changes to `verified`, `rejected`, or `pending`, trigger `trg_verification_status_changed` +writes to `verification_event_outbox`. `verificationEventWorker` polls this table (5-second interval) and enqueues: + +- in-app notification (`notification-delivery`) +- email (`email-delivery`) diff --git a/docs/api/auth.md b/docs/api/auth.md index fec1aa5..27392fd 100644 --- a/docs/api/auth.md +++ b/docs/api/auth.md @@ -1,6 +1,7 @@ # Auth API -This document covers account creation, login, token refresh, OTP verification, session management, logout, and Google OAuth. +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). @@ -20,15 +21,15 @@ Creates a new student or PG owner account and immediately starts a signed-in ses - Auth required: No - Rate limited: Yes, via the auth limiter - Headers: - - Optional: `X-Client-Transport: bearer` + - Optional: `X-Client-Transport: bearer` - Body: ```json { - "email": "priya@iitb.ac.in", - "password": "Pass1234", - "role": "student", - "fullName": "Priya Sharma" + "email": "priya@iitb.ac.in", + "password": "Pass1234", + "role": "student", + "fullName": "Priya Sharma" } ``` @@ -36,11 +37,11 @@ PG owner registration adds `businessName`: ```json { - "email": "owner@sunrisepg.in", - "password": "Owner1234", - "role": "pg_owner", - "fullName": "Rohan Mehta", - "businessName": "Sunrise PG" + "email": "owner@sunrisepg.in", + "password": "Owner1234", + "role": "pg_owner", + "fullName": "Rohan Mehta", + "businessName": "Sunrise PG" } ``` @@ -50,10 +51,10 @@ Request: ```json { - "email": "priya@iitb.ac.in", - "password": "Pass1234", - "role": "student", - "fullName": "Priya Sharma" + "email": "priya@iitb.ac.in", + "password": "Pass1234", + "role": "student", + "fullName": "Priya Sharma" } ``` @@ -63,20 +64,21 @@ 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" - } + "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. +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 @@ -84,10 +86,10 @@ Request: ```json { - "email": "arjun@roomies-test.in", - "password": "Pass1234", - "role": "student", - "fullName": "Arjun Rao" + "email": "arjun@roomies-test.in", + "password": "Pass1234", + "role": "student", + "fullName": "Arjun Rao" } ``` @@ -97,18 +99,18 @@ 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 - } - } + "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 + } + } } ``` @@ -120,11 +122,11 @@ Request: ```json { - "email": "owner@sunrisepg.in", - "password": "Owner1234", - "role": "pg_owner", - "fullName": "Rohan Mehta", - "businessName": "Sunrise PG" + "email": "owner@sunrisepg.in", + "password": "Owner1234", + "role": "pg_owner", + "fullName": "Rohan Mehta", + "businessName": "Sunrise PG" } ``` @@ -132,16 +134,16 @@ 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" - } + "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" + } } ``` @@ -151,8 +153,8 @@ Status: `400` ```json { - "status": "error", - "message": "Business name is required for PG owner registration" + "status": "error", + "message": "Business name is required for PG owner registration" } ``` @@ -162,10 +164,10 @@ Request: ```json { - "email": "not-an-email", - "password": "short", - "role": "student", - "fullName": "P" + "email": "not-an-email", + "password": "short", + "role": "student", + "fullName": "P" } ``` @@ -173,22 +175,22 @@ 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" - } - ] + "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" + } + ] } ``` @@ -198,8 +200,8 @@ Status: `409` ```json { - "status": "error", - "message": "An account with this email already exists" + "status": "error", + "message": "An account with this email already exists" } ``` @@ -211,8 +213,8 @@ Authenticates an existing email/password account and creates a new session. ```json { - "email": "priya@iitb.ac.in", - "password": "Pass1234" + "email": "priya@iitb.ac.in", + "password": "Pass1234" } ``` @@ -222,16 +224,16 @@ 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" - } + "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" + } } ``` @@ -249,18 +251,18 @@ 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 - } - } + "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 + } + } } ``` @@ -270,8 +272,8 @@ Status: `401` ```json { - "status": "error", - "message": "Invalid credentials" + "status": "error", + "message": "Invalid credentials" } ``` @@ -281,8 +283,8 @@ Status: `401` ```json { - "status": "error", - "message": "Account is suspended" + "status": "error", + "message": "Account is suspended" } ``` @@ -302,7 +304,7 @@ Bearer/mobile request example: ```json { - "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh" + "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh" } ``` @@ -312,16 +314,16 @@ 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" - } + "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" + } } ``` @@ -339,7 +341,7 @@ Request: ```json { - "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh" + "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh" } ``` @@ -347,18 +349,18 @@ 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 - } - } + "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 + } + } } ``` @@ -368,8 +370,8 @@ Status: `401` ```json { - "status": "error", - "message": "Refresh token is required" + "status": "error", + "message": "Refresh token is required" } ``` @@ -379,8 +381,8 @@ Status: `401` ```json { - "status": "error", - "message": "Refresh token is invalid or has been revoked" + "status": "error", + "message": "Refresh token is invalid or has been revoked" } ``` @@ -390,8 +392,8 @@ Status: `401` ```json { - "status": "error", - "message": "Account inactive" + "status": "error", + "message": "Account inactive" } ``` @@ -407,7 +409,7 @@ Bearer/mobile request example: ```json { - "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh" + "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh" } ``` @@ -417,8 +419,8 @@ Status: `200` ```json { - "status": "success", - "message": "Logged out" + "status": "success", + "message": "Logged out" } ``` @@ -428,8 +430,8 @@ Status: `401` ```json { - "status": "error", - "message": "Refresh token is required" + "status": "error", + "message": "Refresh token is required" } ``` @@ -439,8 +441,8 @@ Status: `401` ```json { - "status": "error", - "message": "Session not found or already revoked" + "status": "error", + "message": "Session not found or already revoked" } ``` @@ -454,8 +456,8 @@ Status: `200` ```json { - "status": "success", - "message": "Logged out" + "status": "success", + "message": "Logged out" } ``` @@ -465,8 +467,8 @@ Status: `403` ```json { - "status": "error", - "message": "Refresh token does not belong to current user" + "status": "error", + "message": "Refresh token does not belong to current user" } ``` @@ -476,8 +478,8 @@ Status: `403` ```json { - "status": "error", - "message": "Refresh token session does not match the authenticated session" + "status": "error", + "message": "Refresh token session does not match the authenticated session" } ``` @@ -491,8 +493,8 @@ Status: `200` ```json { - "status": "success", - "message": "Logged out from all sessions" + "status": "success", + "message": "Logged out from all sessions" } ``` @@ -506,21 +508,21 @@ 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" - } - ] + "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" + } + ] } ``` @@ -532,7 +534,7 @@ Revokes a specific session ID. - Auth required: Yes - Path param: - - `sid` must be a UUID + - `sid` must be a UUID ### Scenario: revoke another device session @@ -540,8 +542,8 @@ Status: `200` ```json { - "status": "success", - "message": "Session revoked" + "status": "success", + "message": "Session revoked" } ``` @@ -551,8 +553,8 @@ Status: `200` ```json { - "status": "success", - "message": "Session revoked" + "status": "success", + "message": "Session revoked" } ``` @@ -564,14 +566,14 @@ Status: `400` ```json { - "status": "error", - "message": "Validation failed", - "errors": [ - { - "field": "params.sid", - "message": "sid must be a valid UUID" - } - ] + "status": "error", + "message": "Validation failed", + "errors": [ + { + "field": "params.sid", + "message": "sid must be a valid UUID" + } + ] } ``` @@ -581,8 +583,8 @@ Status: `404` ```json { - "status": "error", - "message": "Session not found" + "status": "error", + "message": "Session not found" } ``` @@ -602,8 +604,8 @@ Status: `200` ```json { - "status": "success", - "message": "OTP sent to your email" + "status": "success", + "message": "OTP sent to your email" } ``` @@ -613,8 +615,8 @@ Status: `409` ```json { - "status": "error", - "message": "Email is already verified" + "status": "error", + "message": "Email is already verified" } ``` @@ -624,8 +626,8 @@ Status: `502` ```json { - "status": "error", - "message": "Failed to send OTP email — try again shortly" + "status": "error", + "message": "Failed to send OTP email — try again shortly" } ``` @@ -637,7 +639,7 @@ Verifies the latest OTP for the authenticated user and marks the account email a ```json { - "otp": "123456" + "otp": "123456" } ``` @@ -647,8 +649,8 @@ Status: `200` ```json { - "status": "success", - "message": "Email verified successfully" + "status": "success", + "message": "Email verified successfully" } ``` @@ -658,8 +660,8 @@ Status: `400` ```json { - "status": "error", - "message": "Incorrect OTP — 4 attempts remaining" + "status": "error", + "message": "Incorrect OTP — 4 attempts remaining" } ``` @@ -669,8 +671,8 @@ Status: `429` ```json { - "status": "error", - "message": "Too many incorrect attempts — request a new OTP" + "status": "error", + "message": "Too many incorrect attempts — request a new OTP" } ``` @@ -680,8 +682,8 @@ Status: `400` ```json { - "status": "error", - "message": "OTP has expired or was never sent — request a new one" + "status": "error", + "message": "OTP has expired or was never sent — request a new one" } ``` @@ -691,8 +693,8 @@ Status: `429` ```json { - "status": "error", - "message": "Too many OTP verification attempts from this IP — please wait 15 minutes" + "status": "error", + "message": "Too many OTP verification attempts from this IP — please wait 15 minutes" } ``` @@ -702,8 +704,8 @@ Status: `429` ```json { - "status": "error", - "message": "OTP verification is temporarily unavailable" + "status": "error", + "message": "OTP verification is temporarily unavailable" } ``` @@ -717,20 +719,21 @@ 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 - } + "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. +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 @@ -740,10 +743,10 @@ Accepts a Google ID token issued on the client, verifies it server-side, then ei ```json { - "idToken": "google-id-token-from-client", - "role": "student", - "fullName": "Priya Sharma", - "businessName": "Sunrise PG" + "idToken": "google-id-token-from-client", + "role": "student", + "fullName": "Priya Sharma", + "businessName": "Sunrise PG" } ``` @@ -755,16 +758,16 @@ 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" - } + "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" + } } ``` @@ -774,16 +777,16 @@ 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" - } + "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" + } } ``` @@ -795,9 +798,9 @@ Request: ```json { - "idToken": "google-id-token-from-client", - "role": "student", - "fullName": "Priya Sharma" + "idToken": "google-id-token-from-client", + "role": "student", + "fullName": "Priya Sharma" } ``` @@ -805,16 +808,16 @@ 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" - } + "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" + } } ``` @@ -824,10 +827,10 @@ Request: ```json { - "idToken": "google-id-token-from-client", - "role": "pg_owner", - "fullName": "Rohan Mehta", - "businessName": "Sunrise PG" + "idToken": "google-id-token-from-client", + "role": "pg_owner", + "fullName": "Rohan Mehta", + "businessName": "Sunrise PG" } ``` @@ -835,16 +838,16 @@ 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" - } + "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" + } } ``` @@ -854,8 +857,8 @@ Status: `400` ```json { - "status": "error", - "message": "Role is required for new account registration via Google" + "status": "error", + "message": "Role is required for new account registration via Google" } ``` @@ -865,8 +868,8 @@ Status: `400` ```json { - "status": "error", - "message": "Full name is required for new account registration" + "status": "error", + "message": "Full name is required for new account registration" } ``` @@ -876,8 +879,8 @@ Status: `400` ```json { - "status": "error", - "message": "Business name is required for PG owner registration" + "status": "error", + "message": "Business name is required for PG owner registration" } ``` @@ -887,8 +890,8 @@ Status: `503` ```json { - "status": "error", - "message": "Google OAuth is not configured on this server" + "status": "error", + "message": "Google OAuth is not configured on this server" } ``` @@ -898,8 +901,8 @@ Status: `401` ```json { - "status": "error", - "message": "Invalid or expired Google token" + "status": "error", + "message": "Invalid or expired Google token" } ``` @@ -909,8 +912,8 @@ Status: `400` ```json { - "status": "error", - "message": "Google account does not have a verified email address" + "status": "error", + "message": "Google account does not have a verified email address" } ``` @@ -920,8 +923,8 @@ Status: `409` ```json { - "status": "error", - "message": "This Google account is already linked to another user" + "status": "error", + "message": "This Google account is already linked to another user" } ``` @@ -931,25 +934,25 @@ Status: `409` ```json { - "status": "error", - "message": "This account is already linked to a different Google account" + "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 | +| 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 @@ -957,3 +960,6 @@ Status: `409` - 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. +- JWT TTL env values accept both duration strings (`15m`, `7d`) and numeric seconds (`900`, `604800`). +- Legacy refresh tokens that predate per-session `sid` are migrated transparently on first successful refresh; callers + do not need any migration logic. diff --git a/docs/api/connections.md b/docs/api/connections.md index d2c36f6..c33bad8 100644 --- a/docs/api/connections.md +++ b/docs/api/connections.md @@ -1,6 +1,9 @@ # Connections API -Connections are created internally when an interest request is accepted. API consumers never create connections directly. +Shared conventions: [conventions.md](./conventions.md) + +Connections are created internally when an interest request is accepted. API consumers never create connections +directly. ## `GET /connections/me` @@ -10,11 +13,11 @@ Returns the authenticated user's connection dashboard. - Auth required: Yes - Query params: - - `confirmationStatus` - - `connectionType` - - `limit` - - `cursorTime` - - `cursorId` + - `confirmationStatus` + - `connectionType` + - `limit` + - `cursorTime` + - `cursorId` ### Scenario: fetch my connections with filters @@ -28,36 +31,36 @@ 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 - } + "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 + } } ``` @@ -71,34 +74,34 @@ 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 - } - } + "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 + } + } } ``` @@ -108,8 +111,8 @@ Status: `404` ```json { - "status": "error", - "message": "Connection not found" + "status": "error", + "message": "Connection not found" } ``` @@ -125,14 +128,14 @@ 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" - } + "status": "success", + "data": { + "connectionId": "77777777-7777-4777-8777-777777777777", + "initiatorConfirmed": true, + "counterpartConfirmed": false, + "confirmationStatus": "pending", + "updatedAt": "2026-04-11T12:00:00.000Z" + } } ``` @@ -142,14 +145,14 @@ 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" - } + "status": "success", + "data": { + "connectionId": "77777777-7777-4777-8777-777777777777", + "initiatorConfirmed": true, + "counterpartConfirmed": true, + "confirmationStatus": "confirmed", + "updatedAt": "2026-04-12T09:00:00.000Z" + } } ``` @@ -161,8 +164,8 @@ Status: `404` ```json { - "status": "error", - "message": "Connection not found" + "status": "error", + "message": "Connection not found" } ``` @@ -170,4 +173,5 @@ Status: `404` - 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. +- 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 index ce4d1f7..483cea8 100644 --- a/docs/api/conventions.md +++ b/docs/api/conventions.md @@ -1,6 +1,7 @@ # Roomies API Conventions -This document centralizes behavior shared across the API so the feature docs can stay focused on feature-specific contracts and scenarios. +This document centralizes behavior shared across the API so the feature docs can stay focused on feature-specific +contracts and scenarios. ## Success Envelopes @@ -10,8 +11,8 @@ Success with data: ```json { - "status": "success", - "data": {} + "status": "success", + "data": {} } ``` @@ -19,8 +20,8 @@ Success with message: ```json { - "status": "success", - "message": "OTP sent to your email" + "status": "success", + "message": "OTP sent to your email" } ``` @@ -28,11 +29,11 @@ Accepted-for-processing response: ```json { - "status": "success", - "data": { - "photoId": "6fd7cf0b-d6a7-4f31-bd0a-fdbcf5a5ef2e", - "status": "processing" - } + "status": "success", + "data": { + "photoId": "6fd7cf0b-d6a7-4f31-bd0a-fdbcf5a5ef2e", + "status": "processing" + } } ``` @@ -42,8 +43,8 @@ Simple operational error: ```json { - "status": "error", - "message": "Listing not found" + "status": "error", + "message": "Listing not found" } ``` @@ -51,14 +52,14 @@ Validation error: ```json { - "status": "error", - "message": "Validation failed", - "errors": [ - { - "field": "body.password", - "message": "Password must contain at least one letter and one number" - } - ] + "status": "error", + "message": "Validation failed", + "errors": [ + { + "field": "body.password", + "message": "Password must contain at least one letter and one number" + } + ] } ``` @@ -72,8 +73,8 @@ No token provided: ```json { - "status": "error", - "message": "No token provided" + "status": "error", + "message": "No token provided" } ``` @@ -81,8 +82,8 @@ Invalid token: ```json { - "status": "error", - "message": "Invalid token" + "status": "error", + "message": "Invalid token" } ``` @@ -90,8 +91,8 @@ Expired bearer token: ```json { - "status": "error", - "message": "Token has expired" + "status": "error", + "message": "Token has expired" } ``` @@ -99,8 +100,8 @@ Expired session during cookie auth or silent-refresh failure: ```json { - "status": "error", - "message": "Session expired" + "status": "error", + "message": "Session expired" } ``` @@ -108,8 +109,8 @@ User no longer exists: ```json { - "status": "error", - "message": "User not found" + "status": "error", + "message": "User not found" } ``` @@ -117,8 +118,8 @@ Inactive account: ```json { - "status": "error", - "message": "Account is suspended" + "status": "error", + "message": "Account is suspended" } ``` @@ -126,8 +127,8 @@ Some auth flows return a slightly different message: ```json { - "status": "error", - "message": "Account inactive" + "status": "error", + "message": "Account inactive" } ``` @@ -141,8 +142,8 @@ Standard authorization failure: ```json { - "status": "error", - "message": "Forbidden" + "status": "error", + "message": "Forbidden" } ``` @@ -150,12 +151,13 @@ Privacy-preserving not found: ```json { - "status": "error", - "message": "Connection not found" + "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. +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: @@ -169,7 +171,8 @@ Some routes are guarded by middleware that can short-circuit before the controll - 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). +- 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. @@ -179,8 +182,8 @@ Auth route rate limiter example: ```json { - "status": "error", - "message": "Too many requests, please try again later." + "status": "error", + "message": "Too many requests, please try again later." } ``` @@ -188,8 +191,8 @@ OTP verify IP throttle: ```json { - "status": "error", - "message": "Too many OTP verification attempts from this IP — please wait 15 minutes" + "status": "error", + "message": "Too many OTP verification attempts from this IP — please wait 15 minutes" } ``` @@ -197,10 +200,10 @@ 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" + "status": "error", + "message": "Free contact reveal limit reached. Please log in or sign up to continue.", + "code": "CONTACT_REVEAL_LIMIT_REACHED", + "loginRedirect": "/login/signup" } ``` @@ -210,8 +213,8 @@ Unsupported file type: ```json { - "status": "error", - "message": "Unsupported file type: image/gif. Accepted types: JPEG, PNG, WebP" + "status": "error", + "message": "Unsupported file type: image/gif. Accepted types: JPEG, PNG, WebP" } ``` @@ -219,8 +222,8 @@ Unexpected multipart field: ```json { - "status": "error", - "message": "Unexpected file field. Use field name 'photo'" + "status": "error", + "message": "Unexpected file field. Use field name 'photo'" } ``` @@ -228,8 +231,8 @@ Missing file: ```json { - "status": "error", - "message": "No file uploaded — send the image under the field name 'photo'" + "status": "error", + "message": "No file uploaded — send the image under the field name 'photo'" } ``` @@ -237,8 +240,8 @@ File too large: ```json { - "status": "error", - "message": "File is too large. Maximum allowed size is 10MB" + "status": "error", + "message": "File is too large. Maximum allowed size is 10MB" } ``` @@ -246,8 +249,8 @@ Queue temporarily unavailable after the file is uploaded to staging: ```json { - "status": "error", - "message": "Photo processing queue is temporarily unavailable. Please retry." + "status": "error", + "message": "Photo processing queue is temporarily unavailable. Please retry." } ``` @@ -257,18 +260,18 @@ 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" - } - } + "status": "success", + "data": { + "items": [ + { + "id": "example" + } + ], + "nextCursor": { + "cursorTime": "2026-04-11T09:30:00.000Z", + "cursorId": "0ab4fca0-86a5-4c85-b668-1b6dfb4bd0f8" + } + } } ``` @@ -276,11 +279,11 @@ No next page: ```json { - "status": "success", - "data": { - "items": [], - "nextCursor": null - } + "status": "success", + "data": { + "items": [], + "nextCursor": null + } } ``` @@ -288,14 +291,14 @@ 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" - } - ] + "status": "error", + "message": "Validation failed", + "errors": [ + { + "field": "query.cursorTime", + "message": "cursorTime and cursorId must be provided together" + } + ] } ``` @@ -313,6 +316,14 @@ The feature docs use consistent sample values. - Rating: `88888888-8888-4888-8888-888888888888` - Report: `99999999-9999-4999-8999-999999999999` - Session ID: `aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa` +- Alternate session ID: `bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb` +- Student profile ID: `bc4cc5f8-93cb-4700-80f4-4f404410242f` +- PG owner profile ID: `abf62d6a-2783-4dd1-a808-72d758fb18da` +- Institution ID: `a11f71df-dbc0-4b7c-a9a8-9718eebdd9d1` +- Verification request ID: `c478b4c9-f4cf-4d58-b577-c5bca50d6f34` +- Amenity IDs: `2db2f8fc-d90c-47a1-aebb-c6fa9ea4450a`, `eec6f390-2906-4d50-bf26-4f937833c6f8` +- Photo IDs: `6fd7cf0b-d6a7-4f31-bd0a-fdbcf5a5ef2e`, `bcf8f73b-b1bd-4e30-9a7e-a82a18252d28` +- Notification IDs: `31f907ca-df84-4f9d-9c4a-fd67eb1dfe7d`, `efb6d828-3725-4caf-aa15-5a8ec749f590` Representative emails and locations: diff --git a/docs/api/health.md b/docs/api/health.md index 78068a6..a38e33f 100644 --- a/docs/api/health.md +++ b/docs/api/health.md @@ -1,5 +1,7 @@ # Health API +Shared conventions: [conventions.md](./conventions.md) + ## Endpoint ### `GET /health` @@ -19,89 +21,104 @@ Healthy response: ```json { - "status": "ok", - "timestamp": "2026-04-11T09:40:00.000Z", - "services": { - "database": "ok", - "redis": "ok" - } + "status": "ok", + "timestamp": "2026-04-11T09:40:00.000Z", + "services": { + "database": "ok", + "redis": "ok" + } } ``` ## Scenarios -### All services healthy +### Scenario: all services healthy Status: `200` ```json { - "status": "ok", - "timestamp": "2026-04-11T09:40:00.000Z", - "services": { - "database": "ok", - "redis": "ok" - } + "status": "ok", + "timestamp": "2026-04-11T09:40:00.000Z", + "services": { + "database": "ok", + "redis": "ok" + } +} +``` + +### Scenario: database degraded + +Status: `503` + +```json +{ + "status": "degraded", + "timestamp": "2026-04-11T09:40:00.000Z", + "services": { + "database": "unhealthy", + "redis": "ok" + } } ``` -### Database degraded +### Scenario: database timed out Status: `503` ```json { - "status": "degraded", - "timestamp": "2026-04-11T09:40:00.000Z", - "services": { - "database": "unhealthy", - "redis": "ok" - } + "status": "degraded", + "timestamp": "2026-04-11T09:40:00.000Z", + "services": { + "database": "timeout", + "redis": "ok" + } } ``` -### Database timed out +### Scenario: redis degraded Status: `503` ```json { - "status": "degraded", - "timestamp": "2026-04-11T09:40:00.000Z", - "services": { - "database": "timeout", - "redis": "ok" - } + "status": "degraded", + "timestamp": "2026-04-11T09:40:00.000Z", + "services": { + "database": "ok", + "redis": "unhealthy" + } } ``` -### Redis degraded +### Scenario: redis timed out Status: `503` ```json { - "status": "degraded", - "timestamp": "2026-04-11T09:40:00.000Z", - "services": { - "database": "ok", - "redis": "unhealthy" - } + "status": "degraded", + "timestamp": "2026-04-11T09:40:00.000Z", + "services": { + "database": "ok", + "redis": "timeout" + } } ``` -### Both dependencies degraded +### Scenario: both dependencies degraded Status: `503` ```json { - "status": "degraded", - "timestamp": "2026-04-11T09:40:00.000Z", - "services": { - "database": "timeout", - "redis": "unhealthy" - } + "status": "degraded", + "timestamp": "2026-04-11T09:40:00.000Z", + "services": { + "database": "timeout", + "redis": "unhealthy" + } } ``` diff --git a/docs/api/interests.md b/docs/api/interests.md index 7fb4b29..88c1155 100644 --- a/docs/api/interests.md +++ b/docs/api/interests.md @@ -1,6 +1,9 @@ # 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. +Shared conventions: [conventions.md](./conventions.md) + +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` @@ -18,7 +21,7 @@ With optional message: ```json { - "message": "Hi, I can move in by 1 May and would like to visit this weekend." + "message": "Hi, I can move in by 1 May and would like to visit this weekend." } ``` @@ -28,15 +31,15 @@ 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" - } + "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" + } } ``` @@ -46,15 +49,15 @@ 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" - } + "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" + } } ``` @@ -64,8 +67,8 @@ Status: `404` ```json { - "status": "error", - "message": "Listing not found" + "status": "error", + "message": "Listing not found" } ``` @@ -75,8 +78,8 @@ Status: `422` ```json { - "status": "error", - "message": "Listing has expired and is no longer available" + "status": "error", + "message": "Listing has expired and is no longer available" } ``` @@ -86,8 +89,8 @@ Status: `422` ```json { - "status": "error", - "message": "Listing is no longer available" + "status": "error", + "message": "Listing is no longer available" } ``` @@ -97,8 +100,8 @@ Status: `422` ```json { - "status": "error", - "message": "You cannot express interest in your own listing" + "status": "error", + "message": "You cannot express interest in your own listing" } ``` @@ -108,8 +111,8 @@ Status: `409` ```json { - "status": "error", - "message": "You already have a pending or accepted interest request for this listing" + "status": "error", + "message": "You already have a pending or accepted interest request for this listing" } ``` @@ -122,10 +125,10 @@ Poster-facing dashboard for all requests on a listing. - Auth required: Yes - Listing ownership required: Yes - Query params: - - `status` - - `limit` - - `cursorTime` - - `cursorId` + - `status` + - `limit` + - `cursorTime` + - `cursorId` ### Scenario: poster views listing interests @@ -133,26 +136,26 @@ 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 - } + "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 + } } ``` @@ -164,8 +167,8 @@ Status: `403` ```json { - "status": "error", - "message": "You do not own this listing" + "status": "error", + "message": "You do not own this listing" } ``` @@ -175,8 +178,8 @@ Status: `404` ```json { - "status": "error", - "message": "Listing not found" + "status": "error", + "message": "Listing not found" } ``` @@ -190,27 +193,27 @@ 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 - } + "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 + } } ``` @@ -224,27 +227,27 @@ 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 - } - } + "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 + } + } } ``` @@ -260,8 +263,8 @@ Status: `404` ```json { - "status": "error", - "message": "Interest request not found" + "status": "error", + "message": "Interest request not found" } ``` @@ -275,7 +278,7 @@ Allowed body: ```json { - "status": "accepted" + "status": "accepted" } ``` @@ -287,7 +290,7 @@ Request: ```json { - "status": "declined" + "status": "declined" } ``` @@ -295,14 +298,14 @@ 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" - } + "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" + } } ``` @@ -312,7 +315,7 @@ Request: ```json { - "status": "withdrawn" + "status": "withdrawn" } ``` @@ -320,14 +323,14 @@ 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" - } + "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" + } } ``` @@ -337,7 +340,7 @@ Request: ```json { - "status": "accepted" + "status": "accepted" } ``` @@ -345,16 +348,16 @@ 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 - } + "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 + } } ``` @@ -376,8 +379,8 @@ Status: `422` ```json { - "status": "error", - "message": "Cannot accept a request with status 'declined'" + "status": "error", + "message": "Cannot accept a request with status 'declined'" } ``` @@ -387,8 +390,8 @@ Status: `409` ```json { - "status": "error", - "message": "Interest request cannot be withdrawn — current status is 'accepted'" + "status": "error", + "message": "Interest request cannot be withdrawn — current status is 'accepted'" } ``` @@ -398,8 +401,8 @@ Status: `403` ```json { - "status": "error", - "message": "You are not authorised to perform this action" + "status": "error", + "message": "You are not authorised to perform this action" } ``` @@ -409,8 +412,8 @@ Status: `422` ```json { - "status": "error", - "message": "Listing has expired and is no longer available" + "status": "error", + "message": "Listing has expired and is no longer available" } ``` @@ -420,8 +423,8 @@ Status: `422` ```json { - "status": "error", - "message": "Listing is no longer available" + "status": "error", + "message": "Listing is no longer available" } ``` @@ -431,8 +434,8 @@ Status: `409` ```json { - "status": "error", - "message": "Listing status has already changed — please refresh" + "status": "error", + "message": "Listing status has already changed — please refresh" } ``` @@ -442,21 +445,21 @@ Status: `400` ```json { - "status": "error", - "message": "Invalid target status: expired" + "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 | +| 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 diff --git a/docs/api/listings.api.md b/docs/api/listings.api.md index f91a1eb..ea98bf5 100644 --- a/docs/api/listings.api.md +++ b/docs/api/listings.api.md @@ -1,5 +1,7 @@ # Listings API +Shared conventions: [conventions.md](./conventions.md) + This document covers listing discovery, CRUD, lifecycle transitions, preferences, saved listings, and photo processing. Listings are role-sensitive: diff --git a/docs/api/notifications.md b/docs/api/notifications.md index 2d7327b..2cc9a69 100644 --- a/docs/api/notifications.md +++ b/docs/api/notifications.md @@ -2,6 +2,28 @@ Notifications are created asynchronously by the worker. The HTTP API only reads them and marks them as read. +Shared conventions: [conventions.md](./conventions.md) + +## Notification types (worker source of truth) + +The `NOTIFICATION_MESSAGES` map in `src/workers/notificationWorker.js` is authoritative. + +| Type | Message | Status | +| ---------------------------- | -------------------------------------------------- | --------------- | +| `interest_request_received` | Someone expressed interest in your listing | ACTIVE | +| `interest_request_accepted` | Your interest request was accepted | ACTIVE | +| `interest_request_declined` | Your interest request was declined | ACTIVE | +| `interest_request_withdrawn` | An interest request was withdrawn | ACTIVE | +| `connection_confirmed` | Your connection has been confirmed by both parties | ACTIVE | +| `rating_received` | You received a new rating | ACTIVE | +| `listing_expiring` | One of your listings is expiring soon | ACTIVE | +| `listing_expired` | One of your listings has expired | ACTIVE | +| `listing_filled` | A listing has been marked as filled | ACTIVE | +| `verification_approved` | Your verification request was approved | PLANNED emitter | +| `verification_rejected` | Your verification request was rejected | PLANNED emitter | +| `new_message` | You have a new message | PLANNED emitter | +| `connection_requested` | You have a new connection request | PLANNED emitter | + ## `GET /notifications` Returns the authenticated user's notification feed. @@ -10,10 +32,10 @@ Returns the authenticated user's notification feed. - Auth required: Yes - Query params: - - `isRead` - - `limit` - - `cursorTime` - - `cursorId` + - `isRead` + - `limit` + - `cursorTime` + - `cursorId` ### Scenario: fetch feed with pagination @@ -21,22 +43,22 @@ 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 - } + "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 + } } ``` @@ -74,10 +96,10 @@ Status: `200` ```json { - "status": "success", - "data": { - "count": 3 - } + "status": "success", + "data": { + "count": 3 + } } ``` @@ -91,7 +113,7 @@ Request: ```json { - "all": true + "all": true } ``` @@ -99,10 +121,10 @@ Success: ```json { - "status": "success", - "data": { - "updated": 3 - } + "status": "success", + "data": { + "updated": 3 + } } ``` @@ -112,10 +134,7 @@ Request: ```json { - "notificationIds": [ - "31f907ca-df84-4f9d-9c4a-fd67eb1dfe7d", - "efb6d828-3725-4caf-aa15-5a8ec749f590" - ] + "notificationIds": ["31f907ca-df84-4f9d-9c4a-fd67eb1dfe7d", "efb6d828-3725-4caf-aa15-5a8ec749f590"] } ``` @@ -123,10 +142,10 @@ Success: ```json { - "status": "success", - "data": { - "updated": 2 - } + "status": "success", + "data": { + "updated": 2 + } } ``` @@ -136,14 +155,31 @@ 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" - } - ] + "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: client sends `{ "all": false }` + +Status: `400` + +```json +{ + "status": "error", + "message": "Validation failed", + "errors": [ + { + "field": "body.all", + "message": "Invalid input: expected true" + } + ] } ``` @@ -153,8 +189,8 @@ Status: `400` ```json { - "status": "error", - "message": "notificationIds must be a non-empty array when all is not true" + "status": "error", + "message": "notificationIds must be a non-empty array when all is not true" } ``` @@ -164,14 +200,14 @@ Status: `400` ```json { - "status": "error", - "message": "Validation failed", - "errors": [ - { - "field": "body.notificationIds.0", - "message": "Each notification ID must be a valid UUID" - } - ] + "status": "error", + "message": "Validation failed", + "errors": [ + { + "field": "body.notificationIds.0", + "message": "Each notification ID must be a valid UUID" + } + ] } ``` @@ -181,12 +217,13 @@ Status: `401` ```json { - "status": "error", - "message": "No token provided" + "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. +- 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 index e9949d7..3761d2c 100644 --- a/docs/api/preferences.md +++ b/docs/api/preferences.md @@ -1,12 +1,12 @@ # Preferences API -This document covers preference metadata and student self-preference management. +Shared conventions: [conventions.md](./conventions.md) -`user_preferences` is optional. A user can have zero preferences and still search listings normally. +`user_preferences` is optional. Empty preference state is represented by `[]` (never `null`). ## `GET /preferences/meta` -Returns the authenticated metadata catalog of supported preference keys and values. +Returns the current preference catalog (`preferenceMetadata`). ### Request Contract @@ -28,6 +28,58 @@ Status: `200` { "value": "non_smoker", "label": "Non-smoker" }, { "value": "smoker", "label": "Smoker" } ] + }, + { + "preferenceKey": "food_habit", + "label": "Food Habit", + "values": [ + { "value": "vegetarian", "label": "Vegetarian" }, + { "value": "non_vegetarian", "label": "Non-vegetarian" }, + { "value": "vegan", "label": "Vegan" } + ] + }, + { + "preferenceKey": "sleep_schedule", + "label": "Sleep Schedule", + "values": [ + { "value": "early_bird", "label": "Early bird" }, + { "value": "night_owl", "label": "Night owl" } + ] + }, + { + "preferenceKey": "alcohol", + "label": "Alcohol", + "values": [ + { "value": "okay", "label": "Okay" }, + { "value": "not_okay", "label": "Not okay" } + ] + }, + { + "preferenceKey": "cleanliness_level", + "label": "Cleanliness Level", + "values": [ + { "value": "low", "label": "Low" }, + { "value": "medium", "label": "Medium" }, + { "value": "high", "label": "High" } + ] + }, + { + "preferenceKey": "noise_tolerance", + "label": "Noise Tolerance", + "values": [ + { "value": "low", "label": "Low" }, + { "value": "medium", "label": "Medium" }, + { "value": "high", "label": "High" } + ] + }, + { + "preferenceKey": "guest_policy", + "label": "Guest Policy", + "values": [ + { "value": "rarely", "label": "Rarely" }, + { "value": "occasionally", "label": "Occasionally" }, + { "value": "frequently", "label": "Frequently" } + ] } ] } @@ -36,14 +88,14 @@ Status: `200` ## `GET /students/:userId/preferences` -Returns the current self preferences for the authenticated student. +Owner-only read of the student's current profile preferences. ### Request Contract - Auth required: Yes - Owner-only: `req.user.userId` must match `:userId` -### Scenario: no preferences configured yet +### Scenario: no preferences configured Status: `200` @@ -54,7 +106,7 @@ Status: `200` } ``` -### Scenario: has preferences +### Scenario: preferences exist Status: `200` @@ -62,40 +114,68 @@ Status: `200` { "status": "success", "data": [ - { "preferenceKey": "smoking", "preferenceValue": "non_smoker" }, - { "preferenceKey": "food_habit", "preferenceValue": "vegetarian" } + { "preferenceKey": "food_habit", "preferenceValue": "vegetarian" }, + { "preferenceKey": "smoking", "preferenceValue": "non_smoker" } ] } ``` +### Scenario: caller reads another user's preferences + +Status: `403` + +```json +{ + "status": "error", + "message": "Forbidden" +} +``` + ## `PUT /students/:userId/preferences` -Replaces the full preference set for the authenticated student. +Full replace semantics. -- Empty `preferences` array is valid and clears all preferences. -- Duplicate keys are silently de-duplicated using last-write-wins semantics. +- Empty `preferences` clears all rows. +- Duplicate keys are deduped by `dedupePreferencesByKey` with **last-write-wins**. -### Request body +### Scenario: update with unique keys + +Status: `200` + +```json +{ + "status": "success", + "data": [ + { "preferenceKey": "sleep_schedule", "preferenceValue": "early_bird" }, + { "preferenceKey": "smoking", "preferenceValue": "non_smoker" } + ] +} +``` + +### Scenario: duplicate preference key submitted — last value wins, no error + +Request: ```json { "preferences": [ { "preferenceKey": "smoking", "preferenceValue": "non_smoker" }, - { "preferenceKey": "sleep_schedule", "preferenceValue": "early_bird" } + { "preferenceKey": "smoking", "preferenceValue": "smoker" } ] } ``` -### Scenario: clear all - -Request body: +Status: `200` ```json { - "preferences": [] + "status": "success", + "data": [{ "preferenceKey": "smoking", "preferenceValue": "smoker" }] } ``` +### Scenario: clear all + Status: `200` ```json @@ -121,14 +201,3 @@ Status: `400` ] } ``` - -### 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 index 05bf3c9..83b6640 100644 --- a/docs/api/profiles-and-contact.md +++ b/docs/api/profiles-and-contact.md @@ -1,16 +1,20 @@ # Profiles and Contact Reveal API -This document covers student profiles, PG owner profiles, contact reveal behavior, and PG owner verification document submission. +Shared conventions: [conventions.md](./conventions.md) + +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. +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 + - `userId` must be a UUID ### Scenario: fetch existing student profile as another authenticated user @@ -18,25 +22,25 @@ 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" - } + "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" + } } ``` @@ -46,25 +50,25 @@ 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" - } + "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" + } } ``` @@ -74,8 +78,8 @@ Status: `404` ```json { - "status": "error", - "message": "Student profile not found" + "status": "error", + "message": "Student profile not found" } ``` @@ -89,7 +93,7 @@ Minimal valid body: ```json { - "bio": "Need a flatmate who is okay with early classes." + "bio": "Need a flatmate who is okay with early classes." } ``` @@ -97,12 +101,12 @@ 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" + "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" } ``` @@ -112,17 +116,17 @@ 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" - } + "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" + } } ``` @@ -132,8 +136,8 @@ Status: `403` ```json { - "status": "error", - "message": "Forbidden" + "status": "error", + "message": "Forbidden" } ``` @@ -143,8 +147,8 @@ Status: `400` ```json { - "status": "error", - "message": "No valid fields provided for update" + "status": "error", + "message": "No valid fields provided for update" } ``` @@ -156,8 +160,8 @@ Reveals student contact information using the guest/unverified/verified contact - Auth required: Optional - Auth transport accepted: - - Cookie mode (`accessToken` cookie) - - Bearer mode (`Authorization: Bearer `) + - 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` @@ -168,12 +172,12 @@ Status: `200` ```json { - "status": "success", - "data": { - "user_id": "11111111-1111-4111-8111-111111111111", - "full_name": "Priya Sharma", - "email": "priya@iitb.ac.in" - } + "status": "success", + "data": { + "user_id": "11111111-1111-4111-8111-111111111111", + "full_name": "Priya Sharma", + "email": "priya@iitb.ac.in" + } } ``` @@ -183,12 +187,12 @@ Status: `200` ```json { - "status": "success", - "data": { - "user_id": "11111111-1111-4111-8111-111111111111", - "full_name": "Priya Sharma", - "email": "priya@iitb.ac.in" - } + "status": "success", + "data": { + "user_id": "11111111-1111-4111-8111-111111111111", + "full_name": "Priya Sharma", + "email": "priya@iitb.ac.in" + } } ``` @@ -198,17 +202,18 @@ 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" - } + "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. +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 @@ -216,10 +221,10 @@ 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" + "status": "error", + "message": "Free contact reveal limit reached. Please log in or sign up to continue.", + "code": "CONTACT_REVEAL_LIMIT_REACHED", + "loginRedirect": "/login/signup" } ``` @@ -229,20 +234,21 @@ Status: `400` ```json { - "status": "error", - "message": "Validation failed", - "errors": [ - { - "field": "params.userId", - "message": "Invalid user ID" - } - ] + "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. +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 @@ -250,23 +256,23 @@ 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" - } + "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" + } } ``` @@ -276,8 +282,8 @@ Status: `404` ```json { - "status": "error", - "message": "PG owner profile not found" + "status": "error", + "message": "PG owner profile not found" } ``` @@ -289,11 +295,11 @@ Updates the authenticated PG owner profile. ```json { - "businessName": "Sunrise PG Premium", - "ownerFullName": "Rohan Mehta", - "businessDescription": "Secure PG with Wi-Fi, meals, and weekly cleaning.", - "businessPhone": "+919876543210", - "operatingSince": 2018 + "businessName": "Sunrise PG Premium", + "ownerFullName": "Rohan Mehta", + "businessDescription": "Secure PG with Wi-Fi, meals, and weekly cleaning.", + "businessPhone": "+919876543210", + "operatingSince": 2018 } ``` @@ -303,16 +309,16 @@ 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 - } + "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 + } } ``` @@ -322,8 +328,8 @@ Status: `403` ```json { - "status": "error", - "message": "Forbidden" + "status": "error", + "message": "Forbidden" } ``` @@ -333,21 +339,22 @@ Status: `400` ```json { - "status": "error", - "message": "No valid fields provided for update" + "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`. +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 `) + - Cookie mode (`accessToken` cookie) + - Bearer mode (`Authorization: Bearer `) - Route method: `POST` - Response header: `Cache-Control: no-store` @@ -357,13 +364,13 @@ 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" - } + "status": "success", + "data": { + "user_id": "22222222-2222-4222-8222-222222222222", + "owner_full_name": "Rohan Mehta", + "business_name": "Sunrise PG", + "email": "owner@sunrisepg.in" + } } ``` @@ -373,14 +380,14 @@ 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" - } + "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" + } } ``` @@ -390,10 +397,10 @@ 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" + "status": "error", + "message": "Free contact reveal limit reached. Please log in or sign up to continue.", + "code": "CONTACT_REVEAL_LIMIT_REACHED", + "loginRedirect": "/login/signup" } ``` @@ -406,7 +413,7 @@ Returns the student's preference profile used for compatibility-aware discovery. - Auth required: Yes - Ownership required: caller must match `:userId` - Path param: - - `userId` must be a UUID + - `userId` must be a UUID ### Scenario: fetch preferences @@ -414,17 +421,17 @@ Status: `200` ```json { - "status": "success", - "data": [ - { - "preferenceKey": "food_habit", - "preferenceValue": "vegetarian" - }, - { - "preferenceKey": "smoking", - "preferenceValue": "no" - } - ] + "status": "success", + "data": [ + { + "preferenceKey": "food_habit", + "preferenceValue": "vegetarian" + }, + { + "preferenceKey": "smoking", + "preferenceValue": "no" + } + ] } ``` @@ -434,8 +441,8 @@ Status: `403` ```json { - "status": "error", - "message": "Forbidden" + "status": "error", + "message": "Forbidden" } ``` @@ -447,16 +454,16 @@ Updates the student's preference profile. ```json { - "preferences": [ - { - "preferenceKey": "food_habit", - "preferenceValue": "eggetarian" - }, - { - "preferenceKey": "sleep_schedule", - "preferenceValue": "late_night" - } - ] + "preferences": [ + { + "preferenceKey": "food_habit", + "preferenceValue": "eggetarian" + }, + { + "preferenceKey": "sleep_schedule", + "preferenceValue": "late_night" + } + ] } ``` @@ -466,17 +473,17 @@ Status: `200` ```json { - "status": "success", - "data": [ - { - "preferenceKey": "food_habit", - "preferenceValue": "eggetarian" - }, - { - "preferenceKey": "sleep_schedule", - "preferenceValue": "late_night" - } - ] + "status": "success", + "data": [ + { + "preferenceKey": "food_habit", + "preferenceValue": "eggetarian" + }, + { + "preferenceKey": "sleep_schedule", + "preferenceValue": "late_night" + } + ] } ``` @@ -486,14 +493,14 @@ Status: `400` ```json { - "status": "error", - "message": "Validation failed", - "errors": [ - { - "field": "body.preferences", - "message": "Invalid input: expected array, received undefined" - } - ] + "status": "error", + "message": "Validation failed", + "errors": [ + { + "field": "body.preferences", + "message": "Invalid input: expected array, received undefined" + } + ] } ``` @@ -505,8 +512,8 @@ Submits a verification document for PG owner approval. ```json { - "documentType": "property_document", - "documentUrl": "https://storage.example.com/verification/owner-property-proof.pdf" + "documentType": "property_document", + "documentUrl": "https://storage.example.com/verification/owner-property-proof.pdf" } ``` @@ -516,14 +523,14 @@ 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" - } + "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" + } } ``` @@ -533,8 +540,8 @@ Status: `403` ```json { - "status": "error", - "message": "Forbidden" + "status": "error", + "message": "Forbidden" } ``` @@ -544,8 +551,8 @@ Status: `404` ```json { - "status": "error", - "message": "PG owner profile not found" + "status": "error", + "message": "PG owner profile not found" } ``` @@ -555,27 +562,29 @@ Status: `409` ```json { - "status": "error", - "message": "You already have a pending verification request" + "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 | +| 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 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. +- 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 index 932bfa9..1fc974d 100644 --- a/docs/api/properties.md +++ b/docs/api/properties.md @@ -1,6 +1,9 @@ # 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. +Shared conventions: [conventions.md](./conventions.md) + +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` @@ -11,9 +14,9 @@ Lists properties owned by the authenticated PG owner. - Auth required: Yes - Role required: `pg_owner` - Query params: - - `limit` - - `cursorTime` - - `cursorId` + - `limit` + - `cursorTime` + - `cursorId` ### Scenario: list own properties @@ -21,26 +24,26 @@ 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 - } + "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 + } } ``` @@ -50,8 +53,8 @@ Status: `403` ```json { - "status": "error", - "message": "Forbidden" + "status": "error", + "message": "Forbidden" } ``` @@ -63,22 +66,19 @@ Creates a property for a verified PG owner. ```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" - ] + "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"] } ``` @@ -88,34 +88,34 @@ 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" - } - ] - } + "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" + } + ] + } } ``` @@ -125,8 +125,8 @@ Status: `403` ```json { - "status": "error", - "message": "Forbidden" + "status": "error", + "message": "Forbidden" } ``` @@ -136,8 +136,8 @@ Status: `403` ```json { - "status": "error", - "message": "PG owner must be verified to perform this action" + "status": "error", + "message": "PG owner must be verified to perform this action" } ``` @@ -147,18 +147,18 @@ 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" - } - ] + "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" + } + ] } ``` @@ -172,36 +172,36 @@ 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" - } - ] - } + "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" + } + ] + } } ``` @@ -211,8 +211,8 @@ Status: `404` ```json { - "status": "error", - "message": "Property not found" + "status": "error", + "message": "Property not found" } ``` @@ -224,11 +224,9 @@ Updates a property owned by the authenticated PG owner. ```json { - "description": "Now includes weekly room cleaning and breakfast.", - "landmark": "Near Phoenix Marketcity main gate", - "amenityIds": [ - "2db2f8fc-d90c-47a1-aebb-c6fa9ea4450a" - ] + "description": "Now includes weekly room cleaning and breakfast.", + "landmark": "Near Phoenix Marketcity main gate", + "amenityIds": ["2db2f8fc-d90c-47a1-aebb-c6fa9ea4450a"] } ``` @@ -238,12 +236,12 @@ 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" - } + "status": "success", + "data": { + "property_id": "44444444-4444-4444-8444-444444444444", + "description": "Now includes weekly room cleaning and breakfast.", + "landmark": "Near Phoenix Marketcity main gate" + } } ``` @@ -253,8 +251,8 @@ Status: `400` ```json { - "status": "error", - "message": "No valid fields provided for update" + "status": "error", + "message": "No valid fields provided for update" } ``` @@ -264,8 +262,8 @@ Status: `404` ```json { - "status": "error", - "message": "Property not found" + "status": "error", + "message": "Property not found" } ``` @@ -279,11 +277,11 @@ Status: `200` ```json { - "status": "success", - "data": { - "propertyId": "44444444-4444-4444-8444-444444444444", - "deleted": true - } + "status": "success", + "data": { + "propertyId": "44444444-4444-4444-8444-444444444444", + "deleted": true + } } ``` @@ -293,8 +291,8 @@ Status: `409` ```json { - "status": "error", - "message": "Deactivate or remove all active listings before deleting this property" + "status": "error", + "message": "Deactivate or remove all active listings before deleting this property" } ``` @@ -304,13 +302,14 @@ Status: `404` ```json { - "status": "error", - "message": "Property not found" + "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. +- 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 index 7b302b9..84d3814 100644 --- a/docs/api/ratings-and-reports.md +++ b/docs/api/ratings-and-reports.md @@ -1,6 +1,9 @@ # Ratings and Reports API -Ratings are the reputation layer built on top of confirmed connections. Reports are the moderation layer for disputed ratings. +Shared conventions: [conventions.md](./conventions.md) + +Ratings are the reputation layer built on top of confirmed connections. Reports are the moderation layer for disputed +ratings. ## Ratings @@ -14,10 +17,10 @@ Minimal user-rating request: ```json { - "connectionId": "77777777-7777-4777-8777-777777777777", - "revieweeType": "user", - "revieweeId": "22222222-2222-4222-8222-222222222222", - "overallScore": 5 + "connectionId": "77777777-7777-4777-8777-777777777777", + "revieweeType": "user", + "revieweeId": "22222222-2222-4222-8222-222222222222", + "overallScore": 5 } ``` @@ -25,15 +28,15 @@ 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." + "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." } ``` @@ -43,11 +46,11 @@ Status: `201` ```json { - "status": "success", - "data": { - "ratingId": "88888888-8888-4888-8888-888888888888", - "createdAt": "2026-04-12T10:00:00.000Z" - } + "status": "success", + "data": { + "ratingId": "88888888-8888-4888-8888-888888888888", + "createdAt": "2026-04-12T10:00:00.000Z" + } } ``` @@ -57,11 +60,11 @@ Status: `201` ```json { - "status": "success", - "data": { - "ratingId": "88888888-8888-4888-8888-888888888888", - "createdAt": "2026-04-12T10:00:00.000Z" - } + "status": "success", + "data": { + "ratingId": "88888888-8888-4888-8888-888888888888", + "createdAt": "2026-04-12T10:00:00.000Z" + } } ``` @@ -75,8 +78,8 @@ Status: `400` ```json { - "status": "error", - "message": "Invalid revieweeType: must be 'user' or 'property'" + "status": "error", + "message": "Invalid revieweeType: must be 'user' or 'property'" } ``` @@ -86,8 +89,8 @@ Status: `404` ```json { - "status": "error", - "message": "Reviewee user not found" + "status": "error", + "message": "Reviewee user not found" } ``` @@ -97,8 +100,8 @@ Status: `404` ```json { - "status": "error", - "message": "Reviewee property not found" + "status": "error", + "message": "Reviewee property not found" } ``` @@ -108,8 +111,8 @@ Status: `404` ```json { - "status": "error", - "message": "Connection not found" + "status": "error", + "message": "Connection not found" } ``` @@ -119,8 +122,8 @@ Status: `422` ```json { - "status": "error", - "message": "Ratings can only be submitted for confirmed connections" + "status": "error", + "message": "Ratings can only be submitted for confirmed connections" } ``` @@ -130,8 +133,8 @@ Status: `422` ```json { - "status": "error", - "message": "The reviewee is not a valid party to this connection, or you cannot rate yourself" + "status": "error", + "message": "The reviewee is not a valid party to this connection, or you cannot rate yourself" } ``` @@ -141,8 +144,8 @@ Status: `409` ```json { - "status": "error", - "message": "You have already submitted a rating for this connection and reviewee" + "status": "error", + "message": "You have already submitted a rating for this connection and reviewee" } ``` @@ -156,25 +159,25 @@ 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": [] - } + "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": [] + } } ``` @@ -184,8 +187,8 @@ Status: `404` ```json { - "status": "error", - "message": "Connection not found" + "status": "error", + "message": "Connection not found" } ``` @@ -199,26 +202,26 @@ 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 - } + "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 + } } ``` @@ -232,31 +235,31 @@ 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 - } + "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 + } } ``` @@ -270,26 +273,26 @@ 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 - } + "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 + } } ``` @@ -299,8 +302,8 @@ Status: `404` ```json { - "status": "error", - "message": "Property not found" + "status": "error", + "message": "Property not found" } ``` @@ -315,38 +318,38 @@ Lets a party to the underlying connection report a rating. - Auth required: Yes - Party-membership gate: Yes (must belong to the connection behind the rating) - Path param: - - `ratingId` must be a UUID + - `ratingId` must be a UUID - Body: - - `reason` enum: `fake | abusive | conflict_of_interest | other` - - `explanation` optional free-text context + - `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." + "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." + "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." + "reason": "conflict_of_interest", + "explanation": "The reviewer is closely connected to the property owner." } ``` ```json { - "reason": "other", - "explanation": "Additional context for the moderator." + "reason": "other", + "explanation": "Additional context for the moderator." } ``` @@ -356,15 +359,15 @@ 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" - } + "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" + } } ``` @@ -374,12 +377,13 @@ Status: `404` ```json { - "status": "error", - "message": "Rating not found or you are not a party to this connection" + "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. +Explanation: this route intentionally returns a privacy-preserving `404` so outsiders cannot probe whether a rating +exists. ### Scenario: duplicate open report @@ -387,12 +391,13 @@ Status: `409` ```json { - "status": "error", - "message": "A record with this value already exists" + "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. +Explanation: duplicate open reports hit the database unique constraint and are surfaced by the global PostgreSQL +conflict handler. ## Report Re-Submission Rule @@ -407,23 +412,25 @@ 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). + - `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. +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 | +| 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`. +- 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..f117497 100644 --- a/docs/deployment/tier0.md +++ b/docs/deployment/tier0.md @@ -6,6 +6,13 @@ > 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. + --- ## Read This First — What You're Actually Doing @@ -357,13 +364,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. @@ -484,7 +491,7 @@ ENV_FILE=.env.render DATABASE_URL=postgresql://neondb_owner:YOUR_PASSWORD@ep-YOUR-ENDPOINT.ap-southeast-1.aws.neon.tech/neondb?sslmode=require # ─── 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:YOUR_UPSTASH_PASSWORD@YOUR-ENDPOINT.upstash.io:6379 # ─── JWT ────────────────────────────────────────────────────────────────────── JWT_SECRET=YOUR_GENERATED_64_CHAR_SECRET @@ -509,10 +516,17 @@ ALLOWED_ORIGINS=http://localhost:5173 GOOGLE_CLIENT_ID=xxxxxxxxxxxxxxxxxxxx GOOGLE_CLIENT_SECRET=xxxxxxxxxxxxxxxx -# ─── 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 +557,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 +619,7 @@ 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** | +| `REDIS_URL` | `rediss://default:PASSWORD@ENDPOINT.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 | @@ -618,9 +632,17 @@ Add these variables: | `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` | `http://localhost:5173` (initial) | 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` starts as `http://localhost:5173` during backend-only rollout. After frontend deployment, update it to +production domains (comma-separated), for example: + +`https://roomies.vercel.app,https://www.roomies.in` + **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 +902,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/src/routes/pgOwner.js b/src/routes/pgOwner.js index 5d17659..eb74cb2 100644 --- a/src/routes/pgOwner.js +++ b/src/routes/pgOwner.js @@ -33,12 +33,12 @@ 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, ); From 15c7cf9f321ad0184faff0536763ab772c6eb418 Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Tue, 21 Apr 2026 00:00:59 +0530 Subject: [PATCH 09/54] feat: enhance API documentation with auth/authz matrix and service contracts --- docs/API.md | 15 ++- docs/README.md | 5 + docs/api/auth.md | 8 +- docs/api/authz-matrix.md | 189 +++++++++++++++++++++++++++++++ docs/api/conventions.md | 27 +++++ docs/api/interests.md | 6 +- docs/api/listings.api.md | 22 +++- docs/api/notifications.md | 1 + docs/api/profiles-and-contact.md | 5 +- docs/services/README.md | 58 ++++++++++ 10 files changed, 324 insertions(+), 12 deletions(-) create mode 100644 docs/api/authz-matrix.md create mode 100644 docs/services/README.md diff --git a/docs/API.md b/docs/API.md index 921f222..bb20317 100644 --- a/docs/API.md +++ b/docs/API.md @@ -11,6 +11,7 @@ This file is the API front door. Endpoint-by-endpoint contracts live in `docs/ap ## Feature Docs - [Shared conventions](./api/conventions.md) +- [Auth/Authz matrix](./api/authz-matrix.md) - [Auth](./api/auth.md) - [Profiles and contact reveal](./api/profiles-and-contact.md) - [Properties](./api/properties.md) @@ -20,9 +21,21 @@ This file is the API front door. Endpoint-by-endpoint contracts live in `docs/ap - [Notifications](./api/notifications.md) - [Ratings and reports](./api/ratings-and-reports.md) - [Preferences](./api/preferences.md) -- [Admin](./api/admin.md) +- [Admin](./api/admin.md) _(currently not mounted in this workspace; calls return 404)_ - [Health](./api/health.md) +## Service-owned Documentation Model + +To reduce drift, each backend service now has a dedicated service contract page in `docs/services/README.md` that maps: + +- route entrypoints (`src/routes/*`) +- validation contracts (`src/validators/*`) +- business rules (`src/services/*`) +- worker side effects (`src/workers/*`) + +API consumers should still start with feature docs in `docs/api/*`, then use service docs when they need +implementation-backed edge-case behavior. + ## Response Envelopes Success with data: diff --git a/docs/README.md b/docs/README.md index b554860..704eabc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,7 @@ Feature-level API documentation lives in `docs/api/`. - [api/conventions.md](./api/conventions.md) — shared response envelopes, auth failures, pagination, and example conventions +- [api/authz-matrix.md](./api/authz-matrix.md) — route-level + service-level authentication and authorization matrix - [api/auth.md](./api/auth.md) — registration, login, refresh, OTP, sessions, logout, and Google OAuth flows - [api/profiles-and-contact.md](./api/profiles-and-contact.md) — student and PG owner profiles, contact reveal, and verification document submission @@ -35,6 +36,10 @@ Feature-level API documentation lives in `docs/api/`. - [api/admin.md](./api/admin.md) — admin surface scenarios (and current mount status) - [api/health.md](./api/health.md) — dependency health probe behavior +## Service Contracts + +- [services/README.md](./services/README.md) — service-by-service ownership map linking routes, validators, service logic, and workers + ## Maintenance Rules - Treat the route files in `src/routes/` as the authoritative endpoint list. diff --git a/docs/api/auth.md b/docs/api/auth.md index 27392fd..6fb10bf 100644 --- a/docs/api/auth.md +++ b/docs/api/auth.md @@ -399,7 +399,7 @@ Status: `401` ## `POST /auth/logout` -Revokes a session using the refresh token from the body or cookie. +Revokes a session using the refresh token from the body or cookie. This endpoint intentionally does **not** require `authenticate`, so clients with expired access tokens can still revoke refresh-token sessions. ### Request @@ -448,7 +448,7 @@ Status: `401` ## `POST /auth/logout/current` -Revokes the currently authenticated session only. +Revokes the currently authenticated session only. This endpoint **does** require `authenticate` and is session-scoped: the refresh token must belong to the same authenticated user/session context. ### Scenario: current-session logout succeeds @@ -595,8 +595,8 @@ Sends an email OTP to the authenticated user. ### Request Contract - Auth required: Yes -- Rate limited: Yes, stricter OTP limiter -- Body: none +- Rate limited: Yes, OTP limiter (**5 requests / 15 minutes**) +- Body: none (if a body is sent, it is ignored) ### Scenario: OTP send succeeds diff --git a/docs/api/authz-matrix.md b/docs/api/authz-matrix.md new file mode 100644 index 0000000..7125cd1 --- /dev/null +++ b/docs/api/authz-matrix.md @@ -0,0 +1,189 @@ +# Authentication & Authorization Matrix + +Shared conventions: [conventions.md](./conventions.md) + +This page is the **single auth/authz reference** for the mounted API surface in this workspace. It documents both: + +1. **Route-level enforcement** (middleware in `src/routes/*`), and +2. **Service-level enforcement** (ownership, party checks, verification checks, privacy-preserving 404 behavior). + +> Source of truth order: `src/routes/*` → `src/validators/*` → `src/services/*`. + +## Auth legend + +- **Public**: no token required. +- **Optional auth**: token optional; behavior changes when authenticated. +- **Authenticated**: valid access token required. +- **Role gate**: `authorize("role")` middleware. +- **Service gate**: authorization enforced inside service query logic/business rules. + +--- + +## Optional-authenticate exception semantics + +For routes using `optionalAuthenticate`, token handling is intentionally non-blocking: + +- If no token is sent, request continues as guest. +- If token is malformed/expired/invalid, request still continues as guest (no `401` from this middleware). +- If token is valid but user is missing/inactive, request still continues as guest. +- Only a valid active user token populates `req.user`. + +This behavior affects: + +- `GET /listings` +- `GET /listings/:listingId` +- `GET /students/:userId/contact/reveal` +- `POST /pg-owners/:userId/contact/reveal` + +## Route exceptions and special-case behaviors + +These are intentional exceptions to common auth patterns and should be handled explicitly by clients: + +- `POST /auth/logout` is public by design (refresh-token revocation should still work when access token is expired). +- `GET /listings` and `GET /listings/:listingId` allow guest access via optional auth. +- `GET /listings/:listingId/photos` requires auth even though listing detail is public. +- `contactRevealGate` endpoints are optional-auth but quota-gated for guest/unverified users; verified users are unlimited. +- Contact reveal quota is charged only on successful 2xx responses. +- `/admin/*` docs exist but routes are not mounted in this workspace. +- `/test-utils/*` mounts only when `NODE_ENV !== "production"`. + +--- + +## Auth routes (`/auth`) + +| Endpoint | Route-level auth | Service-level auth/authorization notes | +|---|---|---| +| `POST /auth/register` | Public | Registration role validation in service (`student` / `pg_owner`). | +| `POST /auth/login` | Public | Credential + account-state validation in service. | +| `POST /auth/refresh` | Public | Refresh token required (body or cookie), token/session validity enforced in service. | +| `POST /auth/logout` | Public | Intentionally public so expired access-token clients can still revoke via refresh token. | +| `POST /auth/logout/current` | Authenticated | Refresh token must belong to current authenticated user/session. | +| `POST /auth/logout/all` | Authenticated | Revokes all sessions for authenticated user only. | +| `GET /auth/sessions` | Authenticated | Lists sessions for authenticated user only. | +| `DELETE /auth/sessions/:sid` | Authenticated | Revokes only sessions belonging to authenticated user. | +| `POST /auth/otp/send` | Authenticated | OTP issued only for authenticated user account. | +| `POST /auth/otp/verify` | Authenticated | OTP verification + attempt throttling bound to authenticated user. | +| `GET /auth/me` | Authenticated | Returns authenticated user profile envelope. | +| `POST /auth/google/callback` | Public | Service handles login/register flow based on Google identity + optional role. | + +--- + +## Listings routes (`/listings`) + +| Endpoint | Route-level auth | Service-level auth/authorization notes | +|---|---|---| +| `GET /listings` | Optional auth | Guest users are capped by `guestListingGate`; compatibility fields require auth + preferences context. | +| `GET /listings/:listingId` | Optional auth | Public read; service increments view count asynchronously. | +| `POST /listings` | Authenticated | Listing type allowed by caller role (`student_room` vs PG-owner-only types). | +| `PUT /listings/:listingId` | Authenticated | Ownership and listing-type/location constraints enforced in service. | +| `DELETE /listings/:listingId` | Authenticated | Owner-only soft delete in service. | +| `PATCH /listings/:listingId/status` | Authenticated | Owner-only + transition rules enforced in service. | +| `GET /listings/:listingId/preferences` | Authenticated | Read requires authenticated caller; listing existence checks in service. | +| `PUT /listings/:listingId/preferences` | Authenticated | Owner-only update behavior in service. | +| `POST /listings/:listingId/save` | Authenticated + role `student` | Student-only save behavior; listing state checks in service. | +| `DELETE /listings/:listingId/save` | Authenticated + role `student` | Student-only unsave behavior. | +| `GET /listings/me/saved` | Authenticated + role `student` | Student-only saved feed for caller identity. | +| `GET /listings/:listingId/photos` | Authenticated | Read requires auth (not public listing parity). | +| `POST /listings/:listingId/photos` | Authenticated | Ownership enforced in photo service. | +| `DELETE /listings/:listingId/photos/:photoId` | Authenticated | Ownership + photo existence enforced in photo service. | +| `PATCH /listings/:listingId/photos/:photoId/cover` | Authenticated | Ownership + non-processing photo checks enforced in photo service. | +| `PUT /listings/:listingId/photos/reorder` | Authenticated | Ownership + full-set reorder invariants enforced in photo service. | +| `POST /listings/:listingId/interests` | Authenticated + role `student` | Student cannot interest own listing; duplicate/state gates in service. | +| `GET /listings/:listingId/interests` | Authenticated | Service enforces listing ownership (`403`) vs not found (`404`). | + +--- + +## Interests routes (`/interests`) + +| Endpoint | Route-level auth | Service-level auth/authorization notes | +|---|---|---| +| `GET /interests/me` | Authenticated + role `student` | Sender dashboard only. | +| `GET /interests/:interestId` | Authenticated | Sender/poster party check in service; outsiders receive privacy-preserving `404`. | +| `PATCH /interests/:interestId/status` | Authenticated | Party-only transitions in service (poster accept/decline, student withdraw). Non-parties get `404`. | + +--- + +## Connections routes (`/connections`) + +| Endpoint | Route-level auth | Service-level auth/authorization notes | +|---|---|---| +| `GET /connections/me` | Authenticated | Feed scoped to caller as initiator or counterpart. | +| `GET /connections/:connectionId` | Authenticated | Party-only access in service; outsiders receive `404`. | +| `POST /connections/:connectionId/confirm` | Authenticated | Party-only confirmation; service determines caller side and transition validity. | + +--- + +## Notifications routes (`/notifications`) + +| Endpoint | Route-level auth | Service-level auth/authorization notes | +|---|---|---| +| `GET /notifications` | Authenticated | Feed is always scoped to authenticated recipient. | +| `GET /notifications/unread-count` | Authenticated | Count is scoped to authenticated recipient. | +| `POST /notifications/mark-read` | Authenticated | Updates only caller-owned notification rows. | + +--- + +## Preferences routes (`/preferences`) + +| Endpoint | Route-level auth | Service-level auth/authorization notes | +|---|---|---| +| `GET /preferences/meta` | Authenticated | Metadata read requires authentication in current implementation. | + +--- + +## Student routes (`/students`) + +| Endpoint | Route-level auth | Service-level auth/authorization notes | +|---|---|---| +| `GET /students/:userId/profile` | Authenticated | Own profile includes private fields; others receive redacted fields. | +| `PUT /students/:userId/profile` | Authenticated | Caller can update only own profile (`403` otherwise). | +| `GET /students/:userId/contact/reveal` | Optional auth + `contactRevealGate` | Gate controls guest/unverified quotas and verified full-contact access. | +| `GET /students/:userId/preferences` | Authenticated | Caller/target checks enforced in service contract. | +| `PUT /students/:userId/preferences` | Authenticated | Caller/target checks enforced in service contract. | + +--- + +## PG owner routes (`/pg-owners`) + +| Endpoint | Route-level auth | Service-level auth/authorization notes | +|---|---|---| +| `GET /pg-owners/:userId/profile` | Authenticated | Read available to authenticated users; private fields behavior in service. | +| `POST /pg-owners/:userId/contact/reveal` | Optional auth + `contactRevealGate` | Gate controls quota + full-contact eligibility; `Cache-Control: no-store` applied. | +| `PUT /pg-owners/:userId/profile` | Authenticated + role `pg_owner` | Owner profile update limited to authenticated PG owner identity. | +| `POST /pg-owners/:userId/documents` | Authenticated + role `pg_owner` | Verification submission restricted to own PG owner account; service enforces lifecycle checks. | + +--- + +## Property routes (`/properties`) + +| Endpoint | Route-level auth | Service-level auth/authorization notes | +|---|---|---| +| `GET /properties/:propertyId` | Authenticated | Any authenticated user may view detail; not ownership-restricted. | +| `GET /properties` | Authenticated + role `pg_owner` | Owner management feed; verification/ownership checks in service. | +| `POST /properties` | Authenticated + role `pg_owner` | Verified PG owner required in service. | +| `PUT /properties/:propertyId` | Authenticated + role `pg_owner` | Owner-only modification enforced in service. | +| `DELETE /properties/:propertyId` | Authenticated + role `pg_owner` | Owner-only delete enforced in service. | + +--- + +## Ratings routes (`/ratings`) + +| Endpoint | Route-level auth | Service-level auth/authorization notes | +|---|---|---| +| `GET /ratings/me/given` | Authenticated | Caller-scoped given-ratings feed. | +| `GET /ratings/user/:userId` | Public (rate-limited) | Public profile reputation read. | +| `GET /ratings/property/:propertyId` | Public (rate-limited) | Public property reputation read. | +| `GET /ratings/connection/:connectionId` | Authenticated | Party checks in service (`404` privacy-preserving for outsiders). | +| `POST /ratings` | Authenticated | Service enforces connection status + party rules + duplicate constraints. | +| `POST /ratings/:ratingId/report` | Authenticated | Service enforces reporter party membership and duplicate-open-report rule. | + +--- + +## Health route (`/health`) + +| Endpoint | Route-level auth | Service-level auth/authorization notes | +|---|---|---| +| `GET /health` | Public | No auth required; operational dependency probe only. | + +--- + diff --git a/docs/api/conventions.md b/docs/api/conventions.md index 483cea8..3f5187e 100644 --- a/docs/api/conventions.md +++ b/docs/api/conventions.md @@ -134,6 +134,24 @@ Some auth flows return a slightly different message: That difference is service-specific and documented in the auth feature doc. + +## Auth Transport Header Convention + +Auth endpoints support two client transport modes: + +- default cookie mode (no special header) +- bearer mode via `X-Client-Transport: bearer` + +When bearer mode is requested on auth endpoints, token responses include `accessToken` and `refreshToken` in JSON. +Without that header, browser-safe cookie mode is used and token strings are not exposed in the response body. + +## Money Units Convention + +- Database storage uses paise (integer). +- Public API request/response payloads use rupees (integer). + +Example: `rent_per_month = 950000` in the database corresponds to `rentPerMonth = 9500` in the API. + ## Authorization and Privacy Conventions Roomies intentionally uses two different patterns depending on the sensitivity of the resource. @@ -207,6 +225,15 @@ Guest contact reveal quota exhausted: } ``` +## Endpoint-specific Rate Limits + +Use these values for client retry behavior and UX copy: + +- `authLimiter`: **10 requests / 15 minutes** (`/auth/register`, `/auth/login`, `/auth/refresh`, `/auth/logout/all`, Google auth endpoints). +- `otpLimiter`: **5 requests / 15 minutes** (`/auth/otp/send`). +- OTP verify IP throttle: **20 attempts / 15 minutes per IP** (`/auth/otp/verify`). +- `publicRatingsLimiter`: **120 requests / 15 minutes** (public ratings reads). + ## Upload Error Responses Unsupported file type: diff --git a/docs/api/interests.md b/docs/api/interests.md index 88c1155..c53f7ee 100644 --- a/docs/api/interests.md +++ b/docs/api/interests.md @@ -11,6 +11,8 @@ Student-only endpoint for expressing interest in a listing. ### Request Body +Body is optional. You can send no request body at all, or send `{}`. + Minimal request: ```json @@ -268,7 +270,7 @@ Status: `404` } ``` -This is privacy-preserving behavior. The API does not reveal whether the request exists. +This is privacy-preserving behavior. The API does not reveal whether the request exists, and non-parties always get `404` (not `403`). ## `PATCH /interests/:interestId/status` @@ -336,6 +338,8 @@ Status: `200` ### Scenario: poster accepts pending request +`whatsappLink` can be `null` when the poster has no phone number on file; this is not an error and does not block acceptance. + Request: ```json diff --git a/docs/api/listings.api.md b/docs/api/listings.api.md index ea98bf5..e5a914e 100644 --- a/docs/api/listings.api.md +++ b/docs/api/listings.api.md @@ -18,12 +18,15 @@ 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 | +| Guest (no token) | ✅ Up to 20 items per request (server silently caps higher `limit`) | ❌ 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. +Optional-auth exception: on these two read endpoints, invalid/expired bearer tokens are treated as guest access (the +request is not rejected with `401` by `optionalAuthenticate`). + ## Search and Retrieval ### `GET /listings` @@ -87,6 +90,10 @@ Status: `200` `compatibilityScore` is always `0` and `compatibilityAvailable` is always `false` for guests. +Compatibility semantics for authenticated callers: +- `compatibilityAvailable = false` means either side has no saved preferences, so no comparison was possible. +- `compatibilityAvailable = true` with `compatibilityScore = 0` means comparison happened, but no preferences matched. + #### Scenario: authenticated search with city, rent, and amenity filters Request: @@ -209,7 +216,7 @@ Status: `400` ### `GET /listings/:listingId` -Fetches full listing detail. Also increments `views_count` asynchronously. +Fetches full listing detail. Also increments `views_count` asynchronously (fire-and-forget side effect). Avoid polling this endpoint for background status checks. #### Request Contract @@ -951,6 +958,8 @@ Status: `200` **Auth required.** Role: `student`. Returns the student's saved active listings. +Integration note: this endpoint currently returns a mixed casing payload (legacy `snake_case` fields from SQL rows plus camelCase rent/deposit fields). Treat the example response as authoritative until the response is normalized in code. + #### Scenario: paginated saved listings Status: `200` @@ -978,7 +987,10 @@ Status: `200` "depositAmount": 5000 } ], - "nextCursor": null + "nextCursor": { + "cursorTime": "2026-04-11T10:30:00.000Z", + "cursorId": "55555555-5555-4555-8555-555555555555" + } } } ``` @@ -986,13 +998,13 @@ Status: `200` ## Photos Photo uploads are asynchronous. The HTTP request inserts a provisional row and queues a worker job. Clients should poll -`GET /listings/:listingId/photos`. +`GET /listings/:listingId/photos`. Use this endpoint (not listing detail polling) to avoid inflating `views_count`. **All photo endpoints require authentication.** ### `GET /listings/:listingId/photos` -Returns completed photos only. Processing placeholders are hidden. +Returns completed photos only. Processing placeholders are hidden (`photo_url LIKE 'processing:%'` rows are filtered out until processing completes). #### Scenario: success diff --git a/docs/api/notifications.md b/docs/api/notifications.md index 2cc9a69..e668cb6 100644 --- a/docs/api/notifications.md +++ b/docs/api/notifications.md @@ -19,6 +19,7 @@ The `NOTIFICATION_MESSAGES` map in `src/workers/notificationWorker.js` is author | `listing_expiring` | One of your listings is expiring soon | ACTIVE | | `listing_expired` | One of your listings has expired | ACTIVE | | `listing_filled` | A listing has been marked as filled | ACTIVE | +| `verification_pending` | We received your verification documents | EMAIL-ONLY event (not in-app feed) | | `verification_approved` | Your verification request was approved | PLANNED emitter | | `verification_rejected` | Your verification request was rejected | PLANNED emitter | | `new_message` | You have a new message | PLANNED emitter | diff --git a/docs/api/profiles-and-contact.md b/docs/api/profiles-and-contact.md index 83b6640..353aa6b 100644 --- a/docs/api/profiles-and-contact.md +++ b/docs/api/profiles-and-contact.md @@ -159,6 +159,7 @@ Reveals student contact information using the guest/unverified/verified contact ### Request Contract - Auth required: Optional +- Optional-auth exception: invalid/expired tokens fall back to guest/unverified behavior instead of returning `401` from auth middleware. - Auth transport accepted: - Cookie mode (`accessToken` cookie) - Bearer mode (`Authorization: Bearer `) @@ -196,7 +197,7 @@ Status: `200` } ``` -### Scenario: verified user still receives the student route response actually enforced by the service +### Scenario: verified user receives full contact bundle (service-enforced behavior) Status: `200` @@ -352,9 +353,11 @@ Reveals PG owner contact information. This endpoint is intentionally `POST`, not ### Request Contract - Auth required: Optional +- Optional-auth exception: invalid/expired tokens fall back to guest/unverified behavior instead of returning `401` from auth middleware. - Auth transport accepted: - Cookie mode (`accessToken` cookie) - Bearer mode (`Authorization: Bearer `) +- Quota-gated for guests and unverified users - Route method: `POST` - Response header: `Cache-Control: no-store` diff --git a/docs/services/README.md b/docs/services/README.md new file mode 100644 index 0000000..53c3056 --- /dev/null +++ b/docs/services/README.md @@ -0,0 +1,58 @@ +# Service Contract Index + +This directory provides one stable place to understand backend behavior service-by-service. + +For each service below, treat source files as canonical in this order: route -> validator -> service -> worker. + +## Auth service +- Source: `src/services/auth.service.js` +- Scope: Session lifecycle, token rotation, logout semantics, and OTP verification rules. + +## Listing service +- Source: `src/services/listing.service.js` +- Scope: Search, detail, lifecycle transitions, save/unsave behavior, and pagination contracts. + +## Photo service +- Source: `src/services/photo.service.js` +- Scope: Listing photo upload lifecycle, placeholder filtering, cover election, and reorder invariants. + +## Interest service +- Source: `src/services/interest.service.js` +- Scope: Interest request creation, transitions, and accept-flow side effects. + +## Connection service +- Source: `src/services/connection.service.js` +- Scope: Two-party connection reads and confirmation state transitions. + +## Notification service +- Source: `src/services/notification.service.js` +- Scope: Notification feed reads, unread count, and mark-read updates. + +## Verification service +- Source: `src/services/verification.service.js` +- Scope: PG owner verification request submission and admin decision state changes. + +## Preferences service +- Source: `src/services/preferences.service.js` +- Scope: Student preference CRUD and metadata-backed validation behavior. + +## Property service +- Source: `src/services/property.service.js` +- Scope: PG owner property CRUD and ownership guards. + +## Ratings & reports service +- Source: `src/services/rating.service.js` +- Scope: Rating creation/reads and reporting lifecycle constraints. + +## Student service +- Source: `src/services/student.service.js` +- Scope: Student profile reads, updates, and contact reveal shape controls. + +## PG owner service +- Source: `src/services/pgOwner.service.js` +- Scope: PG owner profile reads, updates, and contact reveal shape controls. + +## Report service +- Source: `src/services/report.service.js` +- Scope: Admin report queue and resolution updates. + From 341fd30958fe7b1036b5bc5c758b49d64a2f8a3b Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Tue, 21 Apr 2026 00:09:14 +0530 Subject: [PATCH 10/54] feat: add frontend TypeScript/Zod type guide and update API documentation --- docs/API.md | 1 + docs/README.md | 5 +- docs/api/auth.md | 6 +- docs/api/authz-matrix.md | 168 ++++++++++++++++---------------- docs/api/conventions.md | 4 +- docs/api/frontend-type-guide.md | 112 +++++++++++++++++++++ docs/services/README.md | 14 ++- 7 files changed, 220 insertions(+), 90 deletions(-) create mode 100644 docs/api/frontend-type-guide.md diff --git a/docs/API.md b/docs/API.md index bb20317..94ec772 100644 --- a/docs/API.md +++ b/docs/API.md @@ -12,6 +12,7 @@ This file is the API front door. Endpoint-by-endpoint contracts live in `docs/ap - [Shared conventions](./api/conventions.md) - [Auth/Authz matrix](./api/authz-matrix.md) +- [Frontend TS/Zod type guide](./api/frontend-type-guide.md) - [Auth](./api/auth.md) - [Profiles and contact reveal](./api/profiles-and-contact.md) - [Properties](./api/properties.md) diff --git a/docs/README.md b/docs/README.md index 704eabc..c67241e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,8 @@ Feature-level API documentation lives in `docs/api/`. - [api/conventions.md](./api/conventions.md) — shared response envelopes, auth failures, pagination, and example conventions - [api/authz-matrix.md](./api/authz-matrix.md) — route-level + service-level authentication and authorization matrix +- [api/frontend-type-guide.md](./api/frontend-type-guide.md) — TypeScript/Zod-focused guidance for stable DTO and enum + modeling - [api/auth.md](./api/auth.md) — registration, login, refresh, OTP, sessions, logout, and Google OAuth flows - [api/profiles-and-contact.md](./api/profiles-and-contact.md) — student and PG owner profiles, contact reveal, and verification document submission @@ -38,7 +40,8 @@ Feature-level API documentation lives in `docs/api/`. ## Service Contracts -- [services/README.md](./services/README.md) — service-by-service ownership map linking routes, validators, service logic, and workers +- [services/README.md](./services/README.md) — service-by-service ownership map linking routes, validators, service + logic, and workers ## Maintenance Rules diff --git a/docs/api/auth.md b/docs/api/auth.md index 6fb10bf..009ccd9 100644 --- a/docs/api/auth.md +++ b/docs/api/auth.md @@ -399,7 +399,8 @@ Status: `401` ## `POST /auth/logout` -Revokes a session using the refresh token from the body or cookie. This endpoint intentionally does **not** require `authenticate`, so clients with expired access tokens can still revoke refresh-token sessions. +Revokes a session using the refresh token from the body or cookie. This endpoint intentionally does **not** require +`authenticate`, so clients with expired access tokens can still revoke refresh-token sessions. ### Request @@ -448,7 +449,8 @@ Status: `401` ## `POST /auth/logout/current` -Revokes the currently authenticated session only. This endpoint **does** require `authenticate` and is session-scoped: the refresh token must belong to the same authenticated user/session context. +Revokes the currently authenticated session only. This endpoint **does** require `authenticate` and is session-scoped: +the refresh token must belong to the same authenticated user/session context. ### Scenario: current-session logout succeeds diff --git a/docs/api/authz-matrix.md b/docs/api/authz-matrix.md index 7125cd1..829aba7 100644 --- a/docs/api/authz-matrix.md +++ b/docs/api/authz-matrix.md @@ -42,7 +42,8 @@ These are intentional exceptions to common auth patterns and should be handled e - `POST /auth/logout` is public by design (refresh-token revocation should still work when access token is expired). - `GET /listings` and `GET /listings/:listingId` allow guest access via optional auth. - `GET /listings/:listingId/photos` requires auth even though listing detail is public. -- `contactRevealGate` endpoints are optional-auth but quota-gated for guest/unverified users; verified users are unlimited. +- `contactRevealGate` endpoints are optional-auth but quota-gated for guest/unverified users; verified users are + unlimited. - Contact reveal quota is charged only on successful 2xx responses. - `/admin/*` docs exist but routes are not mounted in this workspace. - `/test-utils/*` mounts only when `NODE_ENV !== "production"`. @@ -51,139 +52,138 @@ These are intentional exceptions to common auth patterns and should be handled e ## Auth routes (`/auth`) -| Endpoint | Route-level auth | Service-level auth/authorization notes | -|---|---|---| -| `POST /auth/register` | Public | Registration role validation in service (`student` / `pg_owner`). | -| `POST /auth/login` | Public | Credential + account-state validation in service. | -| `POST /auth/refresh` | Public | Refresh token required (body or cookie), token/session validity enforced in service. | -| `POST /auth/logout` | Public | Intentionally public so expired access-token clients can still revoke via refresh token. | -| `POST /auth/logout/current` | Authenticated | Refresh token must belong to current authenticated user/session. | -| `POST /auth/logout/all` | Authenticated | Revokes all sessions for authenticated user only. | -| `GET /auth/sessions` | Authenticated | Lists sessions for authenticated user only. | -| `DELETE /auth/sessions/:sid` | Authenticated | Revokes only sessions belonging to authenticated user. | -| `POST /auth/otp/send` | Authenticated | OTP issued only for authenticated user account. | -| `POST /auth/otp/verify` | Authenticated | OTP verification + attempt throttling bound to authenticated user. | -| `GET /auth/me` | Authenticated | Returns authenticated user profile envelope. | -| `POST /auth/google/callback` | Public | Service handles login/register flow based on Google identity + optional role. | +| Endpoint | Route-level auth | Service-level auth/authorization notes | +| ---------------------------- | ---------------- | ---------------------------------------------------------------------------------------- | +| `POST /auth/register` | Public | Registration role validation in service (`student` / `pg_owner`). | +| `POST /auth/login` | Public | Credential + account-state validation in service. | +| `POST /auth/refresh` | Public | Refresh token required (body or cookie), token/session validity enforced in service. | +| `POST /auth/logout` | Public | Intentionally public so expired access-token clients can still revoke via refresh token. | +| `POST /auth/logout/current` | Authenticated | Refresh token must belong to current authenticated user/session. | +| `POST /auth/logout/all` | Authenticated | Revokes all sessions for authenticated user only. | +| `GET /auth/sessions` | Authenticated | Lists sessions for authenticated user only. | +| `DELETE /auth/sessions/:sid` | Authenticated | Revokes only sessions belonging to authenticated user. | +| `POST /auth/otp/send` | Authenticated | OTP issued only for authenticated user account. | +| `POST /auth/otp/verify` | Authenticated | OTP verification + attempt throttling bound to authenticated user. | +| `GET /auth/me` | Authenticated | Returns authenticated user profile envelope. | +| `POST /auth/google/callback` | Public | Service handles login/register flow based on Google identity + optional role. | --- ## Listings routes (`/listings`) -| Endpoint | Route-level auth | Service-level auth/authorization notes | -|---|---|---| -| `GET /listings` | Optional auth | Guest users are capped by `guestListingGate`; compatibility fields require auth + preferences context. | -| `GET /listings/:listingId` | Optional auth | Public read; service increments view count asynchronously. | -| `POST /listings` | Authenticated | Listing type allowed by caller role (`student_room` vs PG-owner-only types). | -| `PUT /listings/:listingId` | Authenticated | Ownership and listing-type/location constraints enforced in service. | -| `DELETE /listings/:listingId` | Authenticated | Owner-only soft delete in service. | -| `PATCH /listings/:listingId/status` | Authenticated | Owner-only + transition rules enforced in service. | -| `GET /listings/:listingId/preferences` | Authenticated | Read requires authenticated caller; listing existence checks in service. | -| `PUT /listings/:listingId/preferences` | Authenticated | Owner-only update behavior in service. | -| `POST /listings/:listingId/save` | Authenticated + role `student` | Student-only save behavior; listing state checks in service. | -| `DELETE /listings/:listingId/save` | Authenticated + role `student` | Student-only unsave behavior. | -| `GET /listings/me/saved` | Authenticated + role `student` | Student-only saved feed for caller identity. | -| `GET /listings/:listingId/photos` | Authenticated | Read requires auth (not public listing parity). | -| `POST /listings/:listingId/photos` | Authenticated | Ownership enforced in photo service. | -| `DELETE /listings/:listingId/photos/:photoId` | Authenticated | Ownership + photo existence enforced in photo service. | -| `PATCH /listings/:listingId/photos/:photoId/cover` | Authenticated | Ownership + non-processing photo checks enforced in photo service. | -| `PUT /listings/:listingId/photos/reorder` | Authenticated | Ownership + full-set reorder invariants enforced in photo service. | -| `POST /listings/:listingId/interests` | Authenticated + role `student` | Student cannot interest own listing; duplicate/state gates in service. | -| `GET /listings/:listingId/interests` | Authenticated | Service enforces listing ownership (`403`) vs not found (`404`). | +| Endpoint | Route-level auth | Service-level auth/authorization notes | +| -------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------ | +| `GET /listings` | Optional auth | Guest users are capped by `guestListingGate`; compatibility fields require auth + preferences context. | +| `GET /listings/:listingId` | Optional auth | Public read; service increments view count asynchronously. | +| `POST /listings` | Authenticated | Listing type allowed by caller role (`student_room` vs PG-owner-only types). | +| `PUT /listings/:listingId` | Authenticated | Ownership and listing-type/location constraints enforced in service. | +| `DELETE /listings/:listingId` | Authenticated | Owner-only soft delete in service. | +| `PATCH /listings/:listingId/status` | Authenticated | Owner-only + transition rules enforced in service. | +| `GET /listings/:listingId/preferences` | Authenticated | Read requires authenticated caller; listing existence checks in service. | +| `PUT /listings/:listingId/preferences` | Authenticated | Owner-only update behavior in service. | +| `POST /listings/:listingId/save` | Authenticated + role `student` | Student-only save behavior; listing state checks in service. | +| `DELETE /listings/:listingId/save` | Authenticated + role `student` | Student-only unsave behavior. | +| `GET /listings/me/saved` | Authenticated + role `student` | Student-only saved feed for caller identity. | +| `GET /listings/:listingId/photos` | Authenticated | Read requires auth (not public listing parity). | +| `POST /listings/:listingId/photos` | Authenticated | Ownership enforced in photo service. | +| `DELETE /listings/:listingId/photos/:photoId` | Authenticated | Ownership + photo existence enforced in photo service. | +| `PATCH /listings/:listingId/photos/:photoId/cover` | Authenticated | Ownership + non-processing photo checks enforced in photo service. | +| `PUT /listings/:listingId/photos/reorder` | Authenticated | Ownership + full-set reorder invariants enforced in photo service. | +| `POST /listings/:listingId/interests` | Authenticated + role `student` | Student cannot interest own listing; duplicate/state gates in service. | +| `GET /listings/:listingId/interests` | Authenticated | Service enforces listing ownership (`403`) vs not found (`404`). | --- ## Interests routes (`/interests`) -| Endpoint | Route-level auth | Service-level auth/authorization notes | -|---|---|---| -| `GET /interests/me` | Authenticated + role `student` | Sender dashboard only. | -| `GET /interests/:interestId` | Authenticated | Sender/poster party check in service; outsiders receive privacy-preserving `404`. | -| `PATCH /interests/:interestId/status` | Authenticated | Party-only transitions in service (poster accept/decline, student withdraw). Non-parties get `404`. | +| Endpoint | Route-level auth | Service-level auth/authorization notes | +| ------------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------- | +| `GET /interests/me` | Authenticated + role `student` | Sender dashboard only. | +| `GET /interests/:interestId` | Authenticated | Sender/poster party check in service; outsiders receive privacy-preserving `404`. | +| `PATCH /interests/:interestId/status` | Authenticated | Party-only transitions in service (poster accept/decline, student withdraw). Non-parties get `404`. | --- ## Connections routes (`/connections`) -| Endpoint | Route-level auth | Service-level auth/authorization notes | -|---|---|---| -| `GET /connections/me` | Authenticated | Feed scoped to caller as initiator or counterpart. | -| `GET /connections/:connectionId` | Authenticated | Party-only access in service; outsiders receive `404`. | -| `POST /connections/:connectionId/confirm` | Authenticated | Party-only confirmation; service determines caller side and transition validity. | +| Endpoint | Route-level auth | Service-level auth/authorization notes | +| ----------------------------------------- | ---------------- | -------------------------------------------------------------------------------- | +| `GET /connections/me` | Authenticated | Feed scoped to caller as initiator or counterpart. | +| `GET /connections/:connectionId` | Authenticated | Party-only access in service; outsiders receive `404`. | +| `POST /connections/:connectionId/confirm` | Authenticated | Party-only confirmation; service determines caller side and transition validity. | --- ## Notifications routes (`/notifications`) -| Endpoint | Route-level auth | Service-level auth/authorization notes | -|---|---|---| -| `GET /notifications` | Authenticated | Feed is always scoped to authenticated recipient. | -| `GET /notifications/unread-count` | Authenticated | Count is scoped to authenticated recipient. | -| `POST /notifications/mark-read` | Authenticated | Updates only caller-owned notification rows. | +| Endpoint | Route-level auth | Service-level auth/authorization notes | +| --------------------------------- | ---------------- | ------------------------------------------------- | +| `GET /notifications` | Authenticated | Feed is always scoped to authenticated recipient. | +| `GET /notifications/unread-count` | Authenticated | Count is scoped to authenticated recipient. | +| `POST /notifications/mark-read` | Authenticated | Updates only caller-owned notification rows. | --- ## Preferences routes (`/preferences`) -| Endpoint | Route-level auth | Service-level auth/authorization notes | -|---|---|---| -| `GET /preferences/meta` | Authenticated | Metadata read requires authentication in current implementation. | +| Endpoint | Route-level auth | Service-level auth/authorization notes | +| ----------------------- | ---------------- | ---------------------------------------------------------------- | +| `GET /preferences/meta` | Authenticated | Metadata read requires authentication in current implementation. | --- ## Student routes (`/students`) -| Endpoint | Route-level auth | Service-level auth/authorization notes | -|---|---|---| -| `GET /students/:userId/profile` | Authenticated | Own profile includes private fields; others receive redacted fields. | -| `PUT /students/:userId/profile` | Authenticated | Caller can update only own profile (`403` otherwise). | +| Endpoint | Route-level auth | Service-level auth/authorization notes | +| -------------------------------------- | ----------------------------------- | ----------------------------------------------------------------------- | +| `GET /students/:userId/profile` | Authenticated | Own profile includes private fields; others receive redacted fields. | +| `PUT /students/:userId/profile` | Authenticated | Caller can update only own profile (`403` otherwise). | | `GET /students/:userId/contact/reveal` | Optional auth + `contactRevealGate` | Gate controls guest/unverified quotas and verified full-contact access. | -| `GET /students/:userId/preferences` | Authenticated | Caller/target checks enforced in service contract. | -| `PUT /students/:userId/preferences` | Authenticated | Caller/target checks enforced in service contract. | +| `GET /students/:userId/preferences` | Authenticated | Caller/target checks enforced in service contract. | +| `PUT /students/:userId/preferences` | Authenticated | Caller/target checks enforced in service contract. | --- ## PG owner routes (`/pg-owners`) -| Endpoint | Route-level auth | Service-level auth/authorization notes | -|---|---|---| -| `GET /pg-owners/:userId/profile` | Authenticated | Read available to authenticated users; private fields behavior in service. | -| `POST /pg-owners/:userId/contact/reveal` | Optional auth + `contactRevealGate` | Gate controls quota + full-contact eligibility; `Cache-Control: no-store` applied. | -| `PUT /pg-owners/:userId/profile` | Authenticated + role `pg_owner` | Owner profile update limited to authenticated PG owner identity. | -| `POST /pg-owners/:userId/documents` | Authenticated + role `pg_owner` | Verification submission restricted to own PG owner account; service enforces lifecycle checks. | +| Endpoint | Route-level auth | Service-level auth/authorization notes | +| ---------------------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------- | +| `GET /pg-owners/:userId/profile` | Authenticated | Read available to authenticated users; private fields behavior in service. | +| `POST /pg-owners/:userId/contact/reveal` | Optional auth + `contactRevealGate` | Gate controls quota + full-contact eligibility; `Cache-Control: no-store` applied. | +| `PUT /pg-owners/:userId/profile` | Authenticated + role `pg_owner` | Owner profile update limited to authenticated PG owner identity. | +| `POST /pg-owners/:userId/documents` | Authenticated + role `pg_owner` | Verification submission restricted to own PG owner account; service enforces lifecycle checks. | --- ## Property routes (`/properties`) -| Endpoint | Route-level auth | Service-level auth/authorization notes | -|---|---|---| -| `GET /properties/:propertyId` | Authenticated | Any authenticated user may view detail; not ownership-restricted. | -| `GET /properties` | Authenticated + role `pg_owner` | Owner management feed; verification/ownership checks in service. | -| `POST /properties` | Authenticated + role `pg_owner` | Verified PG owner required in service. | -| `PUT /properties/:propertyId` | Authenticated + role `pg_owner` | Owner-only modification enforced in service. | -| `DELETE /properties/:propertyId` | Authenticated + role `pg_owner` | Owner-only delete enforced in service. | +| Endpoint | Route-level auth | Service-level auth/authorization notes | +| -------------------------------- | ------------------------------- | ----------------------------------------------------------------- | +| `GET /properties/:propertyId` | Authenticated | Any authenticated user may view detail; not ownership-restricted. | +| `GET /properties` | Authenticated + role `pg_owner` | Owner management feed; verification/ownership checks in service. | +| `POST /properties` | Authenticated + role `pg_owner` | Verified PG owner required in service. | +| `PUT /properties/:propertyId` | Authenticated + role `pg_owner` | Owner-only modification enforced in service. | +| `DELETE /properties/:propertyId` | Authenticated + role `pg_owner` | Owner-only delete enforced in service. | --- ## Ratings routes (`/ratings`) -| Endpoint | Route-level auth | Service-level auth/authorization notes | -|---|---|---| -| `GET /ratings/me/given` | Authenticated | Caller-scoped given-ratings feed. | -| `GET /ratings/user/:userId` | Public (rate-limited) | Public profile reputation read. | -| `GET /ratings/property/:propertyId` | Public (rate-limited) | Public property reputation read. | -| `GET /ratings/connection/:connectionId` | Authenticated | Party checks in service (`404` privacy-preserving for outsiders). | -| `POST /ratings` | Authenticated | Service enforces connection status + party rules + duplicate constraints. | -| `POST /ratings/:ratingId/report` | Authenticated | Service enforces reporter party membership and duplicate-open-report rule. | +| Endpoint | Route-level auth | Service-level auth/authorization notes | +| --------------------------------------- | --------------------- | -------------------------------------------------------------------------- | +| `GET /ratings/me/given` | Authenticated | Caller-scoped given-ratings feed. | +| `GET /ratings/user/:userId` | Public (rate-limited) | Public profile reputation read. | +| `GET /ratings/property/:propertyId` | Public (rate-limited) | Public property reputation read. | +| `GET /ratings/connection/:connectionId` | Authenticated | Party checks in service (`404` privacy-preserving for outsiders). | +| `POST /ratings` | Authenticated | Service enforces connection status + party rules + duplicate constraints. | +| `POST /ratings/:ratingId/report` | Authenticated | Service enforces reporter party membership and duplicate-open-report rule. | --- ## Health route (`/health`) -| Endpoint | Route-level auth | Service-level auth/authorization notes | -|---|---|---| -| `GET /health` | Public | No auth required; operational dependency probe only. | +| Endpoint | Route-level auth | Service-level auth/authorization notes | +| ------------- | ---------------- | ---------------------------------------------------- | +| `GET /health` | Public | No auth required; operational dependency probe only. | --- - diff --git a/docs/api/conventions.md b/docs/api/conventions.md index 3f5187e..ad8e781 100644 --- a/docs/api/conventions.md +++ b/docs/api/conventions.md @@ -134,7 +134,6 @@ Some auth flows return a slightly different message: That difference is service-specific and documented in the auth feature doc. - ## Auth Transport Header Convention Auth endpoints support two client transport modes: @@ -229,7 +228,8 @@ Guest contact reveal quota exhausted: Use these values for client retry behavior and UX copy: -- `authLimiter`: **10 requests / 15 minutes** (`/auth/register`, `/auth/login`, `/auth/refresh`, `/auth/logout/all`, Google auth endpoints). +- `authLimiter`: **10 requests / 15 minutes** (`/auth/register`, `/auth/login`, `/auth/refresh`, `/auth/logout/all`, + Google auth endpoints). - `otpLimiter`: **5 requests / 15 minutes** (`/auth/otp/send`). - OTP verify IP throttle: **20 attempts / 15 minutes per IP** (`/auth/otp/verify`). - `publicRatingsLimiter`: **120 requests / 15 minutes** (public ratings reads). diff --git a/docs/api/frontend-type-guide.md b/docs/api/frontend-type-guide.md new file mode 100644 index 0000000..813c330 --- /dev/null +++ b/docs/api/frontend-type-guide.md @@ -0,0 +1,112 @@ +# Frontend Type Safety Guide (TypeScript + Zod) + +This guide is for frontend teams generating/maintaining strong API types. + +It complements endpoint docs by focusing on **type modeling decisions**: + +- which fields are stable contract fields, +- where casing is mixed, +- where enums come from SQL schema, +- and where optional-auth / privacy behaviors affect client type unions. + +## Source-of-truth order for frontend types + +When in doubt, model from these layers in this order: + +1. `src/routes/*` for mounted endpoint surface and auth middleware. +2. `src/validators/*` for request shape/requiredness. +3. `src/services/*` for response shape and business-rule branches. +4. `migrations/*.sql` for enum domains and persisted state values. + +## Scalar contract conventions + +- IDs are UUID strings. +- Timestamps are ISO strings in API responses. +- Money in API payloads is rupees (integer); DB stores paise. +- Pagination uses keyset cursors: `{ cursorTime: string, cursorId: string } | null`. + +## Casing expectations + +Most API response payloads are camelCase in docs. + +Known exception to model explicitly: + +- `GET /listings/me/saved` currently returns mixed casing (legacy snake_case SQL fields + camelCase money fields). + +For TypeScript apps, prefer defining a dedicated DTO type for this endpoint instead of reusing generic `ListingCard`. + +## SQL enum domains you should mirror in frontend types + +These enums are defined in schema migrations and are useful as `z.enum([...])` or string-union TS types. + +### Account / identity enums + +- `account_status_enum`: `active | suspended | banned | deactivated` +- `role_enum`: `student | pg_owner | admin` +- `gender_enum`: `male | female | other | prefer_not_to_say` +- `verification_status_enum`: `unverified | pending | verified | rejected` + +### Listing / property enums + +- `listing_type_enum`: `student_room | pg_room | hostel_bed` +- `room_type_enum`: `single | double | triple | entire_flat` +- `bed_type_enum`: `single_bed | double_bed | bunk_bed` +- `listing_status_enum`: `active | filled | expired | deactivated` +- `property_type_enum`: `pg | hostel | shared_apartment` +- `property_status_enum`: `active | inactive | under_review` + +### Trust / workflow enums + +- `request_status_enum`: `pending | accepted | declined | withdrawn | expired` +- `confirmation_status_enum`: `pending | confirmed | denied | expired` +- `connection_type_enum`: `student_roommate | pg_stay | hostel_stay | visit_only` +- `reviewee_type_enum`: `user | property` +- `report_reason_enum`: `fake | abusive | conflict_of_interest | other` +- `report_status_enum`: `open | resolved_removed | resolved_kept` +- `document_type_enum`: `property_document | rental_agreement | owner_id | trade_license` +- `amenity_category_enum`: `utility | safety | comfort` + +### Notification enums + +- `notification_type_enum` initial values are defined in migration 001. +- Migration 002 adds `verification_pending`. + +Frontend recommendation: treat notification `type` as a discriminated union that includes `verification_pending`, even +if UI behavior is currently email-only for that event. + +## Optional-auth endpoints: model as unions + +For endpoints with `optionalAuthenticate`, clients should not assume invalid tokens produce `401`. + +Recommended modeling: + +- Request can be made with or without token. +- Response may still be success guest-shape even if token is stale/invalid. +- Client state machine should support `authenticated-success` and `guest-success` for the same endpoint. + +Primary affected endpoints: + +- `GET /listings` +- `GET /listings/:listingId` +- `GET /students/:userId/contact/reveal` +- `POST /pg-owners/:userId/contact/reveal` + +## Suggested frontend package structure + +- `api/types/common.ts` + - `UUID`, `ISODateTimeString`, `Cursor` +- `api/types/enums.ts` + - all SQL-backed string unions / Zod enums +- `api/types/auth.ts`, `listings.ts`, `interests.ts`, ... + - endpoint DTOs per feature +- `api/types/legacy.ts` + - mixed-case DTOs (for transitional endpoints like saved listings) +- `api/zod/*` + - runtime response parsers for high-risk endpoints + +## High-value guardrails for TS + Zod teams + +- Parse all cursor responses with Zod before writing pagination state. +- Keep endpoint-specific DTOs where response casing diverges. +- Use discriminated unions for privacy-preserving errors (`404` as not-found-or-not-allowed). +- Keep notification type unions synced with migrations when new enum labels are added. diff --git a/docs/services/README.md b/docs/services/README.md index 53c3056..9e3aa0b 100644 --- a/docs/services/README.md +++ b/docs/services/README.md @@ -5,54 +5,66 @@ This directory provides one stable place to understand backend behavior service- For each service below, treat source files as canonical in this order: route -> validator -> service -> worker. ## Auth service + - Source: `src/services/auth.service.js` - Scope: Session lifecycle, token rotation, logout semantics, and OTP verification rules. ## Listing service + - Source: `src/services/listing.service.js` - Scope: Search, detail, lifecycle transitions, save/unsave behavior, and pagination contracts. ## Photo service + - Source: `src/services/photo.service.js` - Scope: Listing photo upload lifecycle, placeholder filtering, cover election, and reorder invariants. ## Interest service + - Source: `src/services/interest.service.js` - Scope: Interest request creation, transitions, and accept-flow side effects. ## Connection service + - Source: `src/services/connection.service.js` - Scope: Two-party connection reads and confirmation state transitions. ## Notification service + - Source: `src/services/notification.service.js` - Scope: Notification feed reads, unread count, and mark-read updates. ## Verification service + - Source: `src/services/verification.service.js` - Scope: PG owner verification request submission and admin decision state changes. ## Preferences service + - Source: `src/services/preferences.service.js` - Scope: Student preference CRUD and metadata-backed validation behavior. ## Property service + - Source: `src/services/property.service.js` - Scope: PG owner property CRUD and ownership guards. ## Ratings & reports service + - Source: `src/services/rating.service.js` - Scope: Rating creation/reads and reporting lifecycle constraints. ## Student service + - Source: `src/services/student.service.js` - Scope: Student profile reads, updates, and contact reveal shape controls. ## PG owner service + - Source: `src/services/pgOwner.service.js` - Scope: PG owner profile reads, updates, and contact reveal shape controls. ## Report service + - Source: `src/services/report.service.js` - Scope: Admin report queue and resolution updates. - From 9155ec72c832fa9562827fb7edabff42469ed560 Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Tue, 21 Apr 2026 12:55:15 +0530 Subject: [PATCH 11/54] Refactor code structure for improved readability and maintainability --- ...ll E2E Test Suite.postman_collection.json" | 2591 ------------- ... \342\200\224 Postman Testing Master P.md" | 3251 ----------------- prompt1.md | 399 -- prompt2.md | 806 ---- 4 files changed, 7047 deletions(-) delete mode 100644 "Roomies API \342\200\224 Full E2E Test Suite.postman_collection.json" delete mode 100644 "Roomies API \342\200\224 Postman Testing Master P.md" delete mode 100644 prompt1.md delete mode 100644 prompt2.md 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 885e8d1..0000000 --- "a/Roomies API \342\200\224 Full E2E Test Suite.postman_collection.json" +++ /dev/null @@ -1,2591 +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": "[1.14] Register Student 3", - "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\");", - "});", - "pm.test(\"Email is NOT auto-verified (gmail domain)\", () => {", - " const body = pm.response.json();", - " pm.expect(body.data.user.isEmailVerified).to.eql(false);", - "});", - "", - "const body = pm.response.json();", - "pm.environment.set(\"student3AccessToken\", body.data.accessToken);", - "pm.environment.set(\"student3RefreshToken\", body.data.refreshToken);", - "pm.environment.set(\"student3Id\", body.data.user.userId);", - "", - "console.log(\"student3Id:\", body.data.user.userId);", - "console.log(\"student3 isEmailVerified:\", body.data.user.isEmailVerified, \"— OTP flow will be needed\");" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - }, - { - "listen": "prerequest", - "script": { - "exec": [], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n\t\"email\": \"rohit.verma@gmail.com\",\n\t\"password\": \"Test1234\",\n\t\"role\": \"student\",\n\t\"fullName\": \"Rohit Verma\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/auth/register", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "register" - ] - } - }, - "response": [] - }, - { - "name": "[1.15] Register Student 4", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 201\", () => pm.response.to.have.status(201));", - "pm.test(\"Returns access token\", () => {", - "\tconst body = pm.response.json();", - "\tpm.expect(body.data.accessToken).to.be.a(\"string\");", - "});", - "pm.test(\"Email is NOT auto-verified (gmail domain)\", () => {", - "\tconst body = pm.response.json();", - "\tpm.expect(body.data.user.isEmailVerified).to.eql(false);", - "});", - "", - "const body = pm.response.json();", - "pm.environment.set(\"student4AccessToken\", body.data.accessToken);", - "pm.environment.set(\"student4RefreshToken\", body.data.refreshToken);", - "pm.environment.set(\"student4Id\", body.data.user.userId);", - "", - "console.log(\"student4Id:\", body.data.user.userId);", - "console.log(\"student4 isEmailVerified:\", body.data.user.isEmailVerified, \"— OTP flow will be needed\");" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - }, - { - "listen": "prerequest", - "script": { - "exec": [], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n\t\"email\": \"sumity1642@gmail.com\",\n\t\"password\": \"Test1234\",\n\t\"role\": \"student\",\n\t\"fullName\": \"Sumit Yadav\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/auth/register", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "register" - ] - } - }, - "response": [] - }, - { - "name": "[1.16] Register Pg Owner 4", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 201\", () => pm.response.to.have.status(201));", - "pm.test(\"Business name in response\", () => {", - "\t// Registration response returns user object, not profile — no business_name here.", - "\t// We confirm the pg_owner role is present instead.", - "\tpm.expect(body.data.user.roles).to.include(\"pg_owner\");", - "});", - "", - "const body = pm.response.json();", - "pm.environment.set(\"pgOwner4AccessToken\", body.data.accessToken);", - "pm.environment.set(\"pgOwner4RefreshToken\", body.data.refreshToken);", - "pm.environment.set(\"pgOwner4Id\", body.data.user.userId);", - "", - "console.log(\"pgOwner4Id:\", body.data.user.userId);" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - }, - { - "listen": "prerequest", - "script": { - "exec": [], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n\t\"email\": \"gameking43221@gmail.com\",\n\t\"password\": \"Test1234\",\n\t\"role\": \"pg_owner\",\n\t\"fullName\": \"Game King\",\n\t\"businessName\": \"Game King Residency\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/auth/register", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "register" - ] - } - }, - "response": [] - }, - { - "name": "[1.17] Login Student 4", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", - "", - "const body = pm.response.json();", - "pm.environment.set(\"student4AccessToken\", body.data.accessToken);", - "pm.environment.set(\"student4RefreshToken\", body.data.refreshToken);", - "", - "console.log(\"Student 4 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\": \"sumity1642@gmail.com\",\n\t\"password\": \"Test1234\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/auth/login", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "login" - ] - } - }, - "response": [] - }, - { - "name": "[1.18] Send OTP — Student 4 (Brevo live test)", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", - "pm.test(\"OTP sent message present\", () => {", - " pm.expect(pm.response.json().message).to.include(\"OTP\");", - "});", - "", - "console.log(\"=== BREVO TEST ===\");", - "console.log(\"If you used your own email: check your inbox for the OTP.\");", - "console.log(\"If you used rohit.verma@gmail.com: open Brevo dashboard → Transactional → Logs.\");", - "console.log(\"A successful send event confirms Brevo is wired correctly.\");", - "console.log(\"messageId should appear in your server terminal log at info level.\");" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - }, - { - "listen": "prerequest", - "script": { - "exec": [], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{student4AccessToken}}", - "type": "text" - } - ], - "url": { - "raw": "{{baseUrl}}/auth/otp/send", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "otp", - "send" - ] - } - }, - "response": [] - }, - { - "name": "[1.19] Verify OTP Student 4", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", - "pm.test(\"Email verified message\", () => {", - "\tpm.expect(pm.response.json().message).to.include(\"verified\");", - "});", - "", - "console.log(\"Student 3 email is now verified — Brevo round-trip confirmed end-to-end.\");" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - }, - { - "listen": "prerequest", - "script": { - "exec": [], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{student4RefreshToken}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n\t\"otp\": \"749828\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/auth/otp/verify", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "otp", - "verify" - ] - } - }, - "response": [] - }, - { - "name": "[1.20] Login Student 3", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", - "", - "const body = pm.response.json();", - "pm.environment.set(\"student3AccessToken\", body.data.accessToken);", - "pm.environment.set(\"student3RefreshToken\", body.data.refreshToken);", - "", - "console.log(\"Student 3 token refreshed via login\");" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - }, - { - "listen": "prerequest", - "script": { - "exec": [ - "" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n\t\"email\": \"rohit.verma@gmail.com\",\n\t\"password\": \"Test1234\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/auth/login", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "login" - ] - } - }, - "response": [] - }, - { - "name": "[1.21] Send OTP Student 3", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", - "pm.test(\"OTP sent message present\", () => {", - " pm.expect(pm.response.json().message).to.include(\"OTP\");", - "});", - "", - "console.log(\"=== BREVO TEST ===\");", - "console.log(\"If you used your own email: check your inbox for the OTP.\");", - "console.log(\"If you used rohit.verma@gmail.com: open Brevo dashboard → Transactional → Logs.\");", - "console.log(\"A successful send event confirms Brevo is wired correctly.\");", - "console.log(\"messageId should appear in your server terminal log at info level.\");" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - }, - { - "listen": "prerequest", - "script": { - "exec": [], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{student3AccessToken}}", - "type": "text" - } - ], - "body": { - "mode": "formdata", - "formdata": [] - }, - "url": { - "raw": "{{baseUrl}}/auth/otp/send", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "otp", - "send" - ] - } - }, - "response": [] - }, - { - "name": "[1.22] Verify Student 3 OTP", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", - "pm.test(\"Email verified message\", () => {", - "\tpm.expect(pm.response.json().message).to.include(\"verified\");", - "});", - "", - "console.log(\"Student 4 email is now verified — Brevo round-trip confirmed end-to-end.\");" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - }, - { - "listen": "prerequest", - "script": { - "exec": [], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{student3AccessToken}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n\t\"otp\": \"427531\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}/auth/otp/verify", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "auth", - "otp", - "verify" - ] - } - }, - "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;", - "});", - "", - "// Visualization Script", - "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/prompt1.md b/prompt1.md deleted file mode 100644 index d2d1600..0000000 --- a/prompt1.md +++ /dev/null @@ -1,399 +0,0 @@ -# Roomies Backend — Documentation Audit & Update Prompt - -## How to Use This Prompt - -This is a **chained instruction set**. The work is broken into focused phases, each of which references a specific -section of the codebase and the existing documentation. Complete the phases in order. Each phase produces output files -that are inputs for the next. - -The existing documentation lives under `docs/`. The authoritative source of truth for contracts is always the code: -`src/routes/`, `src/validators/`, `src/services/`, `src/middleware/`. - ---- - -## Phase 0 — Orientation (Read Before Doing Anything) - -Before writing a single line of documentation, internalise these rules. They govern every decision in every phase that -follows. - -**Rule 1 — Scenario-based JSON format is the canonical style.** Every endpoint must be documented as a series of named -scenarios. Each scenario has a title, a request contract (method, path, auth, headers, body), and a response block -showing the exact JSON. No prose descriptions of what a field "might" contain — show the actual shape. See the existing -`docs/api/auth.md` for the reference style. - -**Rule 2 — Chained files, not a monolith.** Documentation is split across files linked by hyperlinks. `docs/API.md` is -the front door. Feature docs live in `docs/api/`. No feature doc should grow beyond its own feature boundary. If a new -feature was added, create a new file and link it from `docs/API.md` and from `docs/README.md`. - -**Rule 3 — Code is truth, docs are its reflection.** If the service throws `new AppError("message", 404)`, the doc shows -a `404` response with `{ "status": "error", "message": "message" }`. Never infer or paraphrase the error body — copy it -exactly as the service constructs it. - -**Rule 4 — Status codes come from both routes and services.** Check the controller for the success status code -(`res.status(201)` vs `res.json()`), and check the service for every `AppError`. Also check -`src/middleware/errorHandler.js` for the PostgreSQL constraint codes (23505 → 409, 23503 → 409, 23514 → 400) that -produce implicit error responses. - -**Rule 5 — Document gates as first-class responses.** When a route has middleware that can short-circuit (e.g. -`contactRevealGate`, `authorize`, `guestListingGate`), those middleware responses are endpoint outcomes and must appear -as scenarios. Do not hide them in a footnote. - ---- - -## Phase 1 — Audit the Existing Feature Docs Against Current Code - -For each file listed below, open the doc and the corresponding source files side by side. For every discrepancy you -find, note it and fix it in the doc. The checklist below tells you exactly which source files to cross-reference. - -### 1.1 — `docs/api/auth.md` - -Cross-reference against: - -- `src/routes/auth.js` -- `src/validators/auth.validators.js` -- `src/services/auth.service.js` -- `src/controllers/auth.controller.js` - -Things to verify: - -- The `POST /auth/logout` route does **not** require `authenticate` middleware but `POST /auth/logout/current` does. - Confirm both are accurately described. -- The `DELETE /auth/sessions/:sid` route: confirm the scenario where revoking the **current** session also clears auth - cookies is documented. -- The `parseTtlSeconds` function in `auth.service.js` accepts numeric TTL values in addition to string formats — confirm - the JWT expiry behaviour description is still accurate. -- The `verifyRefreshTokenPayload` function has a legacy token migration path. The doc should note that legacy tokens - (issued before per-session keys) are transparently migrated on first use — callers never need to handle this. -- `POST /auth/google/callback`: three internal paths (returning user, account linking, new registration) — all three - scenarios must be present with their exact response shapes. - -### 1.2 — `docs/api/profiles-and-contact.md` - -Cross-reference against: - -- `src/routes/student.js` -- `src/routes/pgOwner.js` -- `src/validators/student.validators.js` -- `src/validators/pgOwner.validators.js` -- `src/services/student.service.js` -- `src/services/pgOwner.service.js` -- `src/middleware/contactRevealGate.js` - -Things to verify: - -- The student contact reveal route is `GET` but the PG owner contact reveal route is `POST`. Confirm this asymmetry is - documented with its rationale (POST prevents browser prefetch/cache of PII). -- Both reveal endpoints set `Cache-Control: no-store` before the gate runs. This header appears on **all** responses - from those routes — including 429 limit-reached responses. Confirm this is reflected in the scenarios. -- The gate's two-tier model: verified users get unlimited + full bundle; guests/unverified get 10 reveals + email-only. - The `contactRevealGate.js` middleware now uses a **pre-response hook** (wrapping `res.json`, `res.send`, `res.end`) to - charge quota after a successful 2xx response rather than using `res.on("finish")`. The doc doesn't need to describe - the implementation, but it must correctly state that quota is charged only on successful reveals. -- `GET /students/:userId/preferences` and `PUT /students/:userId/preferences` are mounted in `src/routes/student.js` — - confirm they are documented (they appear in `profiles-and-contact.md`). - -### 1.3 — `docs/api/listings.api.md` - -Cross-reference against: - -- `src/routes/listing.js` -- `src/validators/listing.validators.js` -- `src/services/listing.service.js` -- `src/middleware/guestListingGate.js` -- `src/services/listingLifecycle.js` - -Things to verify: - -- Guest access: `GET /listings` and `GET /listings/:listingId` are the only two endpoints that accept unauthenticated - requests. Confirm the doc clearly states this and shows a guest scenario for both. -- `compatibilityAvailable` field: the search response now includes both `compatibilityScore` (integer) and - `compatibilityAvailable` (boolean). The existing doc only shows `compatibilityScore`. Add `compatibilityAvailable` to - all search result item shapes. -- The `guestListingGate` middleware caps the `limit` query param to 20 for guests silently. Document this as part of the - guest access table. -- `EXPIRED_LISTING_MESSAGE` and `UNAVAILABLE_LISTING_MESSAGE` are constants in `listingLifecycle.js`. The exact strings - are `"Listing has expired and is no longer available"` and `"Listing is no longer available"`. Confirm the error - scenarios in the doc show these exact strings, not paraphrases. -- For `PUT /listings/:listingId`: the `422` scenario for updating location fields on a `pg_room` or `hostel_bed` listing - should show the actual message format: - `"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."` - (with the actual field names interpolated). -- The `PATCH /listings/:listingId/status` response does not include `rentPerMonth` or other listing fields — it only - returns `{ listingId, status }`. Confirm the doc shows the correct minimal shape. - -### 1.4 — `docs/api/interests.md` - -Cross-reference against: - -- `src/routes/interest.js` -- `src/routes/listing.js` (for the listing-scoped interest routes) -- `src/validators/interest.validators.js` -- `src/services/interest.service.js` - -Things to verify: - -- The `POST /listings/:listingId/interests` route requires `authorize("student")`. Confirm the doc correctly states this - is student-only. -- The accept transition response includes `whatsappLink` (can be `null` if the poster has no phone) and `listingFilled` - (boolean). Confirm both fields are in the scenario response. -- The `GET /listings/:listingId/interests` route returns `403` when the listing exists but the caller doesn't own it, - and `404` when the listing doesn't exist. These are two distinct error scenarios that must both be documented. -- The `createInterestRequest` service uses `ON CONFLICT ... DO NOTHING` on the partial unique index - `(sender_id, listing_id) WHERE status IN ('pending','accepted')`. This means re-sending an interest request after a - previous one was declined or withdrawn succeeds (it creates a new row). The doc should clarify this behaviour. - -### 1.5 — `docs/api/connections.md` - -Cross-reference against: - -- `src/routes/connection.js` -- `src/validators/connection.validators.js` -- `src/services/connection.service.js` - -Things to verify: - -- The `confirmConnection` service uses a single atomic `UPDATE` with a `CASE WHEN` block to flip the caller's flag and - promote `confirmation_status` to `'confirmed'` when both flags are true. The doc should reflect that this is a single - atomic operation, not two steps. -- `GET /connections/me` supports `confirmationStatus` and `connectionType` query filters. Confirm both are documented - with their valid enum values. - -### 1.6 — `docs/api/ratings-and-reports.md` - -Cross-reference against: - -- `src/routes/rating.js` -- `src/validators/rating.validators.js` -- `src/validators/report.validators.js` -- `src/services/rating.service.js` -- `src/services/report.service.js` - -Things to verify: - -- `GET /ratings/user/:userId` and `GET /ratings/property/:propertyId` are public (no auth). Confirm the doc does not - mention an auth requirement for these two endpoints. -- The `submitRating` zero-rowCount disambiguation: the service checks for three distinct failure cases (connection not - found/not party → 404, connection unconfirmed → 422, duplicate rating → 409). All three should be distinct scenarios. -- The `resolveReport` endpoint's `adminNotes` field: it is required when `resolution = "resolved_removed"` and optional - otherwise. This cross-field constraint must be visible in the doc. -- The `submitReport` service uses an atomic `INSERT ... SELECT ... FROM ratings JOIN connections` to enforce party - membership. The 404 message is `"Rating not found or you are not a party to this connection"`. Confirm this exact - string is shown. - -### 1.7 — `docs/api/notifications.md` - -Cross-reference against: - -- `src/routes/notification.js` -- `src/validators/notification.validators.js` -- `src/services/notification.service.js` -- `src/workers/notificationWorker.js` - -Things to verify: - -- The `POST /notifications/mark-read` validator uses `z.literal(true)` for the `all` field, meaning `{ all: false }` is - a validation error, not a no-op. The doc should show the validation error scenario for `all: false` (it currently only - shows the scenario for providing both modes simultaneously). -- The `NOTIFICATION_MESSAGES` map in `notificationWorker.js` is the source of truth for message text. Verify that the - notification type table in the doc matches the map exactly — including `listing_expired` (distinct from - `listing_expiring`), `listing_filled`, and `connection_requested` (currently PLANNED, no emitter yet). - -### 1.8 — `docs/api/health.md` - -Cross-reference against: - -- `src/routes/health.js` - -This file is likely accurate. Quick check: confirm the `503` degraded response shape uses `"timeout"` as a service -status string (not `"timed_out"` or any other variant) — sourced from `isTimeoutError()` in `health.js`. - ---- - -## Phase 2 — Document New or Undocumented Features - -The following features are either missing from the docs entirely or only partially covered. Create new sections or new -files as needed. - -### 2.1 — Admin Feature Doc (`docs/api/admin.md`) - -This file is referenced in `docs/API.md` and `docs/README.md` but check whether it contains full scenario-based -documentation for all admin endpoints. The admin surface is: - -**Verification queue** (in `src/routes/admin.js`, served by `src/services/verification.service.js`): - -- `GET /admin/verification-queue` — paginated, oldest-first -- `POST /admin/verification-queue/:requestId/approve` -- `POST /admin/verification-queue/:requestId/reject` - -**Report queue** (in `src/routes/admin.js`, served by `src/services/report.service.js`): - -- `GET /admin/report-queue` — paginated, oldest-first -- `PATCH /admin/reports/:reportId/resolve` - -All five endpoints share the same auth requirement: `authenticate` + `authorize('admin')` enforced at the router level. -Every request to any route under `/admin` that lacks a valid admin JWT produces a `403` before the route handler runs. - -For each endpoint, document every scenario the service can produce, including the concurrent-state `409` (e.g. approving -an already-resolved request), the `404` party check where applicable, and the cross-field validation error on -`resolveReport` (adminNotes required for `resolved_removed`). - -### 2.2 — Preferences Feature Doc (`docs/api/preferences.md`) - -This file exists but verify it is complete. The full preferences surface is: - -- `GET /preferences/meta` — returns the PREFERENCE_DEFINITIONS catalog (served by `src/services/preferences.service.js`) -- `GET /students/:userId/preferences` — owner-only, returns the student's current preference profile -- `PUT /students/:userId/preferences` — owner-only, full replace (empty array clears all) - -The `dedupePreferencesByKey` function in `src/config/preferences.js` applies last-write-wins when duplicate keys are -submitted. Document this as a named scenario: "Request with duplicate preference keys — last value wins, no error -returned." - -The allowed preference keys and values come from `PREFERENCE_DEFINITIONS` in `src/config/preferences.js`. The doc should -include the full current catalog so integrators don't need to call `/preferences/meta` just to know valid values. - -### 2.3 — Cron Job Behaviour in `docs/API.md` - -The "Background Jobs That Affect API Behavior" section in `docs/API.md` lists three cron jobs. Verify the descriptions -match the actual schedules and behaviour in the cron files: - -- `src/cron/listingExpiry.js` — runs at `0 2 * * *` (02:00 daily), transitions `active` listings to `expired` where - `expires_at < NOW()`, then bulk-expires pending interest requests. Overridable via `CRON_LISTING_EXPIRY` env var. -- `src/cron/expiryWarning.js` — runs at `0 1 * * *` (01:00 daily), enqueues `listing_expiring` notifications for - listings expiring within 7 days. Uses an idempotency key of format `expiry_warning:{listing_id}:{YYYY-MM-DD}` to - prevent duplicate warnings per calendar day. Overridable via `CRON_EXPIRY_WARNING`. -- `src/cron/hardDeleteCleanup.js` — runs at `0 4 * * 0` (04:00 every Sunday), hard-deletes soft-deleted rows older than - `SOFT_DELETE_RETENTION_DAYS` (default 90 days). Overridable via `CRON_HARD_DELETE`. - -The `SOFT_DELETE_RETENTION_DAYS` variable has a strict format requirement: plain decimal integer only (e.g. `"90"`, not -`"90days"` or `"-30"`). Document this in `docs/API.md` under the background jobs section. - ---- - -## Phase 3 — Update `docs/TechStack.md` for Recent Changes - -Cross-reference against `src/config/env.js` and `src/workers/emailWorker.js`. - -The `EMAIL_PROVIDER` enum now includes `"brevo-api"` as a third valid value alongside `"ethereal"` and `"brevo"`. The -TechStack doc's email section should describe all three options. - -The email delivery pipeline has moved from direct `sendMail()` calls in `auth.service.js` to a BullMQ queue -(`email-delivery`) processed by `src/workers/emailWorker.js`. Update the BullMQ queues table in `docs/TechStack.md` to -add: - -| Queue name | Worker file | Concurrency | Used for | -| ---------------- | ---------------- | ------------- | ------------------------------------------------------------- | -| `email-delivery` | `emailWorker.js` | 3 (I/O-bound) | OTP emails, verification status emails via CDC outbox drainer | - -Also document the new CDC outbox worker (`src/workers/verificationEventWorker.js`) which is not a BullMQ worker but a -`setInterval`-based polling loop. It reads from `verification_event_outbox` (written by the -`trg_verification_status_changed` Postgres trigger), processes events, and enqueues both in-app notifications and -emails. This should be explained in its own subsection under the Workers section. - ---- - -## Phase 4 — Update `docs/ImplementationPlan.md` - -This file tracks phase and branch status. The following updates are needed: - -**Phase 5 status correction:** `phase5/cron` is marked ✅ MERGED. Verify `phase5/admin` is still ⏳ NOT STARTED and that -the "What needs building" list accurately reflects what the `emailWorker.js` already provides (the email worker is done -— remove it from the "needs building" list) versus what is genuinely still missing (admin user management endpoints, -analytics endpoint, admin rating visibility endpoints). - -**Source file inventory additions:** The following files exist in the codebase and should be added to the inventory if -they are not already listed: - -- `src/workers/emailWorker.js` -- `src/workers/emailQueue.js` -- `src/workers/verificationEventWorker.js` -- `src/middleware/guestListingGate.js` - -**DB trigger reference table:** The `trg_verification_status_changed` trigger (introduced in migration 002) should be -added to the trigger table with its table, firing condition, and action described. - ---- - -## Phase 5 — Update `docs/Deployment.md` and Deployment Tier Docs - -The deployment docs are spread across `docs/Deployment.md` (the older combined guide) and the newer -`docs/deployment/tier0.md`, `docs/deployment/tier1.md`, `docs/deployment/tier2.md`. - -**Priority check for `docs/deployment/tier0.md`:** This is the most operationally relevant guide. Verify that it -accurately reflects the `brevo-api` email provider requirement (SMTP is blocked on Render free tier — this is already -addressed in Phase 1 of the tier0 guide with the code changes). Confirm that the env var `EMAIL_PROVIDER=brevo-api` is -correctly used throughout the setup instructions and that `BREVO_API_KEY` (not `BREVO_SMTP_KEY`) is what this mode -requires. - -**`TRUST_PROXY` in tier0 guide:** The Render deployment must set `TRUST_PROXY=1`. Verify the Render environment variable -table in Phase 7.2 of `docs/deployment/tier0.md` includes this row. - -**`NODE_ENV` in `.env.render`:** The current `.env.render` file has `NODE_ENV=development`. The tier0 guide Phase 7.2 -table correctly lists `NODE_ENV=production`. Add a callout in the guide noting that the `.env.render` file is for local -testing against live Tier 0 services and therefore uses `development`, whereas the actual Render dashboard environment -variable should be `production`. This distinction must be explicit to avoid confusion. - ---- - -## Phase 6 — Final Consistency Pass - -After completing all phases above, do a final sweep: - -**Check all hyperlinks.** Every cross-reference between doc files (e.g. `docs/API.md` → `docs/api/auth.md`) should -resolve correctly. If you created a new file, make sure it is linked from `docs/README.md` and from `docs/API.md`. - -**Check example UUIDs.** The conventions file (`docs/api/conventions.md`) defines canonical example UUIDs (student = -`11111111-...`, PG owner = `22222222-...`, etc.). Every scenario in every feature doc should use these consistent -example values, not random UUIDs. - -**Check the HTTP status code table.** `docs/API.md` section 20 has a table of status codes. Verify that `202` (photo -upload async) and `503` (service unavailable, including the queue-unavailable response from `photo.service.js`) are -present and described accurately. - -**Check the pagination section.** `docs/API.md` section 19 documents keyset pagination. Verify it mentions the -constraint that `cursorTime` and `cursorId` must always be provided together, and that providing one without the other -returns a `400`. - ---- - -## Deliverables Checklist - -When all phases are complete, the following should be true: - -- [ ] Every endpoint in `src/routes/` has at least one success scenario and all failure scenarios documented in a - `docs/api/` file. -- [ ] Every file in `docs/api/` has been reviewed against current source code and any stale scenarios corrected. -- [ ] `docs/README.md` links to every file in `docs/api/`. -- [ ] `docs/API.md` feature docs list is complete and all links resolve. -- [ ] `docs/TechStack.md` BullMQ queue table includes all three current workers. -- [ ] `docs/ImplementationPlan.md` source file inventory includes all current workers and middleware. -- [ ] `docs/deployment/tier0.md` correctly documents `EMAIL_PROVIDER=brevo-api`, `TRUST_PROXY=1`, and the `NODE_ENV` - distinction between the file and the Render dashboard. -- [ ] All cross-file hyperlinks resolve correctly. -- [ ] All scenario response bodies use the exact strings from the source code, not paraphrases. - ---- - -## Reference: Quick-Lookup Source Files - -Use this table when auditing a specific feature to know exactly which files to open: - -| Feature | Routes | Validators | Service | Controller | -| ------------------------ | -------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------- | -| Auth | `routes/auth.js` | `validators/auth.validators.js` | `services/auth.service.js` | `controllers/auth.controller.js` | -| Student profiles & prefs | `routes/student.js` | `validators/student.validators.js` | `services/student.service.js` | `controllers/student.controller.js` | -| PG owner profiles | `routes/pgOwner.js` | `validators/pgOwner.validators.js` | `services/pgOwner.service.js` | `controllers/pgOwner.controller.js` | -| Properties | `routes/property.js` | `validators/property.validators.js` | `services/property.service.js` | `controllers/property.controller.js` | -| Listings | `routes/listing.js` | `validators/listing.validators.js` | `services/listing.service.js` | `controllers/listing.controller.js` | -| Photos | `routes/listing.js` | `validators/photo.validators.js` | `services/photo.service.js` | `controllers/photo.controller.js` | -| Interests | `routes/interest.js`, `routes/listing.js` | `validators/interest.validators.js` | `services/interest.service.js` | `controllers/interest.controller.js` | -| Connections | `routes/connection.js` | `validators/connection.validators.js` | `services/connection.service.js` | `controllers/connection.controller.js` | -| Notifications | `routes/notification.js` | `validators/notification.validators.js` | `services/notification.service.js` | `controllers/notification.controller.js` | -| Ratings | `routes/rating.js` | `validators/rating.validators.js` | `services/rating.service.js` | `controllers/rating.controller.js` | -| Reports | `routes/rating.js` | `validators/report.validators.js` | `services/report.service.js` | `controllers/report.controller.js` | -| Verification (admin) | `routes/admin.js` | `validators/verification.validators.js` | `services/verification.service.js` | `controllers/verification.controller.js` | -| Preferences | `routes/preferences.js`, `routes/student.js` | `validators/preferences.validators.js` | `services/preferences.service.js` | `controllers/preferences.controller.js` | -| Contact reveal (gate) | `routes/student.js`, `routes/pgOwner.js` | — | `services/student.service.js`, `services/pgOwner.service.js` | `middleware/contactRevealGate.js` | -| Health | `routes/health.js` | — | — | — | -| Error shapes | — | — | — | `middleware/errorHandler.js` | -| Cron behaviour | — | — | `cron/listingExpiry.js`, `cron/expiryWarning.js`, `cron/hardDeleteCleanup.js` | — | diff --git a/prompt2.md b/prompt2.md deleted file mode 100644 index ebb90c2..0000000 --- a/prompt2.md +++ /dev/null @@ -1,806 +0,0 @@ -# Roomies Backend — Documentation Audit & Comprehensive Update Prompt - -## Deployment Reality (Read This First — It Overrides All Doc References to Azure) - -The live production API is hosted on **Render**, not Azure. Every doc section that references Azure App Service as the -hosting layer is describing a _planned future migration_, not the current live environment. The real service topology as -of today is: - -| Component | Actual service | -| ------------- | ----------------------------------------------------------------------- | -| API host | Render free tier — `https://roomies-api.onrender.com` | -| API base URL | `https://roomies-api.onrender.com/api/v1` | -| Health check | `https://roomies-api.onrender.com/api/v1/health` | -| Database | Neon PostgreSQL 16 + PostGIS (pooler endpoint, `ap-southeast-1`) | -| Redis | Upstash — `rediss://...upstash.io:6379` (TLS, port 6379 not 6380) | -| Blob storage | Azure Blob Storage — `roomiesblob` account, `roomies-uploads` container | -| Email (prod) | `EMAIL_PROVIDER=brevo-api` (Brevo REST API, **not** SMTP relay) | -| Email (local) | `EMAIL_PROVIDER=brevo` (Brevo SMTP) or `EMAIL_PROVIDER=ethereal` | - -### Critical env-to-code divergence (must be resolved before any docs are finalised) - -The file `src/config/env.js` defines the `EMAIL_PROVIDER` enum as: - -```js -EMAIL_PROVIDER: z.enum(["ethereal", "brevo"], { ... }).default("ethereal") -``` - -Both `.env` (the root file used for quick local runs) and `.env.render` (the exact env vars loaded by the live Render -service) set `EMAIL_PROVIDER=brevo-api`. This value is **not in the enum** — Zod `safeParse` will fail, the startup -guard will `process.exit(1)`, and the server will never start. One of the following must be true and must be determined -before this audit continues: - -1. The server is crashing on startup on Render right now (most likely: the env var leaks past Zod because Render sets - env vars natively without loading a `.env` file, and the `.env.render` file is for local reference only — meaning the - Render dashboard vars may differ from the file). If so, document the dashboard values separately. -2. A code change has been made to `env.js` that accepts `"brevo-api"` but the change is not yet reflected in the - codebase provided. If so, document the three-value enum and the new provider branch throughout the TechStack and - Deployment docs. - -Either way, **every doc that touches `EMAIL_PROVIDER` must be updated** once this is resolved. The enum is referenced in -`docs/TechStack.md`, `docs/deployment/tier0.md`, `docs/Deployment.md`, and the environment variable tables in -`docs/API.md`. - -Additional env discrepancies that must be resolved: - -- `.env.render` sets `TRUST_PROXY=false`. The running API is behind Render's reverse proxy, which means `req.ip` - resolves to `::ffff:10.x.x.x` (Render's internal address) instead of the real client IP. The OTP IP-rate-limiter in - `auth.service.js` becomes effectively a no-op because every request looks like it comes from the same internal IP. The - correct value is `TRUST_PROXY=1`. Document this as both a security issue and a required configuration fix in the - Render docs. - -- `.env.render` sets `ALLOWED_ORIGINS=http://localhost:5173`. In production on Render, this means every credentialed - cross-origin request from any frontend domain other than `localhost:5173` is rejected by the CORS middleware guard in - `app.js`. Document the correct procedure for updating this when a frontend is deployed. - -- `.env.render` sets `NODE_ENV=production` (correct). The old tier-0 Azure doc said the `.env.render` file uses - `development` to allow local testing against live services. This is no longer accurate — the file is now the literal - copy of what Render runs. - ---- - -## How to Use This Prompt - -This is a **chained instruction set with real-world grounding**. Every phase produces or corrects doc files that the -next phase depends on. Complete the phases in strict order. When a phase references a scenario, write it as though a -real developer is integrating against the live API at `https://roomies-api.onrender.com/api/v1` — use realistic request -bodies, realistic error messages verbatim from the source code, and realistic response shapes. Never paraphrase an error -string from the service layer; copy it exactly. - -The source-of-truth hierarchy is: - -1. The actual source files in `src/` — always wins. -2. The env files as described above — governs deployment docs. -3. The existing docs in `docs/` — to be corrected where they diverge from (1) and (2). - ---- - -## Phase 0 — Governing Rules (Apply to Every Phase) - -Before writing a single line of documentation, internalise these rules. They govern every decision in every phase that -follows. - -**Rule 1 — Scenario-based JSON is the only acceptable format for endpoint documentation.** Every endpoint must be -documented as a series of named scenarios. Each scenario carries: a plain-English title that describes the real-world -situation triggering it; the full request contract (method, path, auth requirement, relevant headers, validated query -params, and exact body shape); and the response block showing the complete JSON. Response bodies must match what the -code actually returns — check the controller's `res.status().json()` call and the service's return value. Never say a -field "might" contain something; show what it actually contains. - -**Rule 2 — Code is truth; docs are its shadow.** If `auth.service.js` throws -`new AppError("Incorrect OTP — 4 attempts remaining", 400)`, the doc shows `400` with -`{ "status": "error", "message": "Incorrect OTP — 4 attempts remaining" }`. The `4` is not a placeholder — it is the -actual string constructed by the service at runtime, and the doc must use the same template. The same applies to every -`AppError` in every service. - -**Rule 3 — Gates are first-class endpoint outcomes.** When a route chain is -`optionalAuthenticate → validate → guestListingGate → controller`, the gate's short-circuit responses are as real as the -controller's success response. Document them as named scenarios with their own request context (e.g. "guest sends -request without a token") and response shape. Never hide gate behaviour in a footnote. - -**Rule 4 — Status codes come from both the controller and the service.** The controller determines the success code -(`res.status(201).json(...)` vs `res.json(...)`). The service determines every error code via `AppError`. The global -error handler in `src/middleware/errorHandler.js` determines what PostgreSQL constraint codes map to: `23505 → 409`, -`23503 → 409`, `23514 → 400`. All three sources must be checked for every endpoint's full scenario list. - -**Rule 5 — Real-world completeness means every path through the code is a scenario.** A service function that has three -different `throw new AppError(...)` calls represents three different failure scenarios. Each one gets its own named -scenario block. The scenario name should describe the real-world condition, not the technical mechanism — "Poster tries -to accept an already-accepted request" rather than "ON CONFLICT fires on interest_requests". - -**Rule 6 — Cross-references must be hyperlinks, not prose.** Every time a doc says "see the auth flow" it must link to -`./auth.md`. Every time it references a pagination pattern it must link to the conventions doc or the pagination section -of `API.md`. Every time a new file is created it must be linked from `docs/README.md` and `docs/API.md`. -Cross-references are a navigability contract, not a stylistic choice. - -**Rule 7 — Example values are canonical and must be consistent.** The file `docs/api/conventions.md` defines canonical -UUIDs. Every scenario in every feature doc uses those exact UUIDs. No scenario invents a new UUID. This rule exists so -that a developer reading multiple feature docs while building an integration flow sees the same identifiers everywhere -and can mentally compose the scenarios into a coherent sequence. - -**Rule 8 — The paise rule is a documentation contract, not just an implementation detail.** Every endpoint that involves -`rentPerMonth` or `depositAmount` must explicitly state in its request contract table that values are accepted and -returned in rupees, even though the database stores paise. A developer who does not know this will send `850000` -expecting ₹8,500 and get ₹8.5M rent on a listing. - ---- - -## Phase 1 — Audit Every Existing Feature Doc Against Current Source - -For each subsection, open the doc file and the listed source files simultaneously. For every discrepancy, note it, -correct it in place, and add whatever scenarios are missing. The subsections below tell you exactly where to look and -what to verify. - -### 1.1 — `docs/api/auth.md` - -Source files: `src/routes/auth.js`, `src/validators/auth.validators.js`, `src/services/auth.service.js`, -`src/controllers/auth.controller.js`. - -Verify and correct each of the following: - -**Logout route asymmetry.** `POST /auth/logout` does not require `authenticate` middleware — it reads the refresh token -from `req.body.refreshToken ?? req.cookies?.refreshToken` and calls `logoutByRefreshToken`. `POST /auth/logout/current` -does require `authenticate` — it reads `req.user.sid` from the JWT to identify the exact session to revoke. The doc must -show both endpoints, and the `/logout/current` doc must clearly state the access-token requirement. - -**Revoking the current session clears cookies.** In `auth.controller.js`, `revokeSession` calls `clearAuthCookies(res)` -when `req.user.sid === sid`. This means deleting your own session via `DELETE /auth/sessions/:sid` also clears the -browser's `accessToken` and `refreshToken` cookies. This side-effect is a real-world outcome that must appear as a named -scenario: "Caller revokes their current session — cookies are cleared in the same response." - -**`parseTtlSeconds` accepts numeric values.** The function accepts `number`, digit-only string, and `"15m"`/`"7d"` -format strings. The doc's description of JWT expiry behaviour must reflect that `JWT_EXPIRES_IN=900` (a plain integer) -is valid and means 900 seconds, not 900 milliseconds. - -**Legacy token migration.** `verifyRefreshTokenPayload` transparently migrates tokens that were issued before -per-session keys were introduced — they lack a `sid` field. When a legacy token arrives, the function generates a new -`sid`, writes the per-session key, deletes the legacy key, and returns normally. Callers never see a migration-related -error. The doc must include a note in the "Integrator Notes" section stating that legacy tokens are migrated -transparently on first use — callers do not need to handle this. - -**Google OAuth three paths.** `POST /auth/google/callback` has three distinct internal branches with distinct real-world -meanings: - -- Path 1 (returning user, found by `google_id`): user previously signed in with Google. -- Path 2 (account linking, found by email, `google_id IS NULL`): user has an existing email/password account and is - linking it to Google for the first time. -- Path 3 (new registration): brand-new user, no existing account by email or Google ID. - -All three must appear as named scenarios with the exact response shape. Path 2's scenario title should be "Existing -email/password account linked to Google for the first time." Path 3 has sub-scenarios for `student` and `pg_owner` roles -because the required body fields differ. - -The failure scenarios for the Google callback endpoint that must all be present: - -- `role` missing on new registration → `400` -- `fullName` missing on new registration → `400` -- `businessName` missing when role is `pg_owner` → `400` -- Google token invalid or expired → `401` -- Google account email not verified → `400` -- Google account already linked to a different user → `409` -- Local account already linked to a different Google account → `409` -- Google OAuth not configured on the server (no `GOOGLE_CLIENT_ID` env var) → `503` - -**OTP verify scenarios are incomplete in most versions of this doc.** Verify all of these are present as distinct named -scenarios with exact message strings from `auth.service.js`: - -- Correct OTP on first attempt → `200` with `"Email verified successfully"` -- Wrong OTP with attempts remaining → `400` with the dynamic `"Incorrect OTP — N attempts remaining"` string -- Wrong OTP on the fifth attempt → `429` with `"Too many incorrect attempts — request a new OTP"` -- OTP expired or never sent → `400` with `"OTP has expired or was never sent — request a new one"` -- IP rate limit tripped → `429` with `"Too many OTP verification attempts from this IP — please wait 15 minutes"` -- Redis unavailable (fail-closed) → `429` with `"OTP verification is temporarily unavailable"` - -### 1.2 — `docs/api/profiles-and-contact.md` - -Source files: `src/routes/student.js`, `src/routes/pgOwner.js`, `src/validators/student.validators.js`, -`src/validators/pgOwner.validators.js`, `src/services/student.service.js`, `src/services/pgOwner.service.js`, -`src/middleware/contactRevealGate.js`. - -Verify and correct each of the following: - -**HTTP method asymmetry.** The student contact reveal route is `GET /students/:userId/contact/reveal`. The PG owner -contact reveal route is `POST /pg-owners/:userId/contact/reveal`. This asymmetry is intentional — `POST` prevents -browser prefetch and intermediate proxy caching of PII responses. The doc must state this rationale explicitly, not just -note the difference. - -**`Cache-Control: no-store` appears on all responses from these routes, not just successes.** In -`src/routes/student.js`, the `no-store` header middleware runs before `contactRevealGate`. In `src/routes/pgOwner.js`, -it also runs before the gate. This means the `429` limit-reached response also carries `Cache-Control: no-store`. Every -scenario for both reveal endpoints — including the `429` — must show `Cache-Control: no-store` in the response headers -section. - -**Gate charges quota only on successful 2xx reveals.** The `contactRevealGate` uses a pre-response hook (wrapping -`res.json`, `res.send`, `res.end`) to increment the Redis counter only when the response status is 2xx. A `404` (user -not found) does not consume a reveal slot. The doc must state this clearly: "Quota is charged only when the reveal is -successful (2xx response). A 404 or 500 does not consume a free reveal." - -**Two-tier model details.** Verified users (authenticated + `isEmailVerified === true`) get unlimited reveals and the -full contact bundle. Guests and unverified authenticated users get at most 10 reveals per 30-day rolling window and -receive only the email — never `whatsapp_phone`. The doc must show the exact response shape for both tiers, and the -shapes must differ only in the presence/absence of `whatsapp_phone`. - -**Preferences routes are mounted in `student.js`.** `GET /students/:userId/preferences` and -`PUT /students/:userId/preferences` are on the student router, not a separate preferences router. Both are owner-only -(caller must match `:userId`). The `PUT` uses the `dedupePreferencesByKey` function from `src/config/preferences.js`, -which implements last-write- wins when duplicate `preferenceKey` values appear in the same request. The doc must include -a named scenario: "Request with duplicate preference keys — last value for each key wins, no error returned." The -example body should send `smoking` twice with different values. - -### 1.3 — `docs/api/listings.api.md` - -Source files: `src/routes/listing.js`, `src/validators/listing.validators.js`, `src/services/listing.service.js`, -`src/middleware/guestListingGate.js`, `src/services/listingLifecycle.js`. - -Verify and correct each of the following: - -**Guest access is a first-class policy that must be documented in a prominent table.** `GET /listings` and -`GET /listings/:listingId` are the only two endpoints that accept unauthenticated requests. All other listing endpoints -return `401` for guests. The doc must include a table at the top of the file that shows this policy at a glance: - -| Caller | `GET /listings` | `GET /listings/:listingId` | Save | Interest | Compatibility | -| ------------- | ---------------- | -------------------------- | --------------- | --------------- | ------------------- | -| Guest | ✅ max 20 items | ✅ full detail | ❌ 401 | ❌ 401 | ❌ always 0 | -| Authenticated | ✅ max 100 items | ✅ full detail | ✅ student only | ✅ student only | ✅ when prefs exist | - -**`compatibilityAvailable` is missing from the existing doc.** The search result item shape now includes two related -fields: `compatibilityScore` (integer, the count of matching preference pairs) and `compatibilityAvailable` (boolean, -true only when both the requesting user has saved preferences AND the listing has at least one preference set). For -guests, both are always `0` and `false`. For authenticated users with no preferences, `compatibilityScore` is `0` and -`compatibilityAvailable` is `false`. Add `compatibilityAvailable` to every search result item shape in the doc. - -**`guestListingGate` silently caps the `limit` param to 20 for guests.** The middleware in -`src/middleware/guestListingGate.js` rewrites `req.query.limit` to `20` if a guest requests more. The cap is silent — no -error, no warning header. Document this behaviour in the guest access table: "Guests receive at most 20 items per -request regardless of the `limit` query parameter." - -**Lifecycle error message strings must be exact.** `src/services/listingLifecycle.js` exports: - -```js -export const EXPIRED_LISTING_MESSAGE = "Listing has expired and is no longer available"; -export const UNAVAILABLE_LISTING_MESSAGE = "Listing is no longer available"; -``` - -These exact strings appear in `422` responses whenever an operation is attempted on an expired or non-active listing. -Verify every expired/unavailable scenario in the doc uses these exact strings, not paraphrases like "listing is expired" -or "listing unavailable". - -**`PUT /listings/:listingId` location-field rejection message must be exact.** When a caller tries to update `city`, -`latitude`, or any other property-owned location field on a `pg_room` or `hostel_bed` listing, the service throws: - -``` -`Location fields (${forbiddenFields.join(", ")}) cannot be updated on a ${listingType} listing — ` + -`they are inherited from the parent property. Update the property's address instead.` -``` - -The doc's `422` scenario must use the exact interpolated format with actual field names. The example scenario should -show a request that sends both `city` and `latitude`, and the message should read: -`"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."` - -**`PATCH /listings/:listingId/status` returns only `{ listingId, status }`.** The service function `updateListingStatus` -returns `{ listingId, status: newStatus }`. It does not return `rentPerMonth`, `title`, or any other listing field. The -existing doc may show a richer response shape — verify it is trimmed to match exactly. - -**The status transition table must include the full terminal-state rules:** - -- `active → filled` ✅ -- `active → deactivated` ✅ -- `deactivated → active` ✅ (only if listing has not expired) -- `filled → *` ❌ terminal, no transitions out -- `expired → *` ❌ terminal, set only by cron — not exposed as a valid target in `updateListingStatusSchema` - -### 1.4 — `docs/api/interests.md` - -Source files: `src/routes/interest.js`, `src/routes/listing.js`, `src/validators/interest.validators.js`, -`src/services/interest.service.js`. - -Verify and correct each of the following: - -**`POST /listings/:listingId/interests` is student-only.** The route applies `authorize("student")` middleware. The doc -must state this explicitly in the request contract and include a named failure scenario: "Non-student role (e.g. PG -owner) attempts to send interest" → `403` with `"Forbidden"`. - -**Accept transition response must include both `whatsappLink` and `listingFilled`.** When a poster accepts an interest -request via `PATCH /interests/:interestId/status`, the `_acceptInterestRequest` service function returns: - -```js -{ interestRequestId, studentId, listingId, status: "accepted", connectionId, whatsappLink, listingFilled } -``` - -The `whatsappLink` is `null` when the poster has no phone number on file. The `listingFilled` boolean is `true` when -this acceptance exhausted the listing's `total_capacity`. Both fields must appear in the scenario response with their -types and null-conditions documented. - -**`GET /listings/:listingId/interests` has two distinct 403/404 scenarios.** In `getInterestRequestsForListing`, the -service first checks whether the listing exists at all, then whether the caller owns it. These produce different -responses: - -- Listing exists, but caller is not the owner → `403` with `"You do not own this listing"` -- Listing does not exist → `404` with `"Listing not found"` - -Both must be distinct named scenarios. The current doc may merge them or show only one. - -**Re-sending after decline is allowed.** The `createInterestRequest` service uses -`ON CONFLICT (sender_id, listing_id) WHERE status IN ('pending', 'accepted') DO NOTHING`. This partial unique index only -blocks duplicate active requests. A student whose previous interest was `declined` or `withdrawn` can successfully send -a new request — the `ON CONFLICT` clause does not match those terminal states, so a fresh `INSERT` succeeds. The doc -must include a named scenario: "Student re-sends interest after their previous request was declined — new request is -created successfully." - -### 1.5 — `docs/api/connections.md` - -Source files: `src/routes/connection.js`, `src/validators/connection.validators.js`, -`src/services/connection.service.js`. - -Verify and correct each of the following: - -**`confirmConnection` uses a single atomic UPDATE.** The service does not perform two separate database operations (flip -flag, then check if both are true). It issues a single `UPDATE` with a `CASE WHEN` block that simultaneously flips the -caller's confirmation flag and conditionally promotes `confirmation_status` to `'confirmed'` if both flags are now true. -The doc must say: "The confirmation and the potential status promotion to `'confirmed'` happen in a single atomic -database operation." This matters for integrators who might worry about race conditions. - -**`GET /connections/me` supports `confirmationStatus` and `connectionType` filters.** Verify the doc shows both filters -with their full enum values: - -- `confirmationStatus`: `"pending"`, `"confirmed"`, `"denied"`, `"expired"` -- `connectionType`: `"student_roommate"`, `"pg_stay"`, `"hostel_stay"`, `"visit_only"` - -Both are optional. The absence of a filter returns all connections regardless of that dimension. - -### 1.6 — `docs/api/ratings-and-reports.md` - -Source files: `src/routes/rating.js`, `src/validators/rating.validators.js`, `src/validators/report.validators.js`, -`src/services/rating.service.js`, `src/services/report.service.js`. - -Verify and correct each of the following: - -**`GET /ratings/user/:userId` and `GET /ratings/property/:propertyId` are public.** Both routes apply -`publicRatingsLimiter` but no `authenticate` middleware. The doc must not list any auth requirement for these two -endpoints, and must not say "Auth required." - -**`submitRating` zero-rowCount discrimination.** When the atomic `INSERT ... SELECT ... WHERE EXISTS` returns zero rows, -the service performs a follow-up query to distinguish three cases: - -1. Connection not found or caller not a party → `404` with `"Connection not found"` -2. Connection exists but `confirmation_status !== 'confirmed'` → `422` with - `"Ratings can only be submitted for confirmed connections"` -3. Duplicate rating (ON CONFLICT fired) → `409` with - `"You have already submitted a rating for this connection and reviewee"` - -All three must be distinct named scenarios. The `422` and `409` cases are especially easy to conflate but represent -entirely different real-world situations. - -**`resolveReport` cross-field constraint.** `adminNotes` is required when `resolution = "resolved_removed"` and optional -when `resolution = "resolved_kept"`. This is enforced both in `src/validators/report.validators.js` (Zod `.refine()`) -and in `src/services/report.service.js` (service-layer guard). The doc must show a named failure scenario: "Admin -submits `resolved_removed` without `adminNotes`" → `400` validation error with -`"adminNotes is required when resolution is resolved_removed"`. - -**`submitReport` party-membership check exact error string.** The service uses: - -```js -throw new AppError("Rating not found or you are not a party to this connection", 404); -``` - -This exact string must appear in the `404` scenario. The intentional vagueness of "or you are not a party" is the -privacy-preserving design — the response never confirms whether the rating exists to an unauthorised caller. - -### 1.7 — `docs/api/notifications.md` - -Source files: `src/routes/notification.js`, `src/validators/notification.validators.js`, -`src/services/notification.service.js`, `src/workers/notificationWorker.js`. - -Verify and correct each of the following: - -**`all: false` is a validation error, not a no-op.** The `markReadSchema` uses `z.literal(true)` for the `all` field. -Sending `{ "all": false }` fails Zod validation and returns `400` before the service is ever called. The doc must -include a named scenario: "Client sends `{ all: false }`" → `400` validation error. This is distinct from the "both -modes supplied simultaneously" scenario. The expected error body is a standard Zod validation error with a field error -on `body.all`. - -**Notification type table must exactly match `NOTIFICATION_MESSAGES` in `notificationWorker.js`.** Cross-check every key -in the map against the table in the doc. The complete current list is: `interest_request_received`, -`interest_request_accepted`, `interest_request_declined`, `interest_request_withdrawn`, `connection_confirmed`, -`rating_received`, `listing_expiring`, `listing_expired`, `listing_filled`, `verification_approved`, -`verification_rejected`, `new_message`, `connection_requested`. - -Note the distinction between `listing_expiring` (fired by the `expiryWarning` cron, 7 days before expiry) and -`listing_expired` (fired by the `listingExpiry` cron, at the moment of expiry). Both must be in the table with correct -trigger descriptions. - -`connection_requested` is marked PLANNED in the worker — no emitter exists in the codebase. The doc must label it -PLANNED and state that no code currently enqueues this type. - -### 1.8 — `docs/api/health.md` - -Source file: `src/routes/health.js`. - -Verify that the `503` degraded response shape uses `"timeout"` as the service status string for timed-out probes. The -`isTimeoutError()` function in `health.js` checks for `ETIMEDOUT`, `ECONNABORTED`, `ESOCKETTIMEDOUT`, `TimeoutError`, -`AbortError`, and a regex `/timed?\s*out/i` on the message. When any of those conditions match, the service status is -set to `"timeout"`. When they do not match, the service status is `"unhealthy"`. The doc must show scenarios for both -`"timeout"` and `"unhealthy"` for both database and Redis. - ---- - -## Phase 2 — Document Missing or Incomplete Features - -### 2.1 — `docs/api/admin.md` - -This file is referenced from `docs/API.md` and `docs/README.md`. Verify it contains full scenario-based documentation -for all five admin endpoints, and add whatever is missing. - -All five endpoints share `authenticate + authorize('admin')` enforced at the router level. Any request to any route -under `/admin` without a valid admin JWT short-circuits with `403` before the route handler runs. This gate response -must appear as a first-class scenario at the top of this file. - -**Verification queue endpoints:** - -`GET /admin/verification-queue` — paginated oldest-first. The anti-starvation ordering (oldest first, not newest) is -deliberate and must be noted in the doc. Success scenario shows the full item shape including `business_name`, -`owner_full_name`, `verification_status`, and `email`. - -`POST /admin/verification-queue/:requestId/approve` — approves the request, sets `verification_status = 'verified'` on -the profile. The service uses `AND status = 'pending'` in the `UPDATE` as a concurrency guard. If a concurrent -approval/rejection already resolved the request, `rowCount === 0` and the service throws -`AppError("Verification request not found or already resolved", 409)`. This concurrent-resolution scenario must be a -named failure scenario. The success response is `{ requestId, status: "verified" }`. - -`POST /admin/verification-queue/:requestId/reject` — requires `rejectionReason` in the body (enforced by -`rejectRequestSchema`). `adminNotes` is optional. Same concurrent-resolution `409` applies. Success response is -`{ requestId, status: "rejected" }`. - -**Report queue endpoints:** - -`GET /admin/report-queue` — paginated oldest-first. Each item carries the full rating content, reporter profile, -reviewer profile, and reviewee profile so the admin can decide without a second request. The response shape is complex — -document it fully. - -`PATCH /admin/reports/:reportId/resolve` — two resolutions: `"resolved_removed"` (hides the rating, triggers the -`update_rating_aggregates` DB trigger) and `"resolved_kept"` (closes the report without touching the rating). Document: - -- `adminNotes` is required for `"resolved_removed"` → `400` when missing -- Report not found or already resolved → `409` -- Successful resolution → `200` with `{ reportId, resolution, ratingId }` - -**CDC pipeline link.** When a verification request is approved or rejected, the trigger -`trg_verification_status_changed` fires and writes to `verification_event_outbox`. The `verificationEventWorker` picks -this up and enqueues both an in-app notification and a transactional email. Document this chain in the admin doc as a -note: "Approval and rejection decisions trigger asynchronous side effects — an in-app notification and a transactional -email are enqueued within approximately 5 seconds via the CDC outbox worker." - -### 2.2 — `docs/api/preferences.md` - -Verify the file covers all three surfaces completely. - -`GET /preferences/meta` — returns the `preferenceMetadata` object from `src/config/preferences.js`. The full current -catalog (7 keys: `smoking`, `food_habit`, `sleep_schedule`, `alcohol`, `cleanliness_level`, `noise_tolerance`, -`guest_policy`) must appear in the doc's success response so integrators do not need to call the endpoint just to learn -the valid values. When the catalog changes in `preferences.js`, this doc section must be updated in the same commit. - -`GET /students/:userId/preferences` — owner-only (Zod params schema validates UUID; service enforces -`requestingUserId === targetUserId`). Returns an array of `{ preferenceKey, preferenceValue }` objects. Empty array for -users who have not set any preferences — not `null`, not `404`. - -`PUT /students/:userId/preferences` — full-replace semantics. Empty array clears all preferences. The -`dedupePreferencesByKey` function applies last-write-wins when duplicate keys are submitted. A request body like: - -```json -{ - "preferences": [ - { "preferenceKey": "smoking", "preferenceValue": "non_smoker" }, - { "preferenceKey": "smoking", "preferenceValue": "smoker" } - ] -} -``` - -results in a single `smoking: smoker` row (last value wins), and the response returns only that one entry. Name this -scenario "Duplicate preference key submitted — last value wins, no error." - -### 2.3 — Background Jobs in `docs/API.md` - -The "Background Jobs That Affect API Behavior" section must describe all three cron jobs with exact schedule -expressions, overridable env var names, and the `SOFT_DELETE_RETENTION_DAYS` format constraint. Use the following -verified details from the source code: - -`src/cron/listingExpiry.js` — schedule: `0 2 * * *` (02:00 daily server time), env override: `CRON_LISTING_EXPIRY`. -Transitions `active` listings where `expires_at < NOW()` to `expired` and bulk-expires pending interest requests on -those listings in the same transaction. Also enqueues `listing_expired` notifications for affected posters post-commit. - -`src/cron/expiryWarning.js` — schedule: `0 1 * * *` (01:00 daily server time), env override: `CRON_EXPIRY_WARNING`. -Enqueues `listing_expiring` notifications for listings expiring within 7 days. Uses a durable INSERT with -`ON CONFLICT (idempotency_key) DO NOTHING` inside the transaction — the idempotency key format is -`expiry_warning:{listing_id}:{YYYY-MM-DD}` (UTC date). This guarantees exactly one notification per listing per calendar -day regardless of how many times the cron runs. - -`src/cron/hardDeleteCleanup.js` — schedule: `0 4 * * 0` (04:00 every Sunday), env override: `CRON_HARD_DELETE`. -Hard-deletes rows with `deleted_at < NOW() - RETENTION_DAYS`. Retention overridable via `SOFT_DELETE_RETENTION_DAYS`. -Format requirement: must be a plain decimal integer with no prefix, suffix, or sign (e.g. `"90"`, not `"90days"`, not -`"-30"`, not `"1e2"`). The strict `/^[0-9]+$/` parse is intentional — `parseInt()` would silently accept `"90days"` as -90, and we want a warning log and fallback to 90 instead of silent misinterpretation. - ---- - -## Phase 3 — Update `docs/TechStack.md` - -### 3.1 — EMAIL_PROVIDER three-value enum - -Once the `brevo-api` discrepancy described in the Deployment Reality section above is resolved, update `TechStack.md` to -describe all three valid `EMAIL_PROVIDER` values: - -- `"ethereal"` — Nodemailer pointed at Ethereal's fake SMTP server (local dev only). Requires `SMTP_HOST`, `SMTP_PORT`, - `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`. Prints a preview URL to the console instead of actually delivering email. -- `"brevo"` — Nodemailer pointed at `smtp-relay.brevo.com:587` (STARTTLS). Requires `BREVO_SMTP_LOGIN`, - `BREVO_SMTP_KEY`, `BREVO_SMTP_FROM`. Real email delivery. -- `"brevo-api"` — Brevo REST API (`/v3/smtp/email`). Requires `BREVO_API_KEY` and `BREVO_SMTP_FROM`. Used on Render - because Render's free tier blocks outbound SMTP connections. - -The `BREVO_SMTP_KEY` vs `BREVO_API_KEY` distinction must be clearly stated: the SMTP key starts with `xsmtpsib-` and is -used only with `EMAIL_PROVIDER=brevo`; the API key starts with `xkeysib-` and is used only with -`EMAIL_PROVIDER=brevo-api`. - -### 3.2 — BullMQ queue table - -Update the BullMQ queues table to include all three current queues: - -| Queue name | Worker file | Concurrency | Used for | -| ----------------------- | ----------------------- | -------------- | ---------------------------------------------------------------- | -| `media-processing` | `mediaProcessor.js` | 1 (CPU-bound) | Sharp compression, storage upload, DB URL update, cover election | -| `notification-delivery` | `notificationWorker.js` | 10 (I/O-bound) | Notification INSERT with idempotency key ON CONFLICT DO NOTHING | -| `email-delivery` | `emailWorker.js` | 3 (I/O-bound) | OTP emails, verification status emails via CDC outbox pipeline | - -### 3.3 — CDC outbox worker subsection - -Add a new subsection under the Workers section for the verification event worker. It must explain: - -- This is not a BullMQ worker. It is a `setInterval`-based polling loop (5-second interval). -- It reads from `verification_event_outbox`, which is populated by the Postgres trigger - `trg_verification_status_changed` (defined in migration `002_verification_event_outbox.sql`). -- It uses `SELECT ... FOR UPDATE SKIP LOCKED` to allow safe concurrent operation across multiple instances without - double-processing. -- On processing an event, it ensures `pg_owner_profiles.verification_status` is consistent with the event (a profile - consistency guard for direct-SQL changes), enqueues an in-app notification, and enqueues a transactional email. -- Failed events are retried up to `MAX_ATTEMPTS = 5` times; permanently failed events have `processed_at` set so they no - longer block the queue, and the error is recorded in `error_message` for operator inspection. -- It is started in `server.js` after Redis and Postgres are confirmed healthy, and stopped during graceful shutdown via - `verificationEventWorker.close()`. - ---- - -## Phase 4 — Update `docs/ImplementationPlan.md` - -### 4.1 — Phase 5 status correction - -`phase5/cron` is marked ✅ MERGED. `phase5/admin` remains ⏳ NOT STARTED. - -Update the "What needs building" list under `phase5/admin` to reflect current reality: - -- The email delivery worker (`emailWorker.js`, `emailQueue.js`) is **done** — remove it from the needs-building list. - Mark it ✅ in the source file inventory. -- The verification event CDC worker (`verificationEventWorker.js`) is **done** — add it to the source file inventory if - not present. It belongs between the cron files and the BullMQ workers. -- The admin user management endpoints (`GET /admin/users`, `PATCH /admin/users/:userId/status`) are **not started**. -- The admin analytics endpoint (`GET /admin/analytics/platform`) is **not started**. -- The admin rating visibility endpoints (`GET /admin/ratings`, `PATCH /admin/ratings/:ratingId/visibility`) are **not - started**. - -### 4.2 — Source file inventory additions - -Add the following files to the inventory with appropriate status marks and one-line descriptions: - -- `src/workers/emailWorker.js` — ✅ BullMQ worker for async email delivery; concurrency 3; handles `otp`, - `verification_approved`, `verification_rejected`, `verification_pending` types -- `src/workers/emailQueue.js` — ✅ Fire-and-forget enqueue helper for `email-delivery` queue; mirrors - `notificationQueue.js` pattern -- `src/workers/verificationEventWorker.js` — ✅ setInterval polling loop (5s); reads `verification_event_outbox`; - dispatches notifications + emails for verification status changes; SELECT FOR UPDATE SKIP LOCKED for concurrency - safety -- `src/middleware/guestListingGate.js` — ✅ Silently caps `limit` to 20 for unauthenticated requests on `GET /listings` - and `GET /listings/:listingId` - -### 4.3 — DB trigger reference table addition - -Add the following trigger to the trigger reference table: - -| Trigger | Table | Fires on | Action | -| --------------------------------- | ----------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `trg_verification_status_changed` | `verification_requests` | AFTER UPDATE OF `status` | Inserts into `verification_event_outbox` for `verified`, `rejected`, or `pending` transitions; the `verificationEventWorker` polls this outbox | - ---- - -## Phase 5 — Update Deployment Documentation - -The current active deployment target is **Render**, not Azure. The deployment docs need to reflect this reality. -`docs/Deployment.md` covers the Azure migration plan (future state). `docs/deployment/tier0.md` covers the Render -deployment (current state). This phase focuses on tier0 since that is what is running right now. - -### 5.1 — `docs/deployment/tier0.md` — Email provider - -The Render free tier blocks outbound SMTP on port 587. This means `EMAIL_PROVIDER=brevo` (which uses Nodemailer SMTP) -will fail silently or with connection errors. The correct provider for Render is `EMAIL_PROVIDER=brevo-api`, which calls -the Brevo REST API over HTTPS. Verify this is reflected everywhere in the tier0 guide: - -- The environment variable table in Phase 7.2 of the guide must list `EMAIL_PROVIDER=brevo-api`. -- Any section that references `BREVO_SMTP_KEY` or `BREVO_SMTP_LOGIN` for Render must be updated to reference - `BREVO_API_KEY` instead — the API provider does not use SMTP credentials. -- `BREVO_SMTP_FROM` (the sender address) is still used by the API provider and must remain. - -### 5.2 — `docs/deployment/tier0.md` — `TRUST_PROXY` setting - -The Render environment variable table must include `TRUST_PROXY=1`. Without this, `req.ip` resolves to Render's internal -load balancer IP rather than the real client IP, which breaks: - -- The OTP verify IP-level rate limiter (all users appear to come from the same IP) -- The contact reveal gate fingerprinting (all guest fingerprints collide) -- The `express-rate-limit` auth limiter IP keying - -This is a security-relevant misconfiguration, not a cosmetic issue. The doc must explain why `TRUST_PROXY=1` is -required, not just list it as a table row. - -The current `.env.render` file sets `TRUST_PROXY=false`. The tier0 guide must add a callout: "The `.env.render` file is -provided as a reference of what is currently deployed and has `TRUST_PROXY=false` — **this is a misconfiguration that -must be corrected** in the Render dashboard. The correct value for Render's proxy architecture is `TRUST_PROXY=1`." - -### 5.3 — `docs/deployment/tier0.md` — `NODE_ENV` clarification - -The `.env.render` file now has `NODE_ENV=production` (not `development`). Update any tier0 doc language that previously -said the file uses `development` for local testing. The current state is: both the local reference file and the Render -dashboard should use `NODE_ENV=production`. If you want a local file for testing against live Render services, that is a -separate `.env.render.local` file that you create manually and never commit. - -### 5.4 — `docs/deployment/tier0.md` — `ALLOWED_ORIGINS` production value - -The current `.env.render` file sets `ALLOWED_ORIGINS=http://localhost:5173`. This means no frontend other than a local -dev server can make credentialed cross-origin requests to the live API. The tier0 guide must include a callout -explaining: - -- The placeholder value `http://localhost:5173` is intentional for the initial deployment phase when no frontend is yet - deployed. -- When a frontend is deployed (e.g. to Vercel or Netlify), this value must be updated in the Render dashboard to the - production frontend URL (e.g. `https://roomies.vercel.app`). -- The value is a comma-separated list for multiple origins: `https://roomies.vercel.app,https://www.roomies.in`. - -### 5.5 — `docs/deployment/tier0.md` — Database and Redis reality - -The tier0 guide must document the actual service providers being used: - -- Database is **Neon PostgreSQL** (not Azure PostgreSQL). The connection string uses the pooler endpoint (`-pooler.` in - the hostname) which is required for Render's serverless-like instances to avoid exhausting connection limits. Direct - connections without `-pooler` may work locally but will exhaust Neon's connection quota under load. -- Redis is **Upstash** on port **6379** (not 6380). The `rediss://` scheme indicates TLS. The Upstash free tier is - sufficient for development but will hit the 500K command/month ceiling under sustained BullMQ polling. For production - load, upgrade to a paid Upstash plan. - -### 5.6 — Live API base URL in all deployment docs - -Add a banner at the top of `docs/deployment/tier0.md` and a note in `docs/API.md`: - -``` -Live API base URL: https://roomies-api.onrender.com/api/v1 -Health check: https://roomies-api.onrender.com/api/v1/health -``` - -This is the URL integrators should use when testing against the live deployment. The local development URL remains -`http://localhost:3000/api/v1`. - ---- - -## Phase 6 — Final Consistency Pass - -After completing all phases above, perform the following cross-cutting checks in order. - -### 6.1 — Hyperlink audit - -Every cross-reference in every doc file must be a working relative hyperlink. Specifically: - -- Every feature doc must have a link back to `./conventions.md` at or near the top. -- `docs/README.md` must link to every file in `docs/api/`. -- `docs/API.md` feature docs list must include every file in `docs/api/` and all links must resolve. -- Phase 5.6 live URL banners must be present. - -### 6.2 — UUID consistency audit - -The canonical UUIDs from `docs/api/conventions.md` must be used in every scenario in every feature doc. Verify no doc -invents a UUID that is not in the conventions file. If a new entity type needs a canonical example UUID (e.g. a report -ID or a session ID), add it to conventions.md and use it everywhere. - -The current canonical set: - -- Student: `11111111-1111-4111-8111-111111111111` -- PG owner: `22222222-2222-4222-8222-222222222222` -- Admin: `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` - -### 6.3 — HTTP status code table in `docs/API.md` - -Section 20 of `docs/API.md` must include these entries (add or correct as needed): - -- `202` — Accepted: async processing started (photo upload `POST /listings/:listingId/photos`) -- `503` — Service unavailable: dependency unhealthy (health endpoint degraded response) AND photo processing queue - temporarily unavailable (from `photo.service.js` when BullMQ enqueue fails) - -### 6.4 — Pagination constraint in `docs/API.md` - -Section 19 must explicitly state: "`cursorTime` and `cursorId` must be provided together or both omitted. Providing -exactly one of the two returns a `400` validation error." Include the exact validation error shape: - -```json -{ - "status": "error", - "message": "Validation failed", - "errors": [{ "field": "query.cursorTime", "message": "cursorTime and cursorId must be provided together" }] -} -``` - -### 6.5 — Paise rule consistency - -Every endpoint that involves `rentPerMonth` or `depositAmount` must include a note in its request contract table: -"Values are in rupees. The API accepts and returns rupees; internal storage uses paise (multiply by 100)." Verify this -note is present in `POST /listings`, `PUT /listings/:listingId`, `GET /listings` (search results), -`GET /listings/:listingId`, and `GET /listings/me/saved`. - -### 6.6 — `docs/API.md` stale inline content - -`docs/API.md` contains several inline endpoint descriptions that duplicate or contradict the feature docs (e.g. an -inline `POST /auth/google/callback` section, inline student/PG owner profile sections). These should be removed and -replaced with links to the feature docs. The API.md file's role is the "front door" — transport rules, shared response -envelopes, pagination, status codes, and links to feature docs. It is not a second copy of the feature docs. - ---- - -## Deliverables Checklist - -When all phases are complete, each of these statements must be true: - -- [ ] Every endpoint in `src/routes/` has at least one named success scenario and a named scenario for every - `throw new AppError(...)` and every PostgreSQL constraint code path in the service. -- [ ] The `EMAIL_PROVIDER=brevo-api` discrepancy is resolved in both code and docs. -- [ ] `docs/deployment/tier0.md` documents `EMAIL_PROVIDER=brevo-api`, `TRUST_PROXY=1` with explanation, the Neon - PostgreSQL connection requirements, and the live API URL. -- [ ] The `TRUST_PROXY=false` misconfiguration in `.env.render` is called out as a known issue requiring correction in - the Render dashboard. -- [ ] `docs/TechStack.md` BullMQ queue table includes all three queues. -- [ ] `docs/TechStack.md` describes the `verificationEventWorker` as a CDC outbox drainer. -- [ ] `docs/ImplementationPlan.md` source file inventory includes `emailWorker.js`, `emailQueue.js`, - `verificationEventWorker.js`, and `guestListingGate.js`. -- [ ] The `trg_verification_status_changed` trigger is in the DB trigger reference table. -- [ ] `docs/README.md` links to every file in `docs/api/`. -- [ ] All hyperlinks between doc files resolve correctly. -- [ ] All scenario response bodies use the exact error strings from the source code, not paraphrases. -- [ ] All scenarios use canonical UUIDs from `docs/api/conventions.md`. -- [ ] Every scenario involving rent/deposit states that values are in rupees. -- [ ] The live API URL `https://roomies-api.onrender.com/api/v1` appears in the appropriate deployment docs and is - consistent across all files that reference it. - ---- - -## Source File Quick-Reference Table - -| Feature | Routes | Validators | Service | Controller | -| ------------------------ | -------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------- | -| Auth | `routes/auth.js` | `validators/auth.validators.js` | `services/auth.service.js` | `controllers/auth.controller.js` | -| Student profiles & prefs | `routes/student.js` | `validators/student.validators.js` | `services/student.service.js` | `controllers/student.controller.js` | -| PG owner profiles | `routes/pgOwner.js` | `validators/pgOwner.validators.js` | `services/pgOwner.service.js` | `controllers/pgOwner.controller.js` | -| Properties | `routes/property.js` | `validators/property.validators.js` | `services/property.service.js` | `controllers/property.controller.js` | -| Listings | `routes/listing.js` | `validators/listing.validators.js` | `services/listing.service.js` | `controllers/listing.controller.js` | -| Photos | `routes/listing.js` | `validators/photo.validators.js` | `services/photo.service.js` | `controllers/photo.controller.js` | -| Interests | `routes/interest.js`, `routes/listing.js` | `validators/interest.validators.js` | `services/interest.service.js` | `controllers/interest.controller.js` | -| Connections | `routes/connection.js` | `validators/connection.validators.js` | `services/connection.service.js` | `controllers/connection.controller.js` | -| Notifications | `routes/notification.js` | `validators/notification.validators.js` | `services/notification.service.js` | `controllers/notification.controller.js` | -| Ratings | `routes/rating.js` | `validators/rating.validators.js` | `services/rating.service.js` | `controllers/rating.controller.js` | -| Reports | `routes/rating.js` | `validators/report.validators.js` | `services/report.service.js` | `controllers/report.controller.js` | -| Verification (admin) | `routes/admin.js` | `validators/verification.validators.js` | `services/verification.service.js` | `controllers/verification.controller.js` | -| Preferences | `routes/preferences.js`, `routes/student.js` | `validators/preferences.validators.js` | `services/preferences.service.js` | `controllers/preferences.controller.js` | -| Contact reveal gate | `routes/student.js`, `routes/pgOwner.js` | — | `services/student.service.js`, `services/pgOwner.service.js` | `middleware/contactRevealGate.js` | -| Health | `routes/health.js` | — | — | — | -| Error shapes | — | — | — | `middleware/errorHandler.js` | -| Cron behaviour | — | — | `cron/listingExpiry.js`, `cron/expiryWarning.js`, `cron/hardDeleteCleanup.js` | — | -| Email delivery | — | — | `services/email.service.js` | `workers/emailWorker.js` | -| CDC outbox | — | — | `workers/verificationEventWorker.js` | Trigger in `002_verification_event_outbox.sql` | From 71bc53a4265db66a14b7a9b8c6c183d38c68a0d2 Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Tue, 21 Apr 2026 13:40:18 +0530 Subject: [PATCH 12/54] Add comprehensive middleware and workers/crons documentation - Introduced detailed documentation for middleware functions including `authenticate`, `optionalAuthenticate`, `authorize`, and others, outlining their roles and associated routes. - Added a reference for BullMQ workers such as `media-processing`, `notification-delivery`, and `email-delivery`, detailing job payloads, processing steps, and failure handling. - Documented cron jobs like `listingExpiry`, `expiryWarning`, and `hardDeleteCleanup`, including their schedules, actions, and API impacts. --- FixOutdated.md | 1008 ++++++++++++++++++++++++++++ docs/README.md | 3 + docs/api/admin.md | 45 +- docs/api/appendix-middleware.md | 199 ++++++ docs/api/appendix-workers-crons.md | 185 +++++ docs/api/auth.md | 80 ++- docs/api/conventions.md | 19 + docs/api/listings.api.md | 64 +- docs/api/notifications.md | 59 +- src/workers/notificationWorker.js | 8 +- 10 files changed, 1633 insertions(+), 37 deletions(-) create mode 100644 FixOutdated.md create mode 100644 docs/api/appendix-middleware.md create mode 100644 docs/api/appendix-workers-crons.md diff --git a/FixOutdated.md b/FixOutdated.md new file mode 100644 index 0000000..ef30065 --- /dev/null +++ b/FixOutdated.md @@ -0,0 +1,1008 @@ +# FixOutdated.md — QA Audit Findings: Documentation vs Source + +> This file documents each QA finding, verifies it against the source, explains what the bug in the docs actually means, and prescribes the exact fix — accounting for all interdependencies. + +--- + +## Finding 1 — Notification Lifecycle Status Mismatch + +### What the finding says + +`docs/api/notifications.md` marks `verification_approved` and `verification_rejected` as having a "PLANNED emitter". The claim is that no active code enqueues in-app notifications for these types. + +### Verification against source + +**`src/workers/verificationEventWorker.js`** (lines inside `processEvent`): + +```js +// For verification_approved: +enqueueNotification({ + recipientId: user_id, + type: "verification_approved", + entityType: "verification_request", + entityId: request_id, +}); + +// For verification_rejected: +enqueueNotification({ + recipientId: user_id, + type: "verification_rejected", + entityType: "verification_request", + entityId: request_id, +}); +``` + +**`src/workers/notificationWorker.js`** — `NOTIFICATION_MESSAGES` map contains: +```js +verification_approved: "Your verification request was approved", +verification_rejected: "Your verification request was rejected", +``` +Both are annotated `PLANNED` in the notificationWorker comments, but the emit code in `verificationEventWorker.js` is **live and active**. The notificationWorker comments are also stale but that is a source comment issue, not just a docs issue. + +**`src/workers/emailWorker.js`** — `EMAIL_HANDLERS` map: +```js +verification_approved: async ({ to, data }) => { await sendVerificationApprovedEmail(...) }, +verification_rejected: async ({ to, data }) => { await sendVerificationRejectedEmail(...) }, +verification_pending: async ({ to, data }) => { await sendVerificationPendingEmail(...) }, +``` +All three are `ACTIVE` in the emailWorker already. + +**The trigger chain** that makes this active: +1. Admin calls `POST /admin/verification-queue/:requestId/approve` or `/reject` +2. `verification.service.approveRequest()` / `rejectRequest()` does `UPDATE verification_requests SET status = 'verified'|'rejected'` +3. Postgres trigger `trg_verification_status_changed` fires → inserts row into `verification_event_outbox` +4. `verificationEventWorker` polls every 5s → calls `processEvent()` → calls both `enqueueNotification()` and `enqueueEmail()` +5. `notificationWorker` processes the BullMQ job → INSERTs into `notifications` table +6. `emailWorker` processes the BullMQ email job → calls `sendVerificationApprovedEmail()` / `sendVerificationRejectedEmail()` + +**Conclusion: Finding is confirmed.** Both `verification_approved` and `verification_rejected` are fully active. `verification_pending` is also active (email only — no in-app notification because it is not enqueued in `verificationEventWorker` for `verification_pending` via `enqueueNotification`, only `enqueueEmail` is called). + +### What the docs currently say (incorrect) + +```markdown +| `verification_approved` | Your verification request was approved | PLANNED emitter | +| `verification_rejected` | Your verification request was rejected | PLANNED emitter | +``` + +### Exact fix for `docs/api/notifications.md` + +Replace the notification type table with the following: + +```markdown +| Type | Message | Status | +| ---------------------------- | -------------------------------------------------- | ---------------------------- | +| `interest_request_received` | Someone expressed interest in your listing | ACTIVE | +| `interest_request_accepted` | Your interest request was accepted | ACTIVE | +| `interest_request_declined` | Your interest request was declined | ACTIVE | +| `interest_request_withdrawn` | An interest request was withdrawn | ACTIVE | +| `connection_confirmed` | Your connection has been confirmed by both parties | ACTIVE | +| `rating_received` | You received a new rating | ACTIVE | +| `listing_expiring` | One of your listings is expiring soon | ACTIVE | +| `listing_expired` | One of your listings has expired | ACTIVE | +| `listing_filled` | A listing has been marked as filled | ACTIVE | +| `verification_approved` | Your verification request was approved | ACTIVE (in-app + email) | +| `verification_rejected` | Your verification request was rejected | ACTIVE (in-app + email) | +| `verification_pending` | We received your verification documents | ACTIVE (email only, no in-app notification) | +| `new_message` | You have a new message | PLANNED — no emitter | +| `connection_requested` | You have a new connection request | PLANNED — no emitter | +``` + +Add a note below the table: + +```markdown +### Delivery pipeline for verification events + +Verification notifications are **not triggered directly by the API controller**. They are driven by a CDC (Change Data Capture) outbox pattern: + +1. Admin calls `POST /admin/verification-queue/:requestId/approve|reject` +2. `verification.service.js` commits `UPDATE verification_requests SET status = 'verified'|'rejected'|'pending'` +3. Postgres trigger `trg_verification_status_changed` (migration `002`) writes to `verification_event_outbox` +4. `src/workers/verificationEventWorker.js` polls the outbox every **5 seconds** using `SELECT ... FOR UPDATE SKIP LOCKED` +5. On each event, the worker calls: + - `enqueueNotification()` → `notification-delivery` BullMQ queue → in-app notification row + - `enqueueEmail()` → `email-delivery` BullMQ queue → Brevo REST/SMTP email +6. `verification_pending` emits **email only** (no `enqueueNotification` call in source) + +This means verification notifications can originate from **direct SQL on the database** (not just the API), as long as the trigger fires. The worker handles retry with up to `MAX_ATTEMPTS = 5` retries per event. +``` + +Also fix `notificationWorker.js` source comments — change the annotation on `verification_approved` and `verification_rejected` from `PLANNED` to `ACTIVE` in the `NOTIFICATION_MESSAGES` map comments. This is a source file fix, not just docs. + +--- + +## Finding 2 — CDC Outbox Worker Behavior Undocumented + +### What the finding says + +API docs don't document that verification side effects are driven by DB-trigger → outbox → poll worker, and that state changes from outside the API (raw SQL, migrations) can also trigger notifications/emails. + +### Verification against source + +**`migrations/002_verification_event_outbox.sql`** — creates `verification_event_outbox` table and the trigger `trg_verification_status_changed` which fires on `AFTER UPDATE OF status ON verification_requests`. + +**`src/workers/verificationEventWorker.js`**: +- Polls every `POLL_INTERVAL_MS = 5_000` ms +- Batch size: `BATCH_SIZE = 10` +- Max retry attempts per event: `MAX_ATTEMPTS = 5` +- Uses `FOR UPDATE SKIP LOCKED` — safe for multi-instance deployments +- On startup, runs one immediate drain cycle (catches events that accumulated during downtime) +- Profile consistency guard: if `pg_owner_profiles.verification_status` is out of sync with the event type, the worker corrects it in the same transaction + +**`src/server.js`**: +```js +const verificationEventWorker = startVerificationEventWorker(); +// ... shutdown: +verificationEventWorker.close(); // clearInterval only — no async drain +``` + +The worker's `close()` is synchronous (`clearInterval`) — it does not drain in-flight events before shutdown. This means events in the middle of processing during a SIGTERM may be retried on next startup. + +### What the docs currently say + +There is no mention of any of this in `docs/api/notifications.md`, `docs/api/admin.md`, or `docs/API.md`. The admin doc has only this footnote: + +```markdown +## CDC side-effects note +When verification status changes to `verified`, `rejected`, or `pending`, trigger `trg_verification_status_changed` writes to `verification_event_outbox`. `verificationEventWorker` polls this table (5-second interval) and enqueues: +- in-app notification (`notification-delivery`) +- email (`email-delivery`) +``` + +This is present but incomplete — it doesn't note email-only for `pending`, doesn't cover the profile consistency guard, doesn't cover the retry model, and doesn't cover the "outside-API trigger" scenario. + +### Exact fix for `docs/api/admin.md` + +Replace the CDC side-effects note at the bottom with: + +```markdown +## CDC Outbox: Verification Side Effects + +Verification status changes trigger a **CDC (Change Data Capture) outbox pipeline**, not direct synchronous effects from the API. + +### Trigger + +The Postgres trigger `trg_verification_status_changed` fires on `AFTER UPDATE OF status ON verification_requests` for any status change to `verified`, `rejected`, or `pending`. This fires regardless of whether the change comes from the API or direct SQL. + +### Worker behavior (`src/workers/verificationEventWorker.js`) + +| Property | Value | +| ---------------- | ---------------------------- | +| Poll interval | 5 seconds | +| Batch size | 10 events per cycle | +| Max retry attempts | 5 (then event is abandoned with error recorded) | +| Concurrency safe | Yes — `FOR UPDATE SKIP LOCKED` prevents double-processing across instances | +| Startup behavior | Runs one immediate drain on startup to catch accumulated events | + +### Per-event actions + +| Event type | In-app notification | Email | +| ----------------------- | ------------------- | ----------------------------- | +| `verification_approved` | Yes | Yes (`sendVerificationApprovedEmail`) | +| `verification_rejected` | Yes | Yes (`sendVerificationRejectedEmail`) | +| `verification_pending` | No | Yes (`sendVerificationPendingEmail`) | + +### Profile consistency guard + +Before enqueuing notifications, the worker verifies `pg_owner_profiles.verification_status` matches the event type. If inconsistent (e.g. admin ran partial SQL), the worker corrects the profile status in the same DB transaction as the event acknowledgement. + +### Failure behavior + +- If `processEvent()` throws, `attempts` is incremented and `error_message` recorded. +- After `MAX_ATTEMPTS = 5` failures, `processed_at` is set — event is permanently skipped but remains in table for inspection. +- Graceful shutdown (`SIGTERM`) calls `clearInterval` only — in-flight events are not drained and will be retried on next startup. +``` + +--- + +## Finding 3 — Auth Transport Docs Missing Branch Semantics + +### What the finding says + +Docs don't fully specify the concrete branching behavior implemented in `authenticate.js` and `optionalAuthenticate.js`: +- Cookie vs bearer priority in token extraction +- Silent refresh triggers (cookie-expired only, not bearer) +- What exactly is returned on each failure path +- `optionalAuthenticate` degrading to guest on any token error + +### Verification against source + +**`src/middleware/authenticate.js` — `extractToken()`:** +```js +const extractToken = (req) => { + const cookieToken = req.cookies?.accessToken; + if (cookieToken) return { token: cookieToken, source: "cookie" }; + const authHeader = req.headers.authorization; + if (authHeader?.startsWith("Bearer ")) return { token: authHeader.slice(7), source: "header" }; + return null; +}; +``` +**Cookie takes priority unconditionally.** If both cookie and `Authorization` header are present, cookie wins. + +**`authenticate` — expired token branching:** +```js +} catch (err) { + if (err.name === "TokenExpiredError" && source === "cookie") { + const session = await attemptSilentRefresh(req, res); + if (!session?.userId) return next(new AppError("Session expired", 401)); + payload = session; + } else { + return next(err); // → global handler: "Token has expired" for bearer + } +} +``` + +**Silent refresh conditions** (`attemptSilentRefresh`): +- Only triggers when `source === "cookie"` AND `err.name === "TokenExpiredError"` +- Reads `req.cookies?.refreshToken` +- Calls `verifyRefreshTokenPayload()` — handles legacy tokens without `sid` +- Checks `redis.get(refreshTokenKey(userId, sid))` — token must match stored value +- Signs new access + refresh tokens, CAS-rotates refresh via Lua script +- Sets new cookies in-place — client sees no interruption +- Returns `null` on any failure → `"Session expired"` 401 + +**`optionalAuthenticate` — failure degradation:** +```js +try { + payload = jwt.verify(token, config.JWT_SECRET); +} catch { + return next(); // any verify error → guest, never 401 +} +if (!payload?.userId) return next(); +const user = await findUserById(payload.userId); +if (!user || INACTIVE_STATUSES.has(user.account_status)) return next(); // still guest +``` +Any failure (expired, invalid, user deleted, user suspended) → request continues as guest with no `req.user`. + +**`isBearerTransport` in `auth.controller.js`:** +```js +const isBearerTransport = (req) => req.headers["x-client-transport"] === "bearer"; +``` +Only the exact header value `"bearer"` (lowercase) triggers bearer response mode. + +**`setAuthCookies` cookie options:** +```js +const ACCESS_COOKIE_OPTIONS = { + httpOnly: true, + secure: config.NODE_ENV === "production", + sameSite: "strict", + maxAge: parseTtlSeconds(config.JWT_EXPIRES_IN, 15 * 60) * 1000, +}; +``` + +### What the docs currently say + +`docs/api/auth.md` Transport Summary: +```markdown +- Browser clients should use cookie mode. +- Mobile and API clients should send `X-Client-Transport: bearer` to receive tokens in the JSON body. +- Silent refresh only happens when the expired access token came from a cookie. +``` + +This is correct but **incomplete** — it doesn't document the response body difference, error message difference between bearer-expired vs cookie-expired, or that cookie takes priority over bearer header when both are present. + +### Exact fix for `docs/api/auth.md` Transport Summary section + +Replace "Transport Summary" with: + +```markdown +## Auth Transport Reference + +### Token extraction priority + +`authenticate` and `optionalAuthenticate` extract the access token in this exact order: + +1. `req.cookies.accessToken` (HttpOnly cookie) — **takes priority** +2. `Authorization: Bearer ` header — used only if no cookie present + +If both are present, **the cookie always wins**. This prevents a stale bearer token from overriding a valid cookie session. + +### Transport mode: cookie (default, browser) + +- No special request header needed +- Auth endpoints set `accessToken` (TTL: `JWT_EXPIRES_IN`, default `15m`) and `refreshToken` (TTL: `JWT_REFRESH_EXPIRES_IN`, default `7d`) as `HttpOnly; SameSite=Strict` cookies +- Response body for auth endpoints contains `{ user, sid }` only — **no raw token strings** +- `secure: true` in production, `secure: false` in development + +### Transport mode: bearer (mobile/Android) + +- Set request header: `X-Client-Transport: bearer` (case-sensitive, lowercase `"bearer"`) +- Response body for auth endpoints includes `{ accessToken, refreshToken, user, sid }` +- For protected endpoints, send: `Authorization: Bearer ` + +### Silent refresh (cookie mode only) + +Triggers automatically inside `authenticate` middleware when: +- `err.name === "TokenExpiredError"` **AND** +- Token came from `req.cookies.accessToken` (not from `Authorization` header) + +Silent refresh process: +1. Reads `req.cookies.refreshToken` +2. Calls `verifyRefreshTokenPayload()` — handles legacy tokens missing `sid` via migration path +3. Checks Redis: `GET refreshToken:{userId}:{sid}` — must match stored value exactly +4. Loads fresh user state from DB (roles, email, account_status) +5. Signs new access token + new refresh token +6. CAS-rotates refresh token via Lua script (atomic compare-and-swap prevents concurrent rotation races) +7. Sets new cookies on the response — client sees no interruption + +Silent refresh **never** triggers for an expired `Authorization: Bearer` header token. + +### Error response matrix by scenario + +| Scenario | Middleware | Response | +| --- | --- | --- | +| No token present | `authenticate` | `401 { "message": "No token provided" }` | +| Bearer token expired | `authenticate` | `401 { "message": "Token has expired" }` | +| Cookie token expired + refresh succeeds | `authenticate` | Request continues transparently | +| Cookie token expired + refresh fails | `authenticate` | `401 { "message": "Session expired" }` | +| Token invalid (malformed/wrong secret) | `authenticate` | `401 { "message": "Invalid token" }` | +| User not found in DB | `authenticate` | `401 { "message": "User not found" }` | +| User suspended/banned/deactivated | `authenticate` | `401 { "message": "Account is suspended" }` | +| Any token error | `optionalAuthenticate` | Request continues as guest (`req.user` is undefined) | +| Invalid/expired token | `optionalAuthenticate` | Request continues as guest — **never returns 401** | +| Suspended user token | `optionalAuthenticate` | Request continues as guest | +``` + +--- + +## Finding 4 — Listing Route Chain and Photo Upload Path Inaccurate + +### What the finding says + +The exact middleware chain for listing read endpoints has validate before guestListingGate (not gate before validate). Photo upload flow should explicitly show the queue/worker chain and 503 queue-failure path. + +### Verification against source + +**`src/routes/listing.js`** — search route: +```js +listingRouter.get( + "/", + optionalAuthenticate, + validate(searchListingsSchema), // ← validate BEFORE gate + guestListingGate, // ← gate AFTER validate + listingController.searchListings, +); +``` + +**`src/routes/listing.js`** — single listing route: +```js +listingRouter.get( + "/:listingId", + optionalAuthenticate, + validate(listingParamsSchema), // ← validate BEFORE gate + guestListingGate, + listingController.getListing, +); +``` + +**Why this order matters**: `validate` runs Zod and writes coerced values back to `req.query`. `guestListingGate` reads `req.query.limit` to apply the guest cap. If gate ran before validate, it would read a raw string from the query rather than the Zod-coerced number. The source comment in `listing.js` explicitly explains this: +```js +// validate runs before guestListingGate so that the limit field is already +// coerced to a number (by Zod) when guestListingGate reads it. +``` + +**`guestListingGate` actual behavior** (`src/middleware/guestListingGate.js`): +- If `req.user` is set → passes through with no modification +- If guest → silently caps `req.query.limit` to `GUEST_MAX_LISTINGS_PER_REQUEST = 20` **only if requested limit exceeds 20** +- Does NOT block or count guest browses, does NOT touch Redis +- Sets `req.query = { ...req.query, limit: 20 }` via object spread (req.query was already made writable by `validate`) + +**Photo upload flow** (`src/routes/listing.js` + `src/middleware/upload.js` + `src/services/photo.service.js`): + +Full chain for `POST /listings/:listingId/photos`: +``` +authenticate +→ upload.single("photo") [Multer: MIME check, extension cross-check, 10MB limit, writes to uploads/staging/] +→ validate(uploadPhotoSchema) [Zod: listingId UUID param] +→ photoController.uploadPhoto + → photoService.enqueuePhotoUpload(posterId, listingId, req.file.path) + → pool.connect() → BEGIN + → SELECT listing_id FROM listings WHERE ... FOR UPDATE [listing lock] + → SELECT COALESCE(MAX(display_order),-1)+1 FROM listing_photos [server-side order] + → INSERT INTO listing_photos (photo_id, ..., photo_url='processing:{photoId}') + → COMMIT + → getQueue("media-processing").add("process-photo", { listingId, photoId, stagingPath }) + → [If queue.add() throws] → UPDATE listing_photos SET deleted_at=NOW() [cleanup] + → throw AppError 503 "Photo processing queue is temporarily unavailable" + → return { photoId, status: "processing" } + → res.status(202).json(...) + +[Async — after HTTP response sent] +mediaProcessor worker (concurrency: 1): + → sharp(stagingPath).resize(1200,1200).webp({quality:80}).withMetadata(false).toBuffer() + → storageService.upload(buffer, listingId, filename) [AzureBlobAdapter: 30s AbortController timeout] + → UPDATE listing_photos SET photo_url = finalUrl WHERE photo_id = ... + → UPDATE listing_photos SET is_cover = TRUE WHERE ... AND NOT EXISTS (SELECT 1 ... WHERE is_cover=TRUE) + → fs.unlink(stagingPath) +``` + +**503 queue-failure path** is real and documented in source but absent from docs: +```js +} catch (queueErr) { + // soft-delete the provisional row + await pool.query(`UPDATE listing_photos SET deleted_at = NOW() WHERE photo_id = $1 ...`, [photoId, listingId]); + throw new AppError("Photo processing queue is temporarily unavailable. Please retry.", 503); +} +``` + +### Exact fix for `docs/api/listings.api.md` + +**1. Fix the middleware chain description** (add to the "Auth Policy for Read Routes" section): + +```markdown +### Exact middleware chain for read endpoints + +**`GET /listings`**: +``` +optionalAuthenticate → validate(searchListingsSchema) → guestListingGate → listingController.searchListings +``` +Note: `validate` runs before `guestListingGate` intentionally — Zod coerces `limit` to a number before the gate reads it. + +**`GET /listings/:listingId`**: +``` +optionalAuthenticate → validate(listingParamsSchema) → guestListingGate → listingController.getListing +``` + +**`guestListingGate` behavior**: +- Authenticated users (`req.user` set): passes through with no changes +- Guests: silently caps `req.query.limit` to `20` if requested value exceeds `20` +- Does not block browsing, does not touch Redis, does not count requests +``` + +**2. Fix the photo upload scenario to include the full chain:** + +```markdown +### `POST /listings/:listingId/photos` + +**Middleware chain:** +``` +authenticate → upload.single("photo") → validate(uploadPhotoSchema) → photoController.uploadPhoto +``` + +**Upload middleware (`src/middleware/upload.js`)**: +- Field name must be `photo` (LIMIT_UNEXPECTED_FILE → 400 if wrong) +- Accepted MIME types: `image/jpeg`, `image/png`, `image/webp` +- Extension cross-checked against MIME type (e.g. `.txt` claiming `image/jpeg` → 400) +- Max file size: `10MB` (LIMIT_FILE_SIZE → 413) +- Writes staged file to `uploads/staging/{uuid}.ext` + +**Service layer (`src/services/photo.service.js` → `enqueuePhotoUpload`)**: +1. Acquires row-level lock: `SELECT ... FROM listings WHERE ... FOR UPDATE` +2. Allocates `display_order` server-side: `SELECT COALESCE(MAX(display_order), -1) + 1` +3. Inserts provisional row with `photo_url = 'processing:{photoId}'` +4. Commits transaction +5. Calls `getQueue("media-processing").add("process-photo", { ... })` + +**Queue failure path** (Redis unavailable after DB commit): +- Soft-deletes the provisional photo row +- Returns `503 { "message": "Photo processing queue is temporarily unavailable. Please retry." }` + +**Async processing** (`src/workers/mediaProcessor.js`, concurrency: 1): +- Sharp: resize to max 1200×1200, WebP quality 80, strip EXIF metadata +- Upload to Azure Blob (30s AbortController timeout — `504` if exceeded) +- `UPDATE listing_photos SET photo_url = finalUrl` +- Cover election: `UPDATE ... SET is_cover = TRUE WHERE NOT EXISTS (SELECT 1 ... WHERE is_cover = TRUE)` +- Deletes staging file + +**Scenario: upload accepted and queued** + +Status: `202` +```json +{ + "status": "success", + "data": { + "photoId": "6fd7cf0b-d6a7-4f31-bd0a-fdbcf5a5ef2e", + "status": "processing" + } +} +``` + +**Scenario: queue unavailable** + +Status: `503` +```json +{ + "status": "error", + "message": "Photo processing queue is temporarily unavailable. Please retry." +} +``` +``` + +--- + +## Finding 5 — Zod-Derived Request Contracts Incomplete + +### What the finding says + +Docs miss important validator constraints actually enforced by Zod. Specific examples: +- `isRead` in notification feed accepts `"true"/"false"` strings and numeric `0`/`1` +- Auth refresh/logout body token is optional due to cookie mode +- Cursor pair rules not fully specified across all keyset endpoints + +### Verification against source + +**`src/validators/notification.validators.js` — `isRead` preprocessing:** +```js +isRead: z.preprocess((val) => { + if (typeof val === "string") { + if (val.toLowerCase() === "true") return true; + if (val.toLowerCase() === "false") return false; + return val; // anything else passes through → z.boolean() rejects it + } + if (typeof val === "number") { + if (val === 0) return false; + if (val === 1) return true; + return val; // 2, -1, etc. pass through → z.boolean() rejects + } + return val; +}, z.boolean()).optional() +``` +Accepts: `"true"`, `"false"`, `"TRUE"`, `"FALSE"`, `0`, `1`. Rejects: `"yes"`, `2`, `"1"` (string). + +**`src/validators/auth.validators.js` — optional refresh token body:** +```js +const optionalRefreshTokenBody = z + .object({ + refreshToken: z.string().min(1).optional(), + }) + .optional() + .default({}); + +export const refreshSchema = z.object({ body: optionalRefreshTokenBody }); +export const logoutCurrentSchema = z.object({ body: optionalRefreshTokenBody }); +``` +Entire body is optional (defaults to `{}`). `refreshToken` within it is also optional. Controller resolves via `req.body?.refreshToken ?? req.cookies?.refreshToken`. + +**`src/validators/pagination.validators.js` — cursor pair rule:** +```js +.refine( + (data) => { + const hasTime = data.cursorTime !== undefined; + const hasId = data.cursorId !== undefined; + return hasTime === hasId; + }, + { error: "cursorTime and cursorId must be provided together", path: ["cursorTime"] } +) +``` +This refine is in `buildKeysetPaginationQuerySchema()` which is used by: `searchListingsSchema`, `getListingInterestsSchema`, `getMyInterestsSchema`, `getFeedSchema` (notifications), `getPublicRatingsSchema`, `getMyGivenRatingsSchema`, `getPublicPropertyRatingsSchema`. But `listPropertiesSchema` uses its own cursor validation (not via `buildKeysetPaginationQuerySchema`), with the same refine applied locally. + +### Exact fixes + +**Fix `docs/api/notifications.md` — add to `GET /notifications` request contract:** + +```markdown +### Query parameter details + +**`isRead`** (optional boolean filter): + +Accepts the following values: +- String: `"true"` or `"false"` (case-insensitive: `"TRUE"`, `"False"` also work) +- Numeric: `0` (false) or `1` (true) +- Omit entirely to receive all notifications (read + unread) + +Values like `"yes"`, `"1"` (as string), `2` are rejected with `400 Validation failed`. +``` + +**Fix `docs/api/auth.md` — `POST /auth/refresh` and `POST /auth/logout` request contracts:** + +```markdown +### Request body + +The request body is **fully optional** for browser clients using cookie transport. + +- **Cookie mode (browser):** Send no body. The controller reads `req.cookies.refreshToken` automatically. +- **Bearer mode (Android/API):** Send `{ "refreshToken": "..." }` in the body. + +The validator accepts an empty body (`{}`), an absent body, or a body with `refreshToken`. Missing `refreshToken` in body is not an error if the cookie is present — the controller resolves: `req.body?.refreshToken ?? req.cookies?.refreshToken`. +``` + +**Fix all paginated endpoint docs — add explicit cursor pair rule note:** + +Add to the shared `docs/api/conventions.md` pagination section: + +```markdown +### Cursor pairing enforcement + +All keyset-paginated endpoints enforce that `cursorTime` and `cursorId` must be supplied together or both omitted. This is validated by Zod before the request reaches the controller. + +Sending only one cursor field returns `400`: + +```json +{ + "status": "error", + "message": "Validation failed", + "errors": [{ "field": "query.cursorTime", "message": "cursorTime and cursorId must be provided together" }] +} +``` + +This rule applies to: `GET /listings`, `GET /listings/:listingId/interests`, `GET /interests/me`, `GET /notifications`, `GET /ratings/user/:userId`, `GET /ratings/property/:propertyId`, `GET /ratings/me/given`, `GET /ratings/connection/:connectionId`, `GET /connections/me`, `GET /properties`, `GET /listings/me/saved`. +``` + +--- + +## Finding 6 — Missing Appendix Sections + +### What the finding says + +Two cross-reference appendices are absent: +1. Middleware reference: maps each middleware → every route that uses it +2. Workers/crons reference: maps each worker/cron → affected endpoints and behavior + +### Verification against source + +Reviewing all route files (`src/routes/*.js`) and cross-referencing with middleware files (`src/middleware/*.js`) and worker/cron files. + +### Fix: Create `docs/api/appendix-middleware.md` + +```markdown +# Appendix: Middleware Reference + +Maps each middleware to every route that uses it, with its role in the chain. + +--- + +## `authenticate` (`src/middleware/authenticate.js`) + +Requires a valid access token. Sets `req.user`. Attempts silent refresh for expired cookie tokens only. + +| Route | Method | +| --- | --- | +| `POST /auth/logout/current` | POST | +| `POST /auth/logout/all` | POST | +| `GET /auth/sessions` | GET | +| `DELETE /auth/sessions/:sid` | DELETE | +| `POST /auth/otp/send` | POST | +| `POST /auth/otp/verify` | POST | +| `GET /auth/me` | GET | +| `POST /listings` | POST | +| `PUT /listings/:listingId` | PUT | +| `DELETE /listings/:listingId` | DELETE | +| `PATCH /listings/:listingId/status` | PATCH | +| `GET /listings/:listingId/preferences` | GET | +| `PUT /listings/:listingId/preferences` | PUT | +| `POST /listings/:listingId/save` | POST | +| `DELETE /listings/:listingId/save` | DELETE | +| `GET /listings/me/saved` | GET | +| `GET /listings/:listingId/photos` | GET | +| `POST /listings/:listingId/photos` | POST | +| `DELETE /listings/:listingId/photos/:photoId` | DELETE | +| `PATCH /listings/:listingId/photos/:photoId/cover` | PATCH | +| `PUT /listings/:listingId/photos/reorder` | PUT | +| `POST /listings/:listingId/interests` | POST | +| `GET /listings/:listingId/interests` | GET | +| `GET /interests/me` | GET | +| `GET /interests/:interestId` | GET | +| `PATCH /interests/:interestId/status` | PATCH | +| `GET /connections/me` | GET | +| `GET /connections/:connectionId` | GET | +| `POST /connections/:connectionId/confirm` | POST | +| `GET /notifications` | GET | +| `GET /notifications/unread-count` | GET | +| `POST /notifications/mark-read` | POST | +| `GET /students/:userId/profile` | GET | +| `PUT /students/:userId/profile` | PUT | +| `GET /students/:userId/preferences` | GET | +| `PUT /students/:userId/preferences` | PUT | +| `GET /pg-owners/:userId/profile` | GET | +| `PUT /pg-owners/:userId/profile` | PUT | +| `POST /pg-owners/:userId/documents` | POST | +| `GET /properties` | GET | +| `GET /properties/:propertyId` | GET | +| `POST /properties` | POST | +| `PUT /properties/:propertyId` | PUT | +| `DELETE /properties/:propertyId` | DELETE | +| `GET /ratings/me/given` | GET | +| `GET /ratings/connection/:connectionId` | GET | +| `POST /ratings` | POST | +| `POST /ratings/:ratingId/report` | POST | +| `GET /preferences/meta` | GET | + +--- + +## `optionalAuthenticate` (`src/middleware/optionalAuthenticate.js`) + +Tries to set `req.user` if a valid token is present. Never returns 401. Any failure (expired, invalid, user deleted/suspended) → request continues as guest. + +Token extraction order: cookie → Authorization header (same as `authenticate`). + +| Route | Method | +| --- | --- | +| `GET /listings` | GET | +| `GET /listings/:listingId` | GET | +| `GET /students/:userId/contact/reveal` | GET | +| `POST /pg-owners/:userId/contact/reveal` | POST | + +--- + +## `authorize(role)` (`src/middleware/authorize.js`) + +Must run after `authenticate`. Checks `req.user.roles.includes(role)`. Returns `403 Forbidden` if role missing. + +**Note:** `authorize()` validates the role argument at route registration time (startup), not per-request. Misconfiguration throws at startup. + +| Route | Required Role | +| --- | --- | +| `GET /listings/me/saved` | `student` | +| `POST /listings/:listingId/save` | `student` | +| `DELETE /listings/:listingId/save` | `student` | +| `POST /listings/:listingId/interests` | `student` | +| `GET /interests/me` | `student` | +| `PUT /pg-owners/:userId/profile` | `pg_owner` | +| `POST /pg-owners/:userId/documents` | `pg_owner` | +| `GET /properties` | `pg_owner` | +| `POST /properties` | `pg_owner` | +| `PUT /properties/:propertyId` | `pg_owner` | +| `DELETE /properties/:propertyId` | `pg_owner` | + +--- + +## `contactRevealGate` (`src/middleware/contactRevealGate.js`) + +Must run after `optionalAuthenticate` and `validate`. Sets `req.contactReveal = { emailOnly, verified }`. + +**Behavior:** +- Verified users (`req.user?.isEmailVerified === true`): passes through, sets `emailOnly: false` → controller returns full bundle (email + phone) +- Guests/unverified: checks Redis counter `contactRevealAnon:{sha256(ip|ua)}`, then checks HttpOnly cookie `contactRevealAnonCount` +- If count ≥ 10: returns `429 CONTACT_REVEAL_LIMIT_REACHED` immediately +- Otherwise: installs a pre-response hook on `res.json`/`res.send`/`res.end` to increment quota **after** a successful 2xx response only +- Quota is incremented atomically via Lua script: `INCR key; if count==1 then EXPIRE key 30days` +- `Cache-Control: no-store` must be set on the response **before** this middleware runs (see route files) + +| Route | Method | +| --- | --- | +| `GET /students/:userId/contact/reveal` | GET | +| `POST /pg-owners/:userId/contact/reveal` | POST | + +--- + +## `guestListingGate` (`src/middleware/guestListingGate.js`) + +Must run after `validate` (relies on Zod-coerced `req.query.limit` being a number). + +**Behavior:** +- Authenticated (`req.user` set): no-op, passes through +- Guest: silently caps `req.query.limit` to `20` if the requested value exceeds `20` +- Does not block, does not count, does not touch Redis + +| Route | Method | +| --- | --- | +| `GET /listings` | GET | +| `GET /listings/:listingId` | GET | + +--- + +## `validate(schema)` (`src/middleware/validate.js`) + +Runs Zod's `schema.safeParse({ body, query, params })`. On success, writes `result.data` back to `req.body`, `req.params`, and `req.query` (using `Object.defineProperty` for query in Express 5 compatibility). On failure, passes `ZodError` to `next(err)` → global error handler. + +Used on virtually every route. Not individually listed here — see per-endpoint docs for the schema name. + +--- + +## `authLimiter` (`src/middleware/rateLimiter.js`) + +10 requests / 15-minute window. Redis-backed (`rl:auth:{ip}`). `passOnStoreError: true` — degrades to no limiting if Redis is down. + +| Route | Method | +| --- | --- | +| `POST /auth/register` | POST | +| `POST /auth/login` | POST | +| `POST /auth/logout/all` | POST | +| `POST /auth/refresh` | POST | +| `GET /auth/sessions` | GET | +| `DELETE /auth/sessions/:sid` | DELETE | +| `POST /auth/google/callback` | POST | + +--- + +## `otpLimiter` (`src/middleware/rateLimiter.js`) + +5 requests / 15-minute window. Redis-backed (`rl:otp:{ip}`). `passOnStoreError: true`. + +| Route | Method | +| --- | --- | +| `POST /auth/otp/send` | POST | + +--- + +## `publicRatingsLimiter` (`src/middleware/rateLimiter.js`) + +120 requests / 15-minute window. Redis-backed (`rl:ratings:public:{ip}`). `passOnStoreError: true`. + +| Route | Method | +| --- | --- | +| `GET /ratings/user/:userId` | GET | +| `GET /ratings/property/:propertyId` | GET | + +--- + +## `upload.single("photo")` (`src/middleware/upload.js`) + +Multer disk storage. Writes to `uploads/staging/{uuid}.ext`. Validates MIME type and file extension cross-match. Limits: 10MB file size, 1 file per request. + +| Route | Method | +| --- | --- | +| `POST /listings/:listingId/photos` | POST | +``` + +### Fix: Create `docs/api/appendix-workers-crons.md` + +```markdown +# Appendix: Workers and Cron Jobs Reference + +--- + +## BullMQ Workers + +### `media-processing` queue — `src/workers/mediaProcessor.js` + +**Concurrency:** 1 (CPU-bound Sharp processing) + +**Triggered by:** `POST /listings/:listingId/photos` (via `photo.service.enqueuePhotoUpload`) + +**Job payload:** `{ listingId, photoId, stagingPath, posterId }` + +**Processing steps:** +1. `sharp(stagingPath).resize(1200, 1200, { fit: "inside" }).webp({ quality: 80 }).withMetadata(false).toBuffer()` +2. `storageService.upload(buffer, listingId, filename)` — AzureBlobAdapter with 30s AbortController timeout +3. `UPDATE listing_photos SET photo_url = finalUrl WHERE photo_id = ?` +4. Cover election: `UPDATE ... SET is_cover = TRUE WHERE NOT EXISTS (SELECT 1 ... WHERE is_cover = TRUE)` +5. `fs.unlink(stagingPath)` + +**Failure handling:** +- If photo row was soft-deleted before processing completed: deletes the already-uploaded permanent file, skips DB update +- `storageService.upload` timeout (30s): throws `AppError 504`, job fails → BullMQ retries (max 3 attempts) +- Stage file cleanup failures: logged as warn, does not fail the job + +**Affects these endpoint-visible states:** +- `GET /listings/:listingId/photos` — photo becomes visible only after worker sets real URL (placeholder `processing:{photoId}` is filtered out) +- `GET /listings/:listingId` — cover photo in `photos` array + +--- + +### `notification-delivery` queue — `src/workers/notificationWorker.js` + +**Concurrency:** 10 (I/O-bound DB inserts) + +**Job options:** 5 attempts, exponential backoff (2s base) + +**Triggered by:** `enqueueNotification()` calls in: +- `interest.service.js` — `createInterestRequest`, `transitionInterestRequest`, `_acceptInterestRequest` +- `connection.service.js` — `confirmConnection` +- `rating.service.js` — `submitRating` +- `cron/listingExpiry.js` — listing expired +- `cron/expiryWarning.js` — listing expiring soon +- `verificationEventWorker.js` — verification approved/rejected + +**Processing:** `INSERT INTO notifications ... ON CONFLICT (idempotency_key) DO NOTHING` + +Idempotency key = BullMQ `job.id`. Retry-safe: duplicate inserts are silently skipped. + +**Affects:** `GET /notifications`, `GET /notifications/unread-count` + +--- + +### `email-delivery` queue — `src/workers/emailWorker.js` + +**Concurrency:** 3 (I/O-bound SMTP/REST) + +**Job options:** 3 attempts, exponential backoff (5s base) + +**Triggered by:** `enqueueEmail()` calls in: +- `auth.service.js` → `sendOtp()` — triggers on `POST /auth/otp/send` +- `verificationEventWorker.js` — all three verification event types + +**Handlers:** + +| Job type | Email function | Triggered by | +| --- | --- | --- | +| `otp` | `sendOtpEmail()` | `POST /auth/otp/send` | +| `verification_approved` | `sendVerificationApprovedEmail()` | verificationEventWorker | +| `verification_rejected` | `sendVerificationRejectedEmail()` | verificationEventWorker | +| `verification_pending` | `sendVerificationPendingEmail()` | verificationEventWorker | + +Unknown job type: logged at warn level, job marked complete without sending. + +--- + +### `verificationEventWorker` — `src/workers/verificationEventWorker.js` + +**Type:** Not BullMQ — `setInterval` polling loop + +**Poll interval:** 5 seconds + +**Batch size:** 10 events per cycle + +**Max attempts per event:** 5 + +**Triggered by:** Postgres trigger `trg_verification_status_changed` which fires on `AFTER UPDATE OF status ON verification_requests` + +**API routes that can trigger the trigger:** +- `POST /admin/verification-queue/:requestId/approve` → `verification.service.approveRequest()` +- `POST /admin/verification-queue/:requestId/reject` → `verification.service.rejectRequest()` +- `POST /pg-owners/:userId/documents` → `verification.service.submitDocument()` → sets status to `pending` + +**Also triggered by:** Any direct SQL UPDATE on `verification_requests.status` (migration scripts, admin psql sessions) + +**Actions per event:** +- `verification_approved`: correct `pg_owner_profiles.verification_status` if needed → `enqueueNotification` + `enqueueEmail` +- `verification_rejected`: correct profile status → `enqueueNotification` + `enqueueEmail` +- `verification_pending`: `enqueueEmail` only (no in-app notification) + +**Concurrency safety:** `SELECT ... FOR UPDATE SKIP LOCKED` — safe for multi-instance deployments + +**Shutdown behavior:** `clearInterval` only — does not drain in-flight events. Events retried on next startup. + +--- + +## Cron Jobs + +### `listingExpiry` — `src/cron/listingExpiry.js` + +**Schedule:** `0 2 * * *` (02:00 daily). Override: `CRON_LISTING_EXPIRY` env var. + +**What it does:** +```sql +UPDATE listings SET status = 'expired' +WHERE status = 'active' AND expires_at < NOW() AND deleted_at IS NULL +RETURNING listing_id, posted_by +``` +Then in the same transaction: +```sql +UPDATE interest_requests SET status = 'expired' +WHERE listing_id = ANY($expiredIds) AND status = 'pending' AND deleted_at IS NULL +``` +Post-commit: `enqueueNotification({ type: "listing_expired" })` for each expired listing's poster. + +**API impact:** +- Expired listings no longer appear in `GET /listings` search results +- `POST /listings/:listingId/save` and `POST /listings/:listingId/interests` return 422 for expired listings +- Pending interest requests on expired listings become `expired` status + +--- + +### `expiryWarning` — `src/cron/expiryWarning.js` + +**Schedule:** `0 1 * * *` (01:00 daily). Override: `CRON_EXPIRY_WARNING` env var. + +**What it does:** Finds active listings expiring within 7 days that have NOT already received a warning today. Inserts notification rows directly (not via BullMQ) inside a transaction with idempotency key `expiry_warning:{listing_id}:{YYYY-MM-DD}`. Uses advisory lock `pg_try_advisory_xact_lock(7001)` to prevent concurrent runs. + +Post-commit: `enqueueNotification({ type: "listing_expiring" })` for each newly inserted warning. + +**Idempotent:** Re-running on the same day produces no duplicates (ON CONFLICT DO NOTHING on idempotency_key). + +**API impact:** Adds `listing_expiring` notification to poster's feed. + +--- + +### `hardDeleteCleanup` — `src/cron/hardDeleteCleanup.js` + +**Schedule:** `0 4 * * 0` (04:00 Sundays). Override: `CRON_HARD_DELETE` env var. + +**Retention period:** `SOFT_DELETE_RETENTION_DAYS` env var (default `90`). Must be a plain decimal integer (e.g. `"90"`). Values like `"90days"` or `"-30"` fall back to 90 with a warn log. + +**What it does:** Hard-deletes rows with `deleted_at < NOW() - N days` across all soft-delete tables, in dependency order to avoid FK violations: + +1. `rating_reports` → 2. `ratings` → 3. `notifications` → 4. `connections` (guarded: skips if not-yet-aged rating references it) → 5. `interest_requests` → 6. `saved_listings` → 7. `listing_photos` → 8. `listings` (guarded: skips if child rows not yet aged) → 9. `verification_requests` → 10. `pg_owner_profiles` → 11. `student_profiles` → 12. `properties` (guarded) → 13. `institutions` → 14. `users` (guarded: skips if any not-yet-aged child references it) + +All in one transaction. Rollback on any error. + +**API impact:** Reduces DB size. Rows aged past retention are permanently unrecoverable. No direct endpoint impact. +``` + +--- + +## Summary of All Changes Required + +| Finding | File(s) to change | Type of change | +| --- | --- | --- | +| 1 | `docs/api/notifications.md` | Update notification type table, add CDC pipeline section | +| 1 | `src/workers/notificationWorker.js` | Fix PLANNED→ACTIVE comments for verification types | +| 2 | `docs/api/admin.md` | Replace CDC side-effects note with full worker behavior table | +| 3 | `docs/api/auth.md` | Replace Transport Summary with full branch-semantics reference | +| 4 | `docs/api/listings.api.md` | Fix middleware chain order, add full photo upload chain + 503 path | +| 5 | `docs/api/notifications.md` | Add `isRead` accepted value details | +| 5 | `docs/api/auth.md` | Add optional body note for refresh/logout | +| 5 | `docs/api/conventions.md` | Add cursor pair rule with full endpoint list | +| 6 | `docs/api/appendix-middleware.md` | **Create new file** — full middleware → route mapping | +| 6 | `docs/api/appendix-workers-crons.md` | **Create new file** — full worker/cron → endpoint mapping | +| 6 | `docs/README.md` | Add links to new appendix files | diff --git a/docs/README.md b/docs/README.md index c67241e..1803f91 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,6 +37,9 @@ Feature-level API documentation lives in `docs/api/`. - [api/preferences.md](./api/preferences.md) — preferences metadata and student preferences endpoints - [api/admin.md](./api/admin.md) — admin surface scenarios (and current mount status) - [api/health.md](./api/health.md) — dependency health probe behavior +- [api/appendix-middleware.md](./api/appendix-middleware.md) — middleware-to-route reference and chain behavior notes +- [api/appendix-workers-crons.md](./api/appendix-workers-crons.md) — worker/cron responsibilities, triggers, retries, + and endpoint-visible effects ## Service Contracts diff --git a/docs/api/admin.md b/docs/api/admin.md index 158fcb5..845ab7b 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -265,10 +265,45 @@ Status: `409` } ``` -## CDC side-effects note +## CDC Outbox: Verification Side Effects -When verification status changes to `verified`, `rejected`, or `pending`, trigger `trg_verification_status_changed` -writes to `verification_event_outbox`. `verificationEventWorker` polls this table (5-second interval) and enqueues: +Verification status changes trigger a CDC (Change Data Capture) outbox pipeline, not direct synchronous effects from the +API. -- in-app notification (`notification-delivery`) -- email (`email-delivery`) +### Trigger + +The Postgres trigger `trg_verification_status_changed` fires on `AFTER UPDATE OF status ON verification_requests` for +any status change to `verified`, `rejected`, or `pending`. This fires regardless of whether the change comes from the +API or direct SQL. + +### Worker behavior (`src/workers/verificationEventWorker.js`) + +| Property | Value | +| ------------------ | -------------------------------------------------------------------------- | +| Poll interval | 5 seconds | +| Batch size | 10 events per cycle | +| Max retry attempts | 5 (then event is abandoned with error recorded) | +| Concurrency safe | Yes — `FOR UPDATE SKIP LOCKED` prevents double-processing across instances | +| Startup behavior | Runs one immediate drain on startup to catch accumulated events | + +### Per-event actions + +| Event type | In-app notification | Email | +| ----------------------- | ------------------- | ------------------------------------- | +| `verification_approved` | Yes | Yes (`sendVerificationApprovedEmail`) | +| `verification_rejected` | Yes | Yes (`sendVerificationRejectedEmail`) | +| `verification_pending` | No | Yes (`sendVerificationPendingEmail`) | + +### Profile consistency guard + +Before enqueuing notifications, the worker verifies `pg_owner_profiles.verification_status` matches the event type. If +inconsistent (for example, admin ran partial SQL), the worker corrects the profile status in the same DB transaction as +the event acknowledgement. + +### Failure behavior + +- If `processEvent()` throws, `attempts` is incremented and `error_message` recorded. +- After `MAX_ATTEMPTS = 5` failures, `processed_at` is set — event is permanently skipped but remains in table for + inspection. +- Graceful shutdown (`SIGTERM`) calls `clearInterval` only — in-flight events are not drained and will be retried on + next startup. diff --git a/docs/api/appendix-middleware.md b/docs/api/appendix-middleware.md new file mode 100644 index 0000000..ae03f7b --- /dev/null +++ b/docs/api/appendix-middleware.md @@ -0,0 +1,199 @@ +# Appendix: Middleware Reference + +Maps each middleware to every route that uses it, with its role in the chain. + +--- + +## `authenticate` (`src/middleware/authenticate.js`) + +Requires a valid access token. Sets `req.user`. Attempts silent refresh for expired cookie tokens only. + +| Route | Method | +| -------------------------------------------------- | ------ | +| `POST /auth/logout/current` | POST | +| `POST /auth/logout/all` | POST | +| `GET /auth/sessions` | GET | +| `DELETE /auth/sessions/:sid` | DELETE | +| `POST /auth/otp/send` | POST | +| `POST /auth/otp/verify` | POST | +| `GET /auth/me` | GET | +| `POST /listings` | POST | +| `PUT /listings/:listingId` | PUT | +| `DELETE /listings/:listingId` | DELETE | +| `PATCH /listings/:listingId/status` | PATCH | +| `GET /listings/:listingId/preferences` | GET | +| `PUT /listings/:listingId/preferences` | PUT | +| `POST /listings/:listingId/save` | POST | +| `DELETE /listings/:listingId/save` | DELETE | +| `GET /listings/me/saved` | GET | +| `GET /listings/:listingId/photos` | GET | +| `POST /listings/:listingId/photos` | POST | +| `DELETE /listings/:listingId/photos/:photoId` | DELETE | +| `PATCH /listings/:listingId/photos/:photoId/cover` | PATCH | +| `PUT /listings/:listingId/photos/reorder` | PUT | +| `POST /listings/:listingId/interests` | POST | +| `GET /listings/:listingId/interests` | GET | +| `GET /interests/me` | GET | +| `GET /interests/:interestId` | GET | +| `PATCH /interests/:interestId/status` | PATCH | +| `GET /connections/me` | GET | +| `GET /connections/:connectionId` | GET | +| `POST /connections/:connectionId/confirm` | POST | +| `GET /notifications` | GET | +| `GET /notifications/unread-count` | GET | +| `POST /notifications/mark-read` | POST | +| `GET /students/:userId/profile` | GET | +| `PUT /students/:userId/profile` | PUT | +| `GET /students/:userId/preferences` | GET | +| `PUT /students/:userId/preferences` | PUT | +| `GET /pg-owners/:userId/profile` | GET | +| `PUT /pg-owners/:userId/profile` | PUT | +| `POST /pg-owners/:userId/documents` | POST | +| `GET /properties` | GET | +| `GET /properties/:propertyId` | GET | +| `POST /properties` | POST | +| `PUT /properties/:propertyId` | PUT | +| `DELETE /properties/:propertyId` | DELETE | +| `GET /ratings/me/given` | GET | +| `GET /ratings/connection/:connectionId` | GET | +| `POST /ratings` | POST | +| `POST /ratings/:ratingId/report` | POST | +| `GET /preferences/meta` | GET | + +--- + +## `optionalAuthenticate` (`src/middleware/optionalAuthenticate.js`) + +Tries to set `req.user` if a valid token is present. Never returns 401. Any failure (expired, invalid, user +deleted/suspended) -> request continues as guest. + +Token extraction order: cookie -> Authorization header (same as `authenticate`). + +| Route | Method | +| ---------------------------------------- | ------ | +| `GET /listings` | GET | +| `GET /listings/:listingId` | GET | +| `GET /students/:userId/contact/reveal` | GET | +| `POST /pg-owners/:userId/contact/reveal` | POST | + +--- + +## `authorize(role)` (`src/middleware/authorize.js`) + +Must run after `authenticate`. Checks `req.user.roles.includes(role)`. Returns `403 Forbidden` if role missing. + +**Note:** `authorize()` validates the role argument at route registration time (startup), not per-request. +Misconfiguration throws at startup. + +| Route | Required Role | +| ------------------------------------- | ------------- | +| `GET /listings/me/saved` | `student` | +| `POST /listings/:listingId/save` | `student` | +| `DELETE /listings/:listingId/save` | `student` | +| `POST /listings/:listingId/interests` | `student` | +| `GET /interests/me` | `student` | +| `PUT /pg-owners/:userId/profile` | `pg_owner` | +| `POST /pg-owners/:userId/documents` | `pg_owner` | +| `GET /properties` | `pg_owner` | +| `POST /properties` | `pg_owner` | +| `PUT /properties/:propertyId` | `pg_owner` | +| `DELETE /properties/:propertyId` | `pg_owner` | + +--- + +## `contactRevealGate` (`src/middleware/contactRevealGate.js`) + +Must run after `optionalAuthenticate` and `validate`. Sets `req.contactReveal = { emailOnly, verified }`. + +**Behavior:** + +- Verified users (`req.user?.isEmailVerified === true`): passes through, sets `emailOnly: false` -> controller returns + full bundle (email + phone) +- Guests/unverified: checks Redis counter `contactRevealAnon:{sha256(ip|ua)}`, then checks HttpOnly cookie + `contactRevealAnonCount` +- If count >= 10: returns `429 CONTACT_REVEAL_LIMIT_REACHED` immediately +- Otherwise: installs a pre-response hook on `res.json`/`res.send`/`res.end` to increment quota after a successful 2xx + response only +- Quota is incremented atomically via Lua script: `INCR key; if count==1 then EXPIRE key 30days` +- `Cache-Control: no-store` must be set on the response before this middleware runs (see route files) + +| Route | Method | +| ---------------------------------------- | ------ | +| `GET /students/:userId/contact/reveal` | GET | +| `POST /pg-owners/:userId/contact/reveal` | POST | + +--- + +## `guestListingGate` (`src/middleware/guestListingGate.js`) + +Must run after `validate` (relies on Zod-coerced `req.query.limit` being a number). + +**Behavior:** + +- Authenticated (`req.user` set): no-op, passes through +- Guest: silently caps `req.query.limit` to `20` if the requested value exceeds `20` +- Does not block, does not count, does not touch Redis + +| Route | Method | +| -------------------------- | ------ | +| `GET /listings` | GET | +| `GET /listings/:listingId` | GET | + +--- + +## `validate(schema)` (`src/middleware/validate.js`) + +Runs Zod's `schema.safeParse({ body, query, params })`. On success, writes `result.data` back to `req.body`, +`req.params`, and `req.query` (using `Object.defineProperty` for query in Express 5 compatibility). On failure, passes +`ZodError` to `next(err)` -> global error handler. + +Used on virtually every route. Not individually listed here — see per-endpoint docs for the schema name. + +--- + +## `authLimiter` (`src/middleware/rateLimiter.js`) + +10 requests / 15-minute window. Redis-backed (`rl:auth:{ip}`). `passOnStoreError: true` — degrades to no limiting if +Redis is down. + +| Route | Method | +| ---------------------------- | ------ | +| `POST /auth/register` | POST | +| `POST /auth/login` | POST | +| `POST /auth/logout/all` | POST | +| `POST /auth/refresh` | POST | +| `GET /auth/sessions` | GET | +| `DELETE /auth/sessions/:sid` | DELETE | +| `POST /auth/google/callback` | POST | + +--- + +## `otpLimiter` (`src/middleware/rateLimiter.js`) + +5 requests / 15-minute window. Redis-backed (`rl:otp:{ip}`). `passOnStoreError: true`. + +| Route | Method | +| --------------------- | ------ | +| `POST /auth/otp/send` | POST | + +--- + +## `publicRatingsLimiter` (`src/middleware/rateLimiter.js`) + +120 requests / 15-minute window. Redis-backed (`rl:ratings:public:{ip}`). `passOnStoreError: true`. + +| Route | Method | +| ----------------------------------- | ------ | +| `GET /ratings/user/:userId` | GET | +| `GET /ratings/property/:propertyId` | GET | + +--- + +## `upload.single("photo")` (`src/middleware/upload.js`) + +Multer disk storage. Writes to `uploads/staging/{uuid}.ext`. Validates MIME type and file extension cross-match. Limits: +10MB file size, 1 file per request. + +| Route | Method | +| ---------------------------------- | ------ | +| `POST /listings/:listingId/photos` | POST | diff --git a/docs/api/appendix-workers-crons.md b/docs/api/appendix-workers-crons.md new file mode 100644 index 0000000..f65d329 --- /dev/null +++ b/docs/api/appendix-workers-crons.md @@ -0,0 +1,185 @@ +# Appendix: Workers and Cron Jobs Reference + +--- + +## BullMQ Workers + +### `media-processing` queue — `src/workers/mediaProcessor.js` + +**Concurrency:** 1 (CPU-bound Sharp processing) + +**Triggered by:** `POST /listings/:listingId/photos` (via `photo.service.enqueuePhotoUpload`) + +**Job payload:** `{ listingId, photoId, stagingPath, posterId }` + +**Processing steps:** + +1. `sharp(stagingPath).resize(1200, 1200, { fit: "inside" }).webp({ quality: 80 }).withMetadata(false).toBuffer()` +2. `storageService.upload(buffer, listingId, filename)` — AzureBlobAdapter with 30s AbortController timeout +3. `UPDATE listing_photos SET photo_url = finalUrl WHERE photo_id = ?` +4. Cover election: `UPDATE ... SET is_cover = TRUE WHERE NOT EXISTS (SELECT 1 ... WHERE is_cover = TRUE)` +5. `fs.unlink(stagingPath)` + +**Failure handling:** + +- If photo row was soft-deleted before processing completed: deletes the already-uploaded permanent file, skips DB + update +- `storageService.upload` timeout (30s): throws `AppError 504`, job fails -> BullMQ retries (max 3 attempts) +- Stage file cleanup failures: logged as warn, does not fail the job + +**Affects these endpoint-visible states:** + +- `GET /listings/:listingId/photos` — photo becomes visible only after worker sets real URL (placeholder + `processing:{photoId}` is filtered out) +- `GET /listings/:listingId` — cover photo in `photos` array + +--- + +### `notification-delivery` queue — `src/workers/notificationWorker.js` + +**Concurrency:** 10 (I/O-bound DB inserts) + +**Job options:** 5 attempts, exponential backoff (2s base) + +**Triggered by:** `enqueueNotification()` calls in: + +- `interest.service.js` — `createInterestRequest`, `transitionInterestRequest`, `_acceptInterestRequest` +- `connection.service.js` — `confirmConnection` +- `rating.service.js` — `submitRating` +- `cron/listingExpiry.js` — listing expired +- `cron/expiryWarning.js` — listing expiring soon +- `verificationEventWorker.js` — verification approved/rejected + +**Processing:** `INSERT INTO notifications ... ON CONFLICT (idempotency_key) DO NOTHING` + +Idempotency key = BullMQ `job.id`. Retry-safe: duplicate inserts are silently skipped. + +**Affects:** `GET /notifications`, `GET /notifications/unread-count` + +--- + +### `email-delivery` queue — `src/workers/emailWorker.js` + +**Concurrency:** 3 (I/O-bound SMTP/REST) + +**Job options:** 3 attempts, exponential backoff (5s base) + +**Triggered by:** `enqueueEmail()` calls in: + +- `auth.service.js` -> `sendOtp()` — triggers on `POST /auth/otp/send` +- `verificationEventWorker.js` — all three verification event types + +**Handlers:** + +| Job type | Email function | Triggered by | +| ----------------------- | --------------------------------- | ----------------------- | +| `otp` | `sendOtpEmail()` | `POST /auth/otp/send` | +| `verification_approved` | `sendVerificationApprovedEmail()` | verificationEventWorker | +| `verification_rejected` | `sendVerificationRejectedEmail()` | verificationEventWorker | +| `verification_pending` | `sendVerificationPendingEmail()` | verificationEventWorker | + +Unknown job type: logged at warn level, job marked complete without sending. + +--- + +### `verificationEventWorker` — `src/workers/verificationEventWorker.js` + +**Type:** Not BullMQ — `setInterval` polling loop + +**Poll interval:** 5 seconds + +**Batch size:** 10 events per cycle + +**Max attempts per event:** 5 + +**Triggered by:** Postgres trigger `trg_verification_status_changed` which fires on +`AFTER UPDATE OF status ON verification_requests` + +**API routes that can trigger the trigger:** + +- `POST /admin/verification-queue/:requestId/approve` -> `verification.service.approveRequest()` +- `POST /admin/verification-queue/:requestId/reject` -> `verification.service.rejectRequest()` +- `POST /pg-owners/:userId/documents` -> `verification.service.submitDocument()` -> sets status to `pending` + +**Also triggered by:** Any direct SQL UPDATE on `verification_requests.status` (migration scripts, admin psql sessions) + +**Actions per event:** + +- `verification_approved`: correct `pg_owner_profiles.verification_status` if needed -> `enqueueNotification` + + `enqueueEmail` +- `verification_rejected`: correct profile status -> `enqueueNotification` + `enqueueEmail` +- `verification_pending`: `enqueueEmail` only (no in-app notification) + +**Concurrency safety:** `SELECT ... FOR UPDATE SKIP LOCKED` — safe for multi-instance deployments + +**Shutdown behavior:** `clearInterval` only — does not drain in-flight events. Events retried on next startup. + +--- + +## Cron Jobs + +### `listingExpiry` — `src/cron/listingExpiry.js` + +**Schedule:** `0 2 * * *` (02:00 daily). Override: `CRON_LISTING_EXPIRY` env var. + +**What it does:** + +```sql +UPDATE listings SET status = 'expired' +WHERE status = 'active' AND expires_at < NOW() AND deleted_at IS NULL +RETURNING listing_id, posted_by +``` + +Then in the same transaction: + +```sql +UPDATE interest_requests SET status = 'expired' +WHERE listing_id = ANY($expiredIds) AND status = 'pending' AND deleted_at IS NULL +``` + +Post-commit: `enqueueNotification({ type: "listing_expired" })` for each expired listing's poster. + +**API impact:** + +- Expired listings no longer appear in `GET /listings` search results +- `POST /listings/:listingId/save` and `POST /listings/:listingId/interests` return 422 for expired listings +- Pending interest requests on expired listings become `expired` status + +--- + +### `expiryWarning` — `src/cron/expiryWarning.js` + +**Schedule:** `0 1 * * *` (01:00 daily). Override: `CRON_EXPIRY_WARNING` env var. + +**What it does:** Finds active listings expiring within 7 days that have NOT already received a warning today. Inserts +notification rows directly (not via BullMQ) inside a transaction with idempotency key +`expiry_warning:{listing_id}:{YYYY-MM-DD}`. Uses advisory lock `pg_try_advisory_xact_lock(7001)` to prevent concurrent +runs. + +Post-commit: `enqueueNotification({ type: "listing_expiring" })` for each newly inserted warning. + +**Idempotent:** Re-running on the same day produces no duplicates (ON CONFLICT DO NOTHING on idempotency_key). + +**API impact:** Adds `listing_expiring` notification to poster's feed. + +--- + +### `hardDeleteCleanup` — `src/cron/hardDeleteCleanup.js` + +**Schedule:** `0 4 * * 0` (04:00 Sundays). Override: `CRON_HARD_DELETE` env var. + +**Retention period:** `SOFT_DELETE_RETENTION_DAYS` env var (default `90`). Must be a plain decimal integer (for example +`"90"`). Values like `"90days"` or `"-30"` fall back to 90 with a warn log. + +**What it does:** Hard-deletes rows with `deleted_at < NOW() - N days` across all soft-delete tables, in dependency +order to avoid FK violations: + +1. `rating_reports` -> 2. `ratings` -> 3. `notifications` -> 4. `connections` (guarded: skips if not-yet-aged rating + references it) -> 5. `interest_requests` -> 6. `saved_listings` -> 7. `listing_photos` -> 8. `listings` (guarded: + skips if child rows not yet aged) -> 9. `verification_requests` -> 10. `pg_owner_profiles` -> 11. `student_profiles` + -> 12. `properties` (guarded) -> 13. `institutions` -> 14. `users` (guarded: skips if any not-yet-aged child + references it) + +All in one transaction. Rollback on any error. + +**API impact:** Reduces DB size. Rows aged past retention are permanently unrecoverable. No direct endpoint impact. diff --git a/docs/api/auth.md b/docs/api/auth.md index 009ccd9..0712302 100644 --- a/docs/api/auth.md +++ b/docs/api/auth.md @@ -5,12 +5,64 @@ OAuth. Shared response and error conventions live in [conventions.md](./conventions.md). -## Transport Summary +## Auth Transport Reference -- 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. +### Token extraction priority + +`authenticate` and `optionalAuthenticate` extract the access token in this exact order: + +1. `req.cookies.accessToken` (HttpOnly cookie) — takes priority +2. `Authorization: Bearer ` header — used only if no cookie is present + +If both are present, the cookie always wins. + +### Transport mode: cookie (default, browser) + +- No special request header needed +- Auth endpoints set `accessToken` (TTL: `JWT_EXPIRES_IN`, default `15m`) and `refreshToken` (TTL: + `JWT_REFRESH_EXPIRES_IN`, default `7d`) as `HttpOnly; SameSite=Strict` cookies +- Response body for auth endpoints contains `{ user, sid }` only — no raw token strings +- `secure: true` in production, `secure: false` in development + +### Transport mode: bearer (mobile/API) + +- Set request header: `X-Client-Transport: bearer` (case-sensitive, lowercase `"bearer"`) +- Response body for auth endpoints includes `{ accessToken, refreshToken, user, sid }` +- For protected endpoints, send: `Authorization: Bearer ` + +### Silent refresh (cookie mode only) + +Triggers automatically inside `authenticate` middleware when: + +- `err.name === "TokenExpiredError"` and +- token came from `req.cookies.accessToken` (not from `Authorization` header) + +Silent refresh process: + +1. Reads `req.cookies.refreshToken` +2. Calls `verifyRefreshTokenPayload()` (supports legacy tokens missing `sid`) +3. Checks Redis key `refreshToken:{userId}:{sid}` and verifies exact token match +4. Loads fresh user state from DB +5. Signs new access token and refresh token +6. CAS-rotates refresh token atomically via Lua script +7. Sets new cookies on the response + +Silent refresh never triggers for an expired bearer header token. + +### Error response matrix by scenario + +| Scenario | Middleware | Response | +| --------------------------------------- | ---------------------- | ----------------------------------------------------------------------------- | +| No token present | `authenticate` | `401 { "message": "No token provided" }` | +| Bearer token expired | `authenticate` | `401 { "message": "Token has expired" }` | +| Cookie token expired + refresh succeeds | `authenticate` | Request continues transparently | +| Cookie token expired + refresh fails | `authenticate` | `401 { "message": "Session expired" }` | +| Token invalid (malformed/wrong secret) | `authenticate` | `401 { "message": "Invalid token" }` | +| User not found in DB | `authenticate` | `401 { "message": "User not found" }` | +| User suspended/banned/deactivated | `authenticate` | `401 { "message": "Account is suspended" }` (or corresponding account status) | +| Any token error | `optionalAuthenticate` | Request continues as guest (`req.user` is undefined) | +| Invalid/expired token | `optionalAuthenticate` | Request continues as guest — never returns `401` | +| Suspended user token | `optionalAuthenticate` | Request continues as guest | ## `POST /auth/register` @@ -298,7 +350,16 @@ Rotates the refresh token and issues a fresh access token. - Auth required: No - Rate limited: Yes -- Body: optional when using the refresh token cookie +- Body: optional (cookie clients can omit body entirely) + +### Request body behavior + +The request body is fully optional for browser clients using cookie transport. + +- Cookie mode (browser): send no body; controller reads `req.cookies.refreshToken` +- Bearer mode (mobile/API): send `{ "refreshToken": "..." }` in body + +Resolution order in controller: `req.body?.refreshToken ?? req.cookies?.refreshToken`. Bearer/mobile request example: @@ -406,6 +467,13 @@ Revokes a session using the refresh token from the body or cookie. This endpoint Cookie-based browser request can send no body. +The request body is optional in cookie mode and accepted in bearer mode: + +- Cookie mode (browser): send no body; controller reads `req.cookies.refreshToken` +- Bearer mode (mobile/API): send `{ "refreshToken": "..." }` + +Resolution order in controller: `req.body?.refreshToken ?? req.cookies?.refreshToken`. + Bearer/mobile request example: ```json diff --git a/docs/api/conventions.md b/docs/api/conventions.md index ad8e781..e930612 100644 --- a/docs/api/conventions.md +++ b/docs/api/conventions.md @@ -329,6 +329,25 @@ If only one cursor field is supplied, validation fails: } ``` +### Cursor pairing enforcement + +All keyset-paginated endpoints enforce that `cursorTime` and `cursorId` must be supplied together or both omitted. +Validation runs before controller logic. + +This rule applies to: + +- `GET /listings` +- `GET /listings/:listingId/interests` +- `GET /interests/me` +- `GET /notifications` +- `GET /ratings/user/:userId` +- `GET /ratings/property/:propertyId` +- `GET /ratings/me/given` +- `GET /ratings/connection/:connectionId` +- `GET /connections/me` +- `GET /properties` +- `GET /listings/me/saved` + ## Example Value Conventions The feature docs use consistent sample values. diff --git a/docs/api/listings.api.md b/docs/api/listings.api.md index e5a914e..bca3eef 100644 --- a/docs/api/listings.api.md +++ b/docs/api/listings.api.md @@ -16,10 +16,10 @@ Prices are expressed in rupees in the API, even though the database stores paise `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 | -| ---------------- | ------------------------------ | ------------------------- | --------------- | --------------- | +| Caller | Browsing | Compatibility score | Saving | Interest | +| ---------------- | ------------------------------------------------------------------- | ------------------------- | --------------- | --------------- | | Guest (no token) | ✅ Up to 20 items per request (server silently caps higher `limit`) | ❌ Always 0 / unavailable | ❌ 401 | ❌ 401 | -| Authenticated | ✅ Up to 100 items per request | ✅ When preferences exist | ✅ Student only | ✅ Student only | +| 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. @@ -27,6 +27,25 @@ compatibility scoring, and the inability to use write endpoints. Optional-auth exception: on these two read endpoints, invalid/expired bearer tokens are treated as guest access (the request is not rejected with `401` by `optionalAuthenticate`). +### Exact middleware chain for read endpoints + +`GET /listings`: + +`optionalAuthenticate -> validate(searchListingsSchema) -> guestListingGate -> listingController.searchListings` + +`GET /listings/:listingId`: + +`optionalAuthenticate -> validate(listingParamsSchema) -> guestListingGate -> listingController.getListing` + +`validate` runs before `guestListingGate` intentionally, so Zod-coerced numeric `limit` is available when the gate reads +it. + +`guestListingGate` behavior: + +- Authenticated users (`req.user` set): passes through unchanged +- Guests: silently caps `req.query.limit` to `20` only when requested value exceeds `20` +- Does not block browsing, does not touch Redis, does not count requests + ## Search and Retrieval ### `GET /listings` @@ -91,6 +110,7 @@ Status: `200` `compatibilityScore` is always `0` and `compatibilityAvailable` is always `false` for guests. Compatibility semantics for authenticated callers: + - `compatibilityAvailable = false` means either side has no saved preferences, so no comparison was possible. - `compatibilityAvailable = true` with `compatibilityScore = 0` means comparison happened, but no preferences matched. @@ -216,7 +236,8 @@ Status: `400` ### `GET /listings/:listingId` -Fetches full listing detail. Also increments `views_count` asynchronously (fire-and-forget side effect). Avoid polling this endpoint for background status checks. +Fetches full listing detail. Also increments `views_count` asynchronously (fire-and-forget side effect). Avoid polling +this endpoint for background status checks. #### Request Contract @@ -958,7 +979,8 @@ Status: `200` **Auth required.** Role: `student`. Returns the student's saved active listings. -Integration note: this endpoint currently returns a mixed casing payload (legacy `snake_case` fields from SQL rows plus camelCase rent/deposit fields). Treat the example response as authoritative until the response is normalized in code. +Integration note: this endpoint currently returns a mixed casing payload (legacy `snake_case` fields from SQL rows plus +camelCase rent/deposit fields). Treat the example response as authoritative until the response is normalized in code. #### Scenario: paginated saved listings @@ -1004,7 +1026,8 @@ Photo uploads are asynchronous. The HTTP request inserts a provisional row and q ### `GET /listings/:listingId/photos` -Returns completed photos only. Processing placeholders are hidden (`photo_url LIKE 'processing:%'` rows are filtered out until processing completes). +Returns completed photos only. Processing placeholders are hidden (`photo_url LIKE 'processing:%'` rows are filtered out +until processing completes). #### Scenario: success @@ -1029,11 +1052,40 @@ Status: `200` **Auth required.** Uploads a staged image using `multipart/form-data`. +#### Middleware chain + +`authenticate -> upload.single("photo") -> validate(uploadPhotoSchema) -> photoController.uploadPhoto` + #### Multipart requirements - field name: `photo` - accepted MIME types: `image/jpeg`, `image/png`, `image/webp` - max size: `10MB` +- extension must match declared MIME type (cross-checked by upload middleware) +- staged file path: `uploads/staging/{uuid}.ext` + +#### Service flow (`src/services/photo.service.js` -> `enqueuePhotoUpload`) + +1. Acquires row lock on listing (`SELECT ... FOR UPDATE`) while validating ownership +2. Allocates `display_order` server-side via `SELECT COALESCE(MAX(display_order), -1) + 1` +3. Inserts provisional row with `photo_url = 'processing:{photoId}'` +4. Commits transaction +5. Enqueues BullMQ job on `media-processing` queue (`process-photo`) + +#### Queue failure path + +If queue enqueue fails after DB commit (for example Redis unavailable): + +- provisional `listing_photos` row is soft-deleted +- endpoint returns `503` with retryable message + +#### Async processing (`src/workers/mediaProcessor.js`, concurrency: 1) + +- Sharp resize to max `1200x1200`, convert WebP quality 80, strip EXIF metadata +- Upload to Azure Blob via storage adapter (30s timeout) +- Update `listing_photos.photo_url` with final URL +- Elect cover photo if none exists +- Delete staging file (best-effort) #### Scenario: upload accepted and queued diff --git a/docs/api/notifications.md b/docs/api/notifications.md index e668cb6..074a3aa 100644 --- a/docs/api/notifications.md +++ b/docs/api/notifications.md @@ -8,22 +8,39 @@ Shared conventions: [conventions.md](./conventions.md) The `NOTIFICATION_MESSAGES` map in `src/workers/notificationWorker.js` is authoritative. -| Type | Message | Status | -| ---------------------------- | -------------------------------------------------- | --------------- | -| `interest_request_received` | Someone expressed interest in your listing | ACTIVE | -| `interest_request_accepted` | Your interest request was accepted | ACTIVE | -| `interest_request_declined` | Your interest request was declined | ACTIVE | -| `interest_request_withdrawn` | An interest request was withdrawn | ACTIVE | -| `connection_confirmed` | Your connection has been confirmed by both parties | ACTIVE | -| `rating_received` | You received a new rating | ACTIVE | -| `listing_expiring` | One of your listings is expiring soon | ACTIVE | -| `listing_expired` | One of your listings has expired | ACTIVE | -| `listing_filled` | A listing has been marked as filled | ACTIVE | -| `verification_pending` | We received your verification documents | EMAIL-ONLY event (not in-app feed) | -| `verification_approved` | Your verification request was approved | PLANNED emitter | -| `verification_rejected` | Your verification request was rejected | PLANNED emitter | -| `new_message` | You have a new message | PLANNED emitter | -| `connection_requested` | You have a new connection request | PLANNED emitter | +| Type | Message | Status | +| ---------------------------- | -------------------------------------------------- | ------------------------------------------- | +| `interest_request_received` | Someone expressed interest in your listing | ACTIVE | +| `interest_request_accepted` | Your interest request was accepted | ACTIVE | +| `interest_request_declined` | Your interest request was declined | ACTIVE | +| `interest_request_withdrawn` | An interest request was withdrawn | ACTIVE | +| `connection_confirmed` | Your connection has been confirmed by both parties | ACTIVE | +| `rating_received` | You received a new rating | ACTIVE | +| `listing_expiring` | One of your listings is expiring soon | ACTIVE | +| `listing_expired` | One of your listings has expired | ACTIVE | +| `listing_filled` | A listing has been marked as filled | ACTIVE | +| `verification_approved` | Your verification request was approved | ACTIVE (in-app + email) | +| `verification_rejected` | Your verification request was rejected | ACTIVE (in-app + email) | +| `verification_pending` | We received your verification documents | ACTIVE (email only, no in-app notification) | +| `new_message` | You have a new message | PLANNED — no emitter | +| `connection_requested` | You have a new connection request | PLANNED — no emitter | + +### Delivery pipeline for verification events + +Verification notifications are not triggered directly by the API controller. They are driven by a CDC (Change Data +Capture) outbox pattern: + +1. Admin calls `POST /admin/verification-queue/:requestId/approve|reject` +2. `verification.service.js` commits `UPDATE verification_requests SET status = 'verified'|'rejected'|'pending'` +3. Postgres trigger `trg_verification_status_changed` (migration `002`) writes to `verification_event_outbox` +4. `src/workers/verificationEventWorker.js` polls the outbox every 5 seconds using `SELECT ... FOR UPDATE SKIP LOCKED` +5. On each event, the worker calls: + - `enqueueNotification()` -> `notification-delivery` BullMQ queue -> in-app notification row + - `enqueueEmail()` -> `email-delivery` BullMQ queue -> Brevo REST/SMTP email +6. `verification_pending` emits email only (no `enqueueNotification` call in source) + +This means verification notifications can originate from direct SQL on the database (not just the API), as long as the +trigger fires. The worker handles retry with up to `MAX_ATTEMPTS = 5` retries per event. ## `GET /notifications` @@ -38,6 +55,16 @@ Returns the authenticated user's notification feed. - `cursorTime` - `cursorId` +### Query parameter details + +`isRead` (optional boolean filter): + +- String: `"true"` or `"false"` (case-insensitive: `"TRUE"`, `"False"` also work) +- Numeric: `0` (false) or `1` (true) +- Omit entirely to receive all notifications (read + unread) + +Values like `"yes"`, `"1"` (string), and `2` are rejected with `400 Validation failed`. + ### Scenario: fetch feed with pagination Status: `200` diff --git a/src/workers/notificationWorker.js b/src/workers/notificationWorker.js index 4404f04..0bff3e7 100644 --- a/src/workers/notificationWorker.js +++ b/src/workers/notificationWorker.js @@ -89,12 +89,12 @@ const NOTIFICATION_MESSAGES = { // Emitted post-commit in _acceptInterestRequest when capacity is exhausted. listing_filled: "A listing has been marked as filled", - // ── PLANNED: wire emitter in verification.service.js (Phase 5 admin work) ── - // Should fire from approveRequest() after committing the verification decision. + // ── ACTIVE: fired from verificationEventWorker.js (CDC pipeline) ─────────── + // Emitted when verification_requests.status transitions to verified. verification_approved: "Your verification request was approved", - // ── PLANNED: wire emitter in verification.service.js (Phase 5 admin work) ── - // Should fire from rejectRequest() after committing the rejection. + // ── ACTIVE: fired from verificationEventWorker.js (CDC pipeline) ─────────── + // Emitted when verification_requests.status transitions to rejected. verification_rejected: "Your verification request was rejected", // ── PLANNED: wire emitter once in-app messaging is implemented (Phase 6+) ── From 2e64cba6f417e9610f4d52cc7352cc21111280a1 Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Tue, 21 Apr 2026 17:51:41 +0530 Subject: [PATCH 13/54] chore: remove outdated QA audit findings documentation and add comprehensive updates across multiple API docs --- FixOutdated.md | 1008 ------------------------------------------------ 1 file changed, 1008 deletions(-) delete mode 100644 FixOutdated.md diff --git a/FixOutdated.md b/FixOutdated.md deleted file mode 100644 index ef30065..0000000 --- a/FixOutdated.md +++ /dev/null @@ -1,1008 +0,0 @@ -# FixOutdated.md — QA Audit Findings: Documentation vs Source - -> This file documents each QA finding, verifies it against the source, explains what the bug in the docs actually means, and prescribes the exact fix — accounting for all interdependencies. - ---- - -## Finding 1 — Notification Lifecycle Status Mismatch - -### What the finding says - -`docs/api/notifications.md` marks `verification_approved` and `verification_rejected` as having a "PLANNED emitter". The claim is that no active code enqueues in-app notifications for these types. - -### Verification against source - -**`src/workers/verificationEventWorker.js`** (lines inside `processEvent`): - -```js -// For verification_approved: -enqueueNotification({ - recipientId: user_id, - type: "verification_approved", - entityType: "verification_request", - entityId: request_id, -}); - -// For verification_rejected: -enqueueNotification({ - recipientId: user_id, - type: "verification_rejected", - entityType: "verification_request", - entityId: request_id, -}); -``` - -**`src/workers/notificationWorker.js`** — `NOTIFICATION_MESSAGES` map contains: -```js -verification_approved: "Your verification request was approved", -verification_rejected: "Your verification request was rejected", -``` -Both are annotated `PLANNED` in the notificationWorker comments, but the emit code in `verificationEventWorker.js` is **live and active**. The notificationWorker comments are also stale but that is a source comment issue, not just a docs issue. - -**`src/workers/emailWorker.js`** — `EMAIL_HANDLERS` map: -```js -verification_approved: async ({ to, data }) => { await sendVerificationApprovedEmail(...) }, -verification_rejected: async ({ to, data }) => { await sendVerificationRejectedEmail(...) }, -verification_pending: async ({ to, data }) => { await sendVerificationPendingEmail(...) }, -``` -All three are `ACTIVE` in the emailWorker already. - -**The trigger chain** that makes this active: -1. Admin calls `POST /admin/verification-queue/:requestId/approve` or `/reject` -2. `verification.service.approveRequest()` / `rejectRequest()` does `UPDATE verification_requests SET status = 'verified'|'rejected'` -3. Postgres trigger `trg_verification_status_changed` fires → inserts row into `verification_event_outbox` -4. `verificationEventWorker` polls every 5s → calls `processEvent()` → calls both `enqueueNotification()` and `enqueueEmail()` -5. `notificationWorker` processes the BullMQ job → INSERTs into `notifications` table -6. `emailWorker` processes the BullMQ email job → calls `sendVerificationApprovedEmail()` / `sendVerificationRejectedEmail()` - -**Conclusion: Finding is confirmed.** Both `verification_approved` and `verification_rejected` are fully active. `verification_pending` is also active (email only — no in-app notification because it is not enqueued in `verificationEventWorker` for `verification_pending` via `enqueueNotification`, only `enqueueEmail` is called). - -### What the docs currently say (incorrect) - -```markdown -| `verification_approved` | Your verification request was approved | PLANNED emitter | -| `verification_rejected` | Your verification request was rejected | PLANNED emitter | -``` - -### Exact fix for `docs/api/notifications.md` - -Replace the notification type table with the following: - -```markdown -| Type | Message | Status | -| ---------------------------- | -------------------------------------------------- | ---------------------------- | -| `interest_request_received` | Someone expressed interest in your listing | ACTIVE | -| `interest_request_accepted` | Your interest request was accepted | ACTIVE | -| `interest_request_declined` | Your interest request was declined | ACTIVE | -| `interest_request_withdrawn` | An interest request was withdrawn | ACTIVE | -| `connection_confirmed` | Your connection has been confirmed by both parties | ACTIVE | -| `rating_received` | You received a new rating | ACTIVE | -| `listing_expiring` | One of your listings is expiring soon | ACTIVE | -| `listing_expired` | One of your listings has expired | ACTIVE | -| `listing_filled` | A listing has been marked as filled | ACTIVE | -| `verification_approved` | Your verification request was approved | ACTIVE (in-app + email) | -| `verification_rejected` | Your verification request was rejected | ACTIVE (in-app + email) | -| `verification_pending` | We received your verification documents | ACTIVE (email only, no in-app notification) | -| `new_message` | You have a new message | PLANNED — no emitter | -| `connection_requested` | You have a new connection request | PLANNED — no emitter | -``` - -Add a note below the table: - -```markdown -### Delivery pipeline for verification events - -Verification notifications are **not triggered directly by the API controller**. They are driven by a CDC (Change Data Capture) outbox pattern: - -1. Admin calls `POST /admin/verification-queue/:requestId/approve|reject` -2. `verification.service.js` commits `UPDATE verification_requests SET status = 'verified'|'rejected'|'pending'` -3. Postgres trigger `trg_verification_status_changed` (migration `002`) writes to `verification_event_outbox` -4. `src/workers/verificationEventWorker.js` polls the outbox every **5 seconds** using `SELECT ... FOR UPDATE SKIP LOCKED` -5. On each event, the worker calls: - - `enqueueNotification()` → `notification-delivery` BullMQ queue → in-app notification row - - `enqueueEmail()` → `email-delivery` BullMQ queue → Brevo REST/SMTP email -6. `verification_pending` emits **email only** (no `enqueueNotification` call in source) - -This means verification notifications can originate from **direct SQL on the database** (not just the API), as long as the trigger fires. The worker handles retry with up to `MAX_ATTEMPTS = 5` retries per event. -``` - -Also fix `notificationWorker.js` source comments — change the annotation on `verification_approved` and `verification_rejected` from `PLANNED` to `ACTIVE` in the `NOTIFICATION_MESSAGES` map comments. This is a source file fix, not just docs. - ---- - -## Finding 2 — CDC Outbox Worker Behavior Undocumented - -### What the finding says - -API docs don't document that verification side effects are driven by DB-trigger → outbox → poll worker, and that state changes from outside the API (raw SQL, migrations) can also trigger notifications/emails. - -### Verification against source - -**`migrations/002_verification_event_outbox.sql`** — creates `verification_event_outbox` table and the trigger `trg_verification_status_changed` which fires on `AFTER UPDATE OF status ON verification_requests`. - -**`src/workers/verificationEventWorker.js`**: -- Polls every `POLL_INTERVAL_MS = 5_000` ms -- Batch size: `BATCH_SIZE = 10` -- Max retry attempts per event: `MAX_ATTEMPTS = 5` -- Uses `FOR UPDATE SKIP LOCKED` — safe for multi-instance deployments -- On startup, runs one immediate drain cycle (catches events that accumulated during downtime) -- Profile consistency guard: if `pg_owner_profiles.verification_status` is out of sync with the event type, the worker corrects it in the same transaction - -**`src/server.js`**: -```js -const verificationEventWorker = startVerificationEventWorker(); -// ... shutdown: -verificationEventWorker.close(); // clearInterval only — no async drain -``` - -The worker's `close()` is synchronous (`clearInterval`) — it does not drain in-flight events before shutdown. This means events in the middle of processing during a SIGTERM may be retried on next startup. - -### What the docs currently say - -There is no mention of any of this in `docs/api/notifications.md`, `docs/api/admin.md`, or `docs/API.md`. The admin doc has only this footnote: - -```markdown -## CDC side-effects note -When verification status changes to `verified`, `rejected`, or `pending`, trigger `trg_verification_status_changed` writes to `verification_event_outbox`. `verificationEventWorker` polls this table (5-second interval) and enqueues: -- in-app notification (`notification-delivery`) -- email (`email-delivery`) -``` - -This is present but incomplete — it doesn't note email-only for `pending`, doesn't cover the profile consistency guard, doesn't cover the retry model, and doesn't cover the "outside-API trigger" scenario. - -### Exact fix for `docs/api/admin.md` - -Replace the CDC side-effects note at the bottom with: - -```markdown -## CDC Outbox: Verification Side Effects - -Verification status changes trigger a **CDC (Change Data Capture) outbox pipeline**, not direct synchronous effects from the API. - -### Trigger - -The Postgres trigger `trg_verification_status_changed` fires on `AFTER UPDATE OF status ON verification_requests` for any status change to `verified`, `rejected`, or `pending`. This fires regardless of whether the change comes from the API or direct SQL. - -### Worker behavior (`src/workers/verificationEventWorker.js`) - -| Property | Value | -| ---------------- | ---------------------------- | -| Poll interval | 5 seconds | -| Batch size | 10 events per cycle | -| Max retry attempts | 5 (then event is abandoned with error recorded) | -| Concurrency safe | Yes — `FOR UPDATE SKIP LOCKED` prevents double-processing across instances | -| Startup behavior | Runs one immediate drain on startup to catch accumulated events | - -### Per-event actions - -| Event type | In-app notification | Email | -| ----------------------- | ------------------- | ----------------------------- | -| `verification_approved` | Yes | Yes (`sendVerificationApprovedEmail`) | -| `verification_rejected` | Yes | Yes (`sendVerificationRejectedEmail`) | -| `verification_pending` | No | Yes (`sendVerificationPendingEmail`) | - -### Profile consistency guard - -Before enqueuing notifications, the worker verifies `pg_owner_profiles.verification_status` matches the event type. If inconsistent (e.g. admin ran partial SQL), the worker corrects the profile status in the same DB transaction as the event acknowledgement. - -### Failure behavior - -- If `processEvent()` throws, `attempts` is incremented and `error_message` recorded. -- After `MAX_ATTEMPTS = 5` failures, `processed_at` is set — event is permanently skipped but remains in table for inspection. -- Graceful shutdown (`SIGTERM`) calls `clearInterval` only — in-flight events are not drained and will be retried on next startup. -``` - ---- - -## Finding 3 — Auth Transport Docs Missing Branch Semantics - -### What the finding says - -Docs don't fully specify the concrete branching behavior implemented in `authenticate.js` and `optionalAuthenticate.js`: -- Cookie vs bearer priority in token extraction -- Silent refresh triggers (cookie-expired only, not bearer) -- What exactly is returned on each failure path -- `optionalAuthenticate` degrading to guest on any token error - -### Verification against source - -**`src/middleware/authenticate.js` — `extractToken()`:** -```js -const extractToken = (req) => { - const cookieToken = req.cookies?.accessToken; - if (cookieToken) return { token: cookieToken, source: "cookie" }; - const authHeader = req.headers.authorization; - if (authHeader?.startsWith("Bearer ")) return { token: authHeader.slice(7), source: "header" }; - return null; -}; -``` -**Cookie takes priority unconditionally.** If both cookie and `Authorization` header are present, cookie wins. - -**`authenticate` — expired token branching:** -```js -} catch (err) { - if (err.name === "TokenExpiredError" && source === "cookie") { - const session = await attemptSilentRefresh(req, res); - if (!session?.userId) return next(new AppError("Session expired", 401)); - payload = session; - } else { - return next(err); // → global handler: "Token has expired" for bearer - } -} -``` - -**Silent refresh conditions** (`attemptSilentRefresh`): -- Only triggers when `source === "cookie"` AND `err.name === "TokenExpiredError"` -- Reads `req.cookies?.refreshToken` -- Calls `verifyRefreshTokenPayload()` — handles legacy tokens without `sid` -- Checks `redis.get(refreshTokenKey(userId, sid))` — token must match stored value -- Signs new access + refresh tokens, CAS-rotates refresh via Lua script -- Sets new cookies in-place — client sees no interruption -- Returns `null` on any failure → `"Session expired"` 401 - -**`optionalAuthenticate` — failure degradation:** -```js -try { - payload = jwt.verify(token, config.JWT_SECRET); -} catch { - return next(); // any verify error → guest, never 401 -} -if (!payload?.userId) return next(); -const user = await findUserById(payload.userId); -if (!user || INACTIVE_STATUSES.has(user.account_status)) return next(); // still guest -``` -Any failure (expired, invalid, user deleted, user suspended) → request continues as guest with no `req.user`. - -**`isBearerTransport` in `auth.controller.js`:** -```js -const isBearerTransport = (req) => req.headers["x-client-transport"] === "bearer"; -``` -Only the exact header value `"bearer"` (lowercase) triggers bearer response mode. - -**`setAuthCookies` cookie options:** -```js -const ACCESS_COOKIE_OPTIONS = { - httpOnly: true, - secure: config.NODE_ENV === "production", - sameSite: "strict", - maxAge: parseTtlSeconds(config.JWT_EXPIRES_IN, 15 * 60) * 1000, -}; -``` - -### What the docs currently say - -`docs/api/auth.md` Transport Summary: -```markdown -- Browser clients should use cookie mode. -- Mobile and API clients should send `X-Client-Transport: bearer` to receive tokens in the JSON body. -- Silent refresh only happens when the expired access token came from a cookie. -``` - -This is correct but **incomplete** — it doesn't document the response body difference, error message difference between bearer-expired vs cookie-expired, or that cookie takes priority over bearer header when both are present. - -### Exact fix for `docs/api/auth.md` Transport Summary section - -Replace "Transport Summary" with: - -```markdown -## Auth Transport Reference - -### Token extraction priority - -`authenticate` and `optionalAuthenticate` extract the access token in this exact order: - -1. `req.cookies.accessToken` (HttpOnly cookie) — **takes priority** -2. `Authorization: Bearer ` header — used only if no cookie present - -If both are present, **the cookie always wins**. This prevents a stale bearer token from overriding a valid cookie session. - -### Transport mode: cookie (default, browser) - -- No special request header needed -- Auth endpoints set `accessToken` (TTL: `JWT_EXPIRES_IN`, default `15m`) and `refreshToken` (TTL: `JWT_REFRESH_EXPIRES_IN`, default `7d`) as `HttpOnly; SameSite=Strict` cookies -- Response body for auth endpoints contains `{ user, sid }` only — **no raw token strings** -- `secure: true` in production, `secure: false` in development - -### Transport mode: bearer (mobile/Android) - -- Set request header: `X-Client-Transport: bearer` (case-sensitive, lowercase `"bearer"`) -- Response body for auth endpoints includes `{ accessToken, refreshToken, user, sid }` -- For protected endpoints, send: `Authorization: Bearer ` - -### Silent refresh (cookie mode only) - -Triggers automatically inside `authenticate` middleware when: -- `err.name === "TokenExpiredError"` **AND** -- Token came from `req.cookies.accessToken` (not from `Authorization` header) - -Silent refresh process: -1. Reads `req.cookies.refreshToken` -2. Calls `verifyRefreshTokenPayload()` — handles legacy tokens missing `sid` via migration path -3. Checks Redis: `GET refreshToken:{userId}:{sid}` — must match stored value exactly -4. Loads fresh user state from DB (roles, email, account_status) -5. Signs new access token + new refresh token -6. CAS-rotates refresh token via Lua script (atomic compare-and-swap prevents concurrent rotation races) -7. Sets new cookies on the response — client sees no interruption - -Silent refresh **never** triggers for an expired `Authorization: Bearer` header token. - -### Error response matrix by scenario - -| Scenario | Middleware | Response | -| --- | --- | --- | -| No token present | `authenticate` | `401 { "message": "No token provided" }` | -| Bearer token expired | `authenticate` | `401 { "message": "Token has expired" }` | -| Cookie token expired + refresh succeeds | `authenticate` | Request continues transparently | -| Cookie token expired + refresh fails | `authenticate` | `401 { "message": "Session expired" }` | -| Token invalid (malformed/wrong secret) | `authenticate` | `401 { "message": "Invalid token" }` | -| User not found in DB | `authenticate` | `401 { "message": "User not found" }` | -| User suspended/banned/deactivated | `authenticate` | `401 { "message": "Account is suspended" }` | -| Any token error | `optionalAuthenticate` | Request continues as guest (`req.user` is undefined) | -| Invalid/expired token | `optionalAuthenticate` | Request continues as guest — **never returns 401** | -| Suspended user token | `optionalAuthenticate` | Request continues as guest | -``` - ---- - -## Finding 4 — Listing Route Chain and Photo Upload Path Inaccurate - -### What the finding says - -The exact middleware chain for listing read endpoints has validate before guestListingGate (not gate before validate). Photo upload flow should explicitly show the queue/worker chain and 503 queue-failure path. - -### Verification against source - -**`src/routes/listing.js`** — search route: -```js -listingRouter.get( - "/", - optionalAuthenticate, - validate(searchListingsSchema), // ← validate BEFORE gate - guestListingGate, // ← gate AFTER validate - listingController.searchListings, -); -``` - -**`src/routes/listing.js`** — single listing route: -```js -listingRouter.get( - "/:listingId", - optionalAuthenticate, - validate(listingParamsSchema), // ← validate BEFORE gate - guestListingGate, - listingController.getListing, -); -``` - -**Why this order matters**: `validate` runs Zod and writes coerced values back to `req.query`. `guestListingGate` reads `req.query.limit` to apply the guest cap. If gate ran before validate, it would read a raw string from the query rather than the Zod-coerced number. The source comment in `listing.js` explicitly explains this: -```js -// validate runs before guestListingGate so that the limit field is already -// coerced to a number (by Zod) when guestListingGate reads it. -``` - -**`guestListingGate` actual behavior** (`src/middleware/guestListingGate.js`): -- If `req.user` is set → passes through with no modification -- If guest → silently caps `req.query.limit` to `GUEST_MAX_LISTINGS_PER_REQUEST = 20` **only if requested limit exceeds 20** -- Does NOT block or count guest browses, does NOT touch Redis -- Sets `req.query = { ...req.query, limit: 20 }` via object spread (req.query was already made writable by `validate`) - -**Photo upload flow** (`src/routes/listing.js` + `src/middleware/upload.js` + `src/services/photo.service.js`): - -Full chain for `POST /listings/:listingId/photos`: -``` -authenticate -→ upload.single("photo") [Multer: MIME check, extension cross-check, 10MB limit, writes to uploads/staging/] -→ validate(uploadPhotoSchema) [Zod: listingId UUID param] -→ photoController.uploadPhoto - → photoService.enqueuePhotoUpload(posterId, listingId, req.file.path) - → pool.connect() → BEGIN - → SELECT listing_id FROM listings WHERE ... FOR UPDATE [listing lock] - → SELECT COALESCE(MAX(display_order),-1)+1 FROM listing_photos [server-side order] - → INSERT INTO listing_photos (photo_id, ..., photo_url='processing:{photoId}') - → COMMIT - → getQueue("media-processing").add("process-photo", { listingId, photoId, stagingPath }) - → [If queue.add() throws] → UPDATE listing_photos SET deleted_at=NOW() [cleanup] - → throw AppError 503 "Photo processing queue is temporarily unavailable" - → return { photoId, status: "processing" } - → res.status(202).json(...) - -[Async — after HTTP response sent] -mediaProcessor worker (concurrency: 1): - → sharp(stagingPath).resize(1200,1200).webp({quality:80}).withMetadata(false).toBuffer() - → storageService.upload(buffer, listingId, filename) [AzureBlobAdapter: 30s AbortController timeout] - → UPDATE listing_photos SET photo_url = finalUrl WHERE photo_id = ... - → UPDATE listing_photos SET is_cover = TRUE WHERE ... AND NOT EXISTS (SELECT 1 ... WHERE is_cover=TRUE) - → fs.unlink(stagingPath) -``` - -**503 queue-failure path** is real and documented in source but absent from docs: -```js -} catch (queueErr) { - // soft-delete the provisional row - await pool.query(`UPDATE listing_photos SET deleted_at = NOW() WHERE photo_id = $1 ...`, [photoId, listingId]); - throw new AppError("Photo processing queue is temporarily unavailable. Please retry.", 503); -} -``` - -### Exact fix for `docs/api/listings.api.md` - -**1. Fix the middleware chain description** (add to the "Auth Policy for Read Routes" section): - -```markdown -### Exact middleware chain for read endpoints - -**`GET /listings`**: -``` -optionalAuthenticate → validate(searchListingsSchema) → guestListingGate → listingController.searchListings -``` -Note: `validate` runs before `guestListingGate` intentionally — Zod coerces `limit` to a number before the gate reads it. - -**`GET /listings/:listingId`**: -``` -optionalAuthenticate → validate(listingParamsSchema) → guestListingGate → listingController.getListing -``` - -**`guestListingGate` behavior**: -- Authenticated users (`req.user` set): passes through with no changes -- Guests: silently caps `req.query.limit` to `20` if requested value exceeds `20` -- Does not block browsing, does not touch Redis, does not count requests -``` - -**2. Fix the photo upload scenario to include the full chain:** - -```markdown -### `POST /listings/:listingId/photos` - -**Middleware chain:** -``` -authenticate → upload.single("photo") → validate(uploadPhotoSchema) → photoController.uploadPhoto -``` - -**Upload middleware (`src/middleware/upload.js`)**: -- Field name must be `photo` (LIMIT_UNEXPECTED_FILE → 400 if wrong) -- Accepted MIME types: `image/jpeg`, `image/png`, `image/webp` -- Extension cross-checked against MIME type (e.g. `.txt` claiming `image/jpeg` → 400) -- Max file size: `10MB` (LIMIT_FILE_SIZE → 413) -- Writes staged file to `uploads/staging/{uuid}.ext` - -**Service layer (`src/services/photo.service.js` → `enqueuePhotoUpload`)**: -1. Acquires row-level lock: `SELECT ... FROM listings WHERE ... FOR UPDATE` -2. Allocates `display_order` server-side: `SELECT COALESCE(MAX(display_order), -1) + 1` -3. Inserts provisional row with `photo_url = 'processing:{photoId}'` -4. Commits transaction -5. Calls `getQueue("media-processing").add("process-photo", { ... })` - -**Queue failure path** (Redis unavailable after DB commit): -- Soft-deletes the provisional photo row -- Returns `503 { "message": "Photo processing queue is temporarily unavailable. Please retry." }` - -**Async processing** (`src/workers/mediaProcessor.js`, concurrency: 1): -- Sharp: resize to max 1200×1200, WebP quality 80, strip EXIF metadata -- Upload to Azure Blob (30s AbortController timeout — `504` if exceeded) -- `UPDATE listing_photos SET photo_url = finalUrl` -- Cover election: `UPDATE ... SET is_cover = TRUE WHERE NOT EXISTS (SELECT 1 ... WHERE is_cover = TRUE)` -- Deletes staging file - -**Scenario: upload accepted and queued** - -Status: `202` -```json -{ - "status": "success", - "data": { - "photoId": "6fd7cf0b-d6a7-4f31-bd0a-fdbcf5a5ef2e", - "status": "processing" - } -} -``` - -**Scenario: queue unavailable** - -Status: `503` -```json -{ - "status": "error", - "message": "Photo processing queue is temporarily unavailable. Please retry." -} -``` -``` - ---- - -## Finding 5 — Zod-Derived Request Contracts Incomplete - -### What the finding says - -Docs miss important validator constraints actually enforced by Zod. Specific examples: -- `isRead` in notification feed accepts `"true"/"false"` strings and numeric `0`/`1` -- Auth refresh/logout body token is optional due to cookie mode -- Cursor pair rules not fully specified across all keyset endpoints - -### Verification against source - -**`src/validators/notification.validators.js` — `isRead` preprocessing:** -```js -isRead: z.preprocess((val) => { - if (typeof val === "string") { - if (val.toLowerCase() === "true") return true; - if (val.toLowerCase() === "false") return false; - return val; // anything else passes through → z.boolean() rejects it - } - if (typeof val === "number") { - if (val === 0) return false; - if (val === 1) return true; - return val; // 2, -1, etc. pass through → z.boolean() rejects - } - return val; -}, z.boolean()).optional() -``` -Accepts: `"true"`, `"false"`, `"TRUE"`, `"FALSE"`, `0`, `1`. Rejects: `"yes"`, `2`, `"1"` (string). - -**`src/validators/auth.validators.js` — optional refresh token body:** -```js -const optionalRefreshTokenBody = z - .object({ - refreshToken: z.string().min(1).optional(), - }) - .optional() - .default({}); - -export const refreshSchema = z.object({ body: optionalRefreshTokenBody }); -export const logoutCurrentSchema = z.object({ body: optionalRefreshTokenBody }); -``` -Entire body is optional (defaults to `{}`). `refreshToken` within it is also optional. Controller resolves via `req.body?.refreshToken ?? req.cookies?.refreshToken`. - -**`src/validators/pagination.validators.js` — cursor pair rule:** -```js -.refine( - (data) => { - const hasTime = data.cursorTime !== undefined; - const hasId = data.cursorId !== undefined; - return hasTime === hasId; - }, - { error: "cursorTime and cursorId must be provided together", path: ["cursorTime"] } -) -``` -This refine is in `buildKeysetPaginationQuerySchema()` which is used by: `searchListingsSchema`, `getListingInterestsSchema`, `getMyInterestsSchema`, `getFeedSchema` (notifications), `getPublicRatingsSchema`, `getMyGivenRatingsSchema`, `getPublicPropertyRatingsSchema`. But `listPropertiesSchema` uses its own cursor validation (not via `buildKeysetPaginationQuerySchema`), with the same refine applied locally. - -### Exact fixes - -**Fix `docs/api/notifications.md` — add to `GET /notifications` request contract:** - -```markdown -### Query parameter details - -**`isRead`** (optional boolean filter): - -Accepts the following values: -- String: `"true"` or `"false"` (case-insensitive: `"TRUE"`, `"False"` also work) -- Numeric: `0` (false) or `1` (true) -- Omit entirely to receive all notifications (read + unread) - -Values like `"yes"`, `"1"` (as string), `2` are rejected with `400 Validation failed`. -``` - -**Fix `docs/api/auth.md` — `POST /auth/refresh` and `POST /auth/logout` request contracts:** - -```markdown -### Request body - -The request body is **fully optional** for browser clients using cookie transport. - -- **Cookie mode (browser):** Send no body. The controller reads `req.cookies.refreshToken` automatically. -- **Bearer mode (Android/API):** Send `{ "refreshToken": "..." }` in the body. - -The validator accepts an empty body (`{}`), an absent body, or a body with `refreshToken`. Missing `refreshToken` in body is not an error if the cookie is present — the controller resolves: `req.body?.refreshToken ?? req.cookies?.refreshToken`. -``` - -**Fix all paginated endpoint docs — add explicit cursor pair rule note:** - -Add to the shared `docs/api/conventions.md` pagination section: - -```markdown -### Cursor pairing enforcement - -All keyset-paginated endpoints enforce that `cursorTime` and `cursorId` must be supplied together or both omitted. This is validated by Zod before the request reaches the controller. - -Sending only one cursor field returns `400`: - -```json -{ - "status": "error", - "message": "Validation failed", - "errors": [{ "field": "query.cursorTime", "message": "cursorTime and cursorId must be provided together" }] -} -``` - -This rule applies to: `GET /listings`, `GET /listings/:listingId/interests`, `GET /interests/me`, `GET /notifications`, `GET /ratings/user/:userId`, `GET /ratings/property/:propertyId`, `GET /ratings/me/given`, `GET /ratings/connection/:connectionId`, `GET /connections/me`, `GET /properties`, `GET /listings/me/saved`. -``` - ---- - -## Finding 6 — Missing Appendix Sections - -### What the finding says - -Two cross-reference appendices are absent: -1. Middleware reference: maps each middleware → every route that uses it -2. Workers/crons reference: maps each worker/cron → affected endpoints and behavior - -### Verification against source - -Reviewing all route files (`src/routes/*.js`) and cross-referencing with middleware files (`src/middleware/*.js`) and worker/cron files. - -### Fix: Create `docs/api/appendix-middleware.md` - -```markdown -# Appendix: Middleware Reference - -Maps each middleware to every route that uses it, with its role in the chain. - ---- - -## `authenticate` (`src/middleware/authenticate.js`) - -Requires a valid access token. Sets `req.user`. Attempts silent refresh for expired cookie tokens only. - -| Route | Method | -| --- | --- | -| `POST /auth/logout/current` | POST | -| `POST /auth/logout/all` | POST | -| `GET /auth/sessions` | GET | -| `DELETE /auth/sessions/:sid` | DELETE | -| `POST /auth/otp/send` | POST | -| `POST /auth/otp/verify` | POST | -| `GET /auth/me` | GET | -| `POST /listings` | POST | -| `PUT /listings/:listingId` | PUT | -| `DELETE /listings/:listingId` | DELETE | -| `PATCH /listings/:listingId/status` | PATCH | -| `GET /listings/:listingId/preferences` | GET | -| `PUT /listings/:listingId/preferences` | PUT | -| `POST /listings/:listingId/save` | POST | -| `DELETE /listings/:listingId/save` | DELETE | -| `GET /listings/me/saved` | GET | -| `GET /listings/:listingId/photos` | GET | -| `POST /listings/:listingId/photos` | POST | -| `DELETE /listings/:listingId/photos/:photoId` | DELETE | -| `PATCH /listings/:listingId/photos/:photoId/cover` | PATCH | -| `PUT /listings/:listingId/photos/reorder` | PUT | -| `POST /listings/:listingId/interests` | POST | -| `GET /listings/:listingId/interests` | GET | -| `GET /interests/me` | GET | -| `GET /interests/:interestId` | GET | -| `PATCH /interests/:interestId/status` | PATCH | -| `GET /connections/me` | GET | -| `GET /connections/:connectionId` | GET | -| `POST /connections/:connectionId/confirm` | POST | -| `GET /notifications` | GET | -| `GET /notifications/unread-count` | GET | -| `POST /notifications/mark-read` | POST | -| `GET /students/:userId/profile` | GET | -| `PUT /students/:userId/profile` | PUT | -| `GET /students/:userId/preferences` | GET | -| `PUT /students/:userId/preferences` | PUT | -| `GET /pg-owners/:userId/profile` | GET | -| `PUT /pg-owners/:userId/profile` | PUT | -| `POST /pg-owners/:userId/documents` | POST | -| `GET /properties` | GET | -| `GET /properties/:propertyId` | GET | -| `POST /properties` | POST | -| `PUT /properties/:propertyId` | PUT | -| `DELETE /properties/:propertyId` | DELETE | -| `GET /ratings/me/given` | GET | -| `GET /ratings/connection/:connectionId` | GET | -| `POST /ratings` | POST | -| `POST /ratings/:ratingId/report` | POST | -| `GET /preferences/meta` | GET | - ---- - -## `optionalAuthenticate` (`src/middleware/optionalAuthenticate.js`) - -Tries to set `req.user` if a valid token is present. Never returns 401. Any failure (expired, invalid, user deleted/suspended) → request continues as guest. - -Token extraction order: cookie → Authorization header (same as `authenticate`). - -| Route | Method | -| --- | --- | -| `GET /listings` | GET | -| `GET /listings/:listingId` | GET | -| `GET /students/:userId/contact/reveal` | GET | -| `POST /pg-owners/:userId/contact/reveal` | POST | - ---- - -## `authorize(role)` (`src/middleware/authorize.js`) - -Must run after `authenticate`. Checks `req.user.roles.includes(role)`. Returns `403 Forbidden` if role missing. - -**Note:** `authorize()` validates the role argument at route registration time (startup), not per-request. Misconfiguration throws at startup. - -| Route | Required Role | -| --- | --- | -| `GET /listings/me/saved` | `student` | -| `POST /listings/:listingId/save` | `student` | -| `DELETE /listings/:listingId/save` | `student` | -| `POST /listings/:listingId/interests` | `student` | -| `GET /interests/me` | `student` | -| `PUT /pg-owners/:userId/profile` | `pg_owner` | -| `POST /pg-owners/:userId/documents` | `pg_owner` | -| `GET /properties` | `pg_owner` | -| `POST /properties` | `pg_owner` | -| `PUT /properties/:propertyId` | `pg_owner` | -| `DELETE /properties/:propertyId` | `pg_owner` | - ---- - -## `contactRevealGate` (`src/middleware/contactRevealGate.js`) - -Must run after `optionalAuthenticate` and `validate`. Sets `req.contactReveal = { emailOnly, verified }`. - -**Behavior:** -- Verified users (`req.user?.isEmailVerified === true`): passes through, sets `emailOnly: false` → controller returns full bundle (email + phone) -- Guests/unverified: checks Redis counter `contactRevealAnon:{sha256(ip|ua)}`, then checks HttpOnly cookie `contactRevealAnonCount` -- If count ≥ 10: returns `429 CONTACT_REVEAL_LIMIT_REACHED` immediately -- Otherwise: installs a pre-response hook on `res.json`/`res.send`/`res.end` to increment quota **after** a successful 2xx response only -- Quota is incremented atomically via Lua script: `INCR key; if count==1 then EXPIRE key 30days` -- `Cache-Control: no-store` must be set on the response **before** this middleware runs (see route files) - -| Route | Method | -| --- | --- | -| `GET /students/:userId/contact/reveal` | GET | -| `POST /pg-owners/:userId/contact/reveal` | POST | - ---- - -## `guestListingGate` (`src/middleware/guestListingGate.js`) - -Must run after `validate` (relies on Zod-coerced `req.query.limit` being a number). - -**Behavior:** -- Authenticated (`req.user` set): no-op, passes through -- Guest: silently caps `req.query.limit` to `20` if the requested value exceeds `20` -- Does not block, does not count, does not touch Redis - -| Route | Method | -| --- | --- | -| `GET /listings` | GET | -| `GET /listings/:listingId` | GET | - ---- - -## `validate(schema)` (`src/middleware/validate.js`) - -Runs Zod's `schema.safeParse({ body, query, params })`. On success, writes `result.data` back to `req.body`, `req.params`, and `req.query` (using `Object.defineProperty` for query in Express 5 compatibility). On failure, passes `ZodError` to `next(err)` → global error handler. - -Used on virtually every route. Not individually listed here — see per-endpoint docs for the schema name. - ---- - -## `authLimiter` (`src/middleware/rateLimiter.js`) - -10 requests / 15-minute window. Redis-backed (`rl:auth:{ip}`). `passOnStoreError: true` — degrades to no limiting if Redis is down. - -| Route | Method | -| --- | --- | -| `POST /auth/register` | POST | -| `POST /auth/login` | POST | -| `POST /auth/logout/all` | POST | -| `POST /auth/refresh` | POST | -| `GET /auth/sessions` | GET | -| `DELETE /auth/sessions/:sid` | DELETE | -| `POST /auth/google/callback` | POST | - ---- - -## `otpLimiter` (`src/middleware/rateLimiter.js`) - -5 requests / 15-minute window. Redis-backed (`rl:otp:{ip}`). `passOnStoreError: true`. - -| Route | Method | -| --- | --- | -| `POST /auth/otp/send` | POST | - ---- - -## `publicRatingsLimiter` (`src/middleware/rateLimiter.js`) - -120 requests / 15-minute window. Redis-backed (`rl:ratings:public:{ip}`). `passOnStoreError: true`. - -| Route | Method | -| --- | --- | -| `GET /ratings/user/:userId` | GET | -| `GET /ratings/property/:propertyId` | GET | - ---- - -## `upload.single("photo")` (`src/middleware/upload.js`) - -Multer disk storage. Writes to `uploads/staging/{uuid}.ext`. Validates MIME type and file extension cross-match. Limits: 10MB file size, 1 file per request. - -| Route | Method | -| --- | --- | -| `POST /listings/:listingId/photos` | POST | -``` - -### Fix: Create `docs/api/appendix-workers-crons.md` - -```markdown -# Appendix: Workers and Cron Jobs Reference - ---- - -## BullMQ Workers - -### `media-processing` queue — `src/workers/mediaProcessor.js` - -**Concurrency:** 1 (CPU-bound Sharp processing) - -**Triggered by:** `POST /listings/:listingId/photos` (via `photo.service.enqueuePhotoUpload`) - -**Job payload:** `{ listingId, photoId, stagingPath, posterId }` - -**Processing steps:** -1. `sharp(stagingPath).resize(1200, 1200, { fit: "inside" }).webp({ quality: 80 }).withMetadata(false).toBuffer()` -2. `storageService.upload(buffer, listingId, filename)` — AzureBlobAdapter with 30s AbortController timeout -3. `UPDATE listing_photos SET photo_url = finalUrl WHERE photo_id = ?` -4. Cover election: `UPDATE ... SET is_cover = TRUE WHERE NOT EXISTS (SELECT 1 ... WHERE is_cover = TRUE)` -5. `fs.unlink(stagingPath)` - -**Failure handling:** -- If photo row was soft-deleted before processing completed: deletes the already-uploaded permanent file, skips DB update -- `storageService.upload` timeout (30s): throws `AppError 504`, job fails → BullMQ retries (max 3 attempts) -- Stage file cleanup failures: logged as warn, does not fail the job - -**Affects these endpoint-visible states:** -- `GET /listings/:listingId/photos` — photo becomes visible only after worker sets real URL (placeholder `processing:{photoId}` is filtered out) -- `GET /listings/:listingId` — cover photo in `photos` array - ---- - -### `notification-delivery` queue — `src/workers/notificationWorker.js` - -**Concurrency:** 10 (I/O-bound DB inserts) - -**Job options:** 5 attempts, exponential backoff (2s base) - -**Triggered by:** `enqueueNotification()` calls in: -- `interest.service.js` — `createInterestRequest`, `transitionInterestRequest`, `_acceptInterestRequest` -- `connection.service.js` — `confirmConnection` -- `rating.service.js` — `submitRating` -- `cron/listingExpiry.js` — listing expired -- `cron/expiryWarning.js` — listing expiring soon -- `verificationEventWorker.js` — verification approved/rejected - -**Processing:** `INSERT INTO notifications ... ON CONFLICT (idempotency_key) DO NOTHING` - -Idempotency key = BullMQ `job.id`. Retry-safe: duplicate inserts are silently skipped. - -**Affects:** `GET /notifications`, `GET /notifications/unread-count` - ---- - -### `email-delivery` queue — `src/workers/emailWorker.js` - -**Concurrency:** 3 (I/O-bound SMTP/REST) - -**Job options:** 3 attempts, exponential backoff (5s base) - -**Triggered by:** `enqueueEmail()` calls in: -- `auth.service.js` → `sendOtp()` — triggers on `POST /auth/otp/send` -- `verificationEventWorker.js` — all three verification event types - -**Handlers:** - -| Job type | Email function | Triggered by | -| --- | --- | --- | -| `otp` | `sendOtpEmail()` | `POST /auth/otp/send` | -| `verification_approved` | `sendVerificationApprovedEmail()` | verificationEventWorker | -| `verification_rejected` | `sendVerificationRejectedEmail()` | verificationEventWorker | -| `verification_pending` | `sendVerificationPendingEmail()` | verificationEventWorker | - -Unknown job type: logged at warn level, job marked complete without sending. - ---- - -### `verificationEventWorker` — `src/workers/verificationEventWorker.js` - -**Type:** Not BullMQ — `setInterval` polling loop - -**Poll interval:** 5 seconds - -**Batch size:** 10 events per cycle - -**Max attempts per event:** 5 - -**Triggered by:** Postgres trigger `trg_verification_status_changed` which fires on `AFTER UPDATE OF status ON verification_requests` - -**API routes that can trigger the trigger:** -- `POST /admin/verification-queue/:requestId/approve` → `verification.service.approveRequest()` -- `POST /admin/verification-queue/:requestId/reject` → `verification.service.rejectRequest()` -- `POST /pg-owners/:userId/documents` → `verification.service.submitDocument()` → sets status to `pending` - -**Also triggered by:** Any direct SQL UPDATE on `verification_requests.status` (migration scripts, admin psql sessions) - -**Actions per event:** -- `verification_approved`: correct `pg_owner_profiles.verification_status` if needed → `enqueueNotification` + `enqueueEmail` -- `verification_rejected`: correct profile status → `enqueueNotification` + `enqueueEmail` -- `verification_pending`: `enqueueEmail` only (no in-app notification) - -**Concurrency safety:** `SELECT ... FOR UPDATE SKIP LOCKED` — safe for multi-instance deployments - -**Shutdown behavior:** `clearInterval` only — does not drain in-flight events. Events retried on next startup. - ---- - -## Cron Jobs - -### `listingExpiry` — `src/cron/listingExpiry.js` - -**Schedule:** `0 2 * * *` (02:00 daily). Override: `CRON_LISTING_EXPIRY` env var. - -**What it does:** -```sql -UPDATE listings SET status = 'expired' -WHERE status = 'active' AND expires_at < NOW() AND deleted_at IS NULL -RETURNING listing_id, posted_by -``` -Then in the same transaction: -```sql -UPDATE interest_requests SET status = 'expired' -WHERE listing_id = ANY($expiredIds) AND status = 'pending' AND deleted_at IS NULL -``` -Post-commit: `enqueueNotification({ type: "listing_expired" })` for each expired listing's poster. - -**API impact:** -- Expired listings no longer appear in `GET /listings` search results -- `POST /listings/:listingId/save` and `POST /listings/:listingId/interests` return 422 for expired listings -- Pending interest requests on expired listings become `expired` status - ---- - -### `expiryWarning` — `src/cron/expiryWarning.js` - -**Schedule:** `0 1 * * *` (01:00 daily). Override: `CRON_EXPIRY_WARNING` env var. - -**What it does:** Finds active listings expiring within 7 days that have NOT already received a warning today. Inserts notification rows directly (not via BullMQ) inside a transaction with idempotency key `expiry_warning:{listing_id}:{YYYY-MM-DD}`. Uses advisory lock `pg_try_advisory_xact_lock(7001)` to prevent concurrent runs. - -Post-commit: `enqueueNotification({ type: "listing_expiring" })` for each newly inserted warning. - -**Idempotent:** Re-running on the same day produces no duplicates (ON CONFLICT DO NOTHING on idempotency_key). - -**API impact:** Adds `listing_expiring` notification to poster's feed. - ---- - -### `hardDeleteCleanup` — `src/cron/hardDeleteCleanup.js` - -**Schedule:** `0 4 * * 0` (04:00 Sundays). Override: `CRON_HARD_DELETE` env var. - -**Retention period:** `SOFT_DELETE_RETENTION_DAYS` env var (default `90`). Must be a plain decimal integer (e.g. `"90"`). Values like `"90days"` or `"-30"` fall back to 90 with a warn log. - -**What it does:** Hard-deletes rows with `deleted_at < NOW() - N days` across all soft-delete tables, in dependency order to avoid FK violations: - -1. `rating_reports` → 2. `ratings` → 3. `notifications` → 4. `connections` (guarded: skips if not-yet-aged rating references it) → 5. `interest_requests` → 6. `saved_listings` → 7. `listing_photos` → 8. `listings` (guarded: skips if child rows not yet aged) → 9. `verification_requests` → 10. `pg_owner_profiles` → 11. `student_profiles` → 12. `properties` (guarded) → 13. `institutions` → 14. `users` (guarded: skips if any not-yet-aged child references it) - -All in one transaction. Rollback on any error. - -**API impact:** Reduces DB size. Rows aged past retention are permanently unrecoverable. No direct endpoint impact. -``` - ---- - -## Summary of All Changes Required - -| Finding | File(s) to change | Type of change | -| --- | --- | --- | -| 1 | `docs/api/notifications.md` | Update notification type table, add CDC pipeline section | -| 1 | `src/workers/notificationWorker.js` | Fix PLANNED→ACTIVE comments for verification types | -| 2 | `docs/api/admin.md` | Replace CDC side-effects note with full worker behavior table | -| 3 | `docs/api/auth.md` | Replace Transport Summary with full branch-semantics reference | -| 4 | `docs/api/listings.api.md` | Fix middleware chain order, add full photo upload chain + 503 path | -| 5 | `docs/api/notifications.md` | Add `isRead` accepted value details | -| 5 | `docs/api/auth.md` | Add optional body note for refresh/logout | -| 5 | `docs/api/conventions.md` | Add cursor pair rule with full endpoint list | -| 6 | `docs/api/appendix-middleware.md` | **Create new file** — full middleware → route mapping | -| 6 | `docs/api/appendix-workers-crons.md` | **Create new file** — full worker/cron → endpoint mapping | -| 6 | `docs/README.md` | Add links to new appendix files | From 8653f5055a78d33a4d1010ef7e8369ab127cb608 Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Wed, 22 Apr 2026 00:30:48 +0530 Subject: [PATCH 14/54] feat: enhance CORS and cookie handling for cross-origin requests --- src/app.js | 56 +++++++++++++++------ src/controllers/auth.controller.js | 72 +++++++++------------------ src/middleware/authenticate.js | 78 +++++++++++++++++------------- 3 files changed, 111 insertions(+), 95 deletions(-) diff --git a/src/app.js b/src/app.js index cd78e84..4c0c14a 100644 --- a/src/app.js +++ b/src/app.js @@ -12,37 +12,65 @@ 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()); +// Relax helmet's default restrictions for cross-origin API access. +// The frontend needs to read response bodies from a different origin. +app.use( + helmet({ + crossOriginResourcePolicy: { policy: "cross-origin" }, + }), +); // ─── 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. +// Cross-origin cookie rules (for reference): +// sameSite: "none" + secure: true → browser sends cookies cross-site +// sameSite: "strict" → browser NEVER sends cookies cross-site +// sameSite: "lax" → only sent on top-level GET navigations +// +// Since our frontend is on a different domain from the backend, we need: +// 1. credentials: true → sends Access-Control-Allow-Credentials +// 2. An explicit origin (not "*") → required when credentials: true +// 3. Cookies set with sameSite: "none" + secure (handled in authenticate.js) +// +// In development: origin: true reflects the incoming Origin header — works for +// any localhost port. +// In production: explicit allowlist from ALLOWED_ORIGINS. + 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) => { + // Allow requests with no origin (Postman, curl, server-to-server) + if (!origin) return callback(null, true); + + if (config.NODE_ENV === "development") { + // In development, allow any origin (localhost on any port) + return callback(null, true); + } + + // In production, check against the explicit allowlist + if (config.ALLOWED_ORIGINS.includes(origin)) { + return callback(null, true); + } + + callback(new Error(`CORS: origin '${origin}' is not allowed`)); + }, + // credentials: true is REQUIRED for cross-origin cookies AND for the + // browser to expose response headers/body on credentialed requests. credentials: true, + // Expose headers the frontend may need to read + exposedHeaders: ["X-Request-Id"], }), ); diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js index f8a0a07..2e37d38 100644 --- a/src/controllers/auth.controller.js +++ b/src/controllers/auth.controller.js @@ -4,20 +4,18 @@ 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"; +import { ACCESS_COOKIE_OPTIONS, REFRESH_COOKIE_OPTIONS } from "../middleware/authenticate.js"; -const ACCESS_COOKIE_OPTIONS = { - httpOnly: true, - secure: config.NODE_ENV === "production", - sameSite: "strict", - maxAge: parseTtlSeconds(config.JWT_EXPIRES_IN, 15 * 60) * 1000, -}; +// Determines whether the caller is a non-browser client (e.g. Android) or a +// cross-origin SPA that uses the bearer transport instead of cookies. +// The frontend sends X-Client-Transport: bearer in production. +const isBearerTransport = (req) => req.headers["x-client-transport"] === "bearer"; -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, -}; +// Builds the safe body payload for cookie-mode responses. +const buildSafeBody = (tokens) => ({ + user: tokens.user, + sid: tokens.sid, +}); const setAuthCookies = (res, accessToken, refreshToken) => { res.cookie("accessToken", accessToken, ACCESS_COOKIE_OPTIONS); @@ -25,42 +23,19 @@ const setAuthCookies = (res, accessToken, refreshToken) => { }; const clearAuthCookies = (res) => { + // Must use the same sameSite/secure settings to clear correctly 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 ────────────────────────────────────────────────────────────── export const register = async (req, res, next) => { @@ -68,8 +43,10 @@ export const register = async (req, res, next) => { 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 }); + // Always return full token data — the frontend needs it in production + // where cookies can't cross domains. In cookie-mode the frontend ignores + // the tokens in the body and relies on cookies. + res.status(201).json({ status: "success", data: tokens }); } catch (err) { next(err); } @@ -80,8 +57,8 @@ export const login = async (req, res, next) => { 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 }); + // Return full tokens unconditionally. Same reasoning as register above. + res.json({ status: "success", data: tokens }); } catch (err) { next(err); } @@ -127,8 +104,8 @@ export const refresh = async (req, res, next) => { setAuthCookies(res, tokens.accessToken, tokens.refreshToken); - const data = isBearerTransport(req) ? tokens : buildSafeBody(tokens); - res.json({ status: "success", data }); + // Always return full tokens (bearer transport + cookie both work) + res.json({ status: "success", data: tokens }); } catch (err) { next(err); } @@ -194,8 +171,7 @@ export const googleCallback = async (req, res, next) => { 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: tokens }); } catch (err) { next(err); } diff --git a/src/middleware/authenticate.js b/src/middleware/authenticate.js index d9a126c..278ab4b 100644 --- a/src/middleware/authenticate.js +++ b/src/middleware/authenticate.js @@ -12,58 +12,73 @@ 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 = { +// ─── Cross-origin cookie policy ─────────────────────────────────────────────── +// +// In production the frontend (Vercel) and backend (Render) are on different +// domains. Browsers apply the following rules: +// +// sameSite: "strict" → cookie is NEVER sent cross-site, even with +// credentials: "include". Auth breaks completely. +// sameSite: "lax" → cookie is sent on top-level navigations (GET) but +// NOT on cross-origin fetch with credentials. Still +// broken for API calls. +// sameSite: "none" → sent on all cross-site requests, but REQUIRES +// secure: true (HTTPS only). +// +// In practice, for a cross-domain SPA + API architecture the correct approach +// is to use sameSite: "none" + secure: true in production so the browser +// actually sends the cookie on every credentialed fetch. +// +// We also support the X-Client-Transport: bearer header (sent by the frontend +// in production). When present, the auth controller returns tokens in the JSON +// body AND sets cookies. The frontend stores tokens in memory/sessionStorage +// and sends them as Authorization: Bearer headers — completely bypassing the +// cross-domain cookie problem. +// +// Cookie options for each environment: +// development → sameSite: "lax", secure: false (localhost HTTP) +// production → sameSite: "none", secure: true (cross-domain HTTPS) + +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) { - return { token: cookieToken, source: "cookie" }; - } - + // Authorization: Bearer takes priority over cookies for + // cross-origin clients that use the bearer transport. const authHeader = req.headers.authorization; if (authHeader && authHeader.startsWith("Bearer ")) { return { token: authHeader.slice(7), source: "header" }; } + const cookieToken = req.cookies?.accessToken; + if (cookieToken) { + return { token: cookieToken, source: "cookie" }; + } + 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 +88,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 +136,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); @@ -140,6 +150,8 @@ export const authenticate = async (req, res, next) => { try { payload = jwt.verify(token, config.JWT_SECRET); } catch (err) { + // Only attempt silent cookie-based refresh for cookie-sourced tokens. + // Header-sourced tokens (bearer transport) handle refresh client-side. if (err.name === "TokenExpiredError" && source === "cookie") { const session = await attemptSilentRefresh(req, res); if (!session?.userId) { From c33d641553fb39fb13cda24f0b249c134ee8aec4 Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Wed, 22 Apr 2026 19:46:12 +0530 Subject: [PATCH 15/54] feat: add amenities endpoint and integrate with routing --- src/routes/amenities.js | 29 +++++++++++++++++ src/routes/index.js | 7 ++-- src/services/interest.service.js | 45 +++++++++++--------------- src/services/listing.service.js | 55 ++++++++++++++++---------------- 4 files changed, 78 insertions(+), 58 deletions(-) create mode 100644 src/routes/amenities.js diff --git a/src/routes/amenities.js b/src/routes/amenities.js new file mode 100644 index 0000000..a1bb1a3 --- /dev/null +++ b/src/routes/amenities.js @@ -0,0 +1,29 @@ +// src/routes/amenities.js +// Public read-only endpoint to list all amenities. +// Used by the frontend AmenityPicker component when creating/editing properties and listings. +// No auth required — amenity catalog is not sensitive. + +import { Router } from "express"; +import { pool } from "../db/client.js"; + +export const amenitiesRouter = Router(); + +// GET /api/v1/amenities +// Returns all amenities grouped by category, ordered by category then name. +// Response: { status: "success", data: { items: Amenity[] } } +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/index.js b/src/routes/index.js index ab82fa2..7fd4f34 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -12,12 +12,10 @@ 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 { testUtilsRouter } from "./testUtils.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 +29,10 @@ rootRouter.use("/connections", connectionRouter); rootRouter.use("/notifications", notificationRouter); rootRouter.use("/ratings", ratingRouter); rootRouter.use("/preferences", preferencesRouter); +rootRouter.use("/amenities", amenitiesRouter); + 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/services/interest.service.js b/src/services/interest.service.js index 212d064..7410098 100644 --- a/src/services/interest.service.js +++ b/src/services/interest.service.js @@ -1,4 +1,11 @@ // src/services/interest.service.js +// FIX: ON CONFLICT clause must EXACTLY match the partial unique index definition. +// The index idx_interest_requests_no_duplicates is: +// ON interest_requests (sender_id, listing_id) +// WHERE status IN ('pending','accepted') AND deleted_at IS NULL +// +// The ON CONFLICT clause must also include AND deleted_at IS NULL to match exactly. +// Without it, PostgreSQL error 42P10: "there is no unique or exclusion constraint matching" import { pool } from "../db/client.js"; import { logger } from "../logger/index.js"; @@ -18,9 +25,6 @@ const LISTING_TYPE_TO_CONNECTION_TYPE = { }; // ─── Create interest request ───────────────────────────────────────────────── -// A student expresses interest in a listing. Only one active (pending or -// accepted) interest request per student per listing is allowed; the partial -// unique index idx_interest_requests_no_duplicates enforces this at DB level. export const createInterestRequest = async (studentId, listingId, data) => { const { message } = data; @@ -57,19 +61,19 @@ export const createInterestRequest = async (studentId, listingId, data) => { throw new AppError("You cannot express interest in your own listing", 422); } - // ON CONFLICT predicate must exactly match the partial unique index: + // FIX: The ON CONFLICT WHERE clause must EXACTLY match the partial index: // idx_interest_requests_no_duplicates ON (sender_id, listing_id) // WHERE status IN ('pending','accepted') AND deleted_at IS NULL - // The original clause incorrectly included "AND deleted_at IS NULL" inside - // the ON CONFLICT WHERE, which caused PostgreSQL to skip conflict detection - // for soft-deleted rows and allowed duplicate active requests to be inserted. - // The fix removes that extra predicate so the clause matches the index exactly. + // + // Both the status filter AND deleted_at IS NULL must be present. + // Adding the ::request_status_enum cast also ensures proper type matching. const { rows } = await pool.query( `INSERT INTO interest_requests (sender_id, listing_id, message, status) - VALUES ($1, $2, $3, 'pending') + VALUES ($1, $2, $3, 'pending'::request_status_enum) ON CONFLICT (sender_id, listing_id) WHERE status IN ('pending', 'accepted') + AND deleted_at IS NULL DO NOTHING RETURNING request_id, sender_id, listing_id, message, status, created_at`, [studentId, listingId, message ?? null], @@ -101,8 +105,6 @@ export const createInterestRequest = async (studentId, listingId, data) => { }; // ─── Transition interest request status ────────────────────────────────────── -// Routes accepted → _acceptInterestRequest (atomic multi-step). -// Routes declined/withdrawn → simple UPDATE with actor ownership check. export const transitionInterestRequest = async (callerId, requestId, targetStatus) => { const ALLOWED_STATUSES = new Set(["accepted", "declined", "withdrawn"]); if (!ALLOWED_STATUSES.has(targetStatus)) { @@ -115,6 +117,7 @@ export const transitionInterestRequest = async (callerId, requestId, targetStatu const ownershipClause = targetStatus === "declined" ? `AND l.posted_by = $2` : `AND ir.sender_id = $2`; + // FIX: Add ::request_status_enum cast to avoid type mismatch const { rowCount, rows } = await pool.query( `UPDATE interest_requests ir SET status = $3::request_status_enum, @@ -122,7 +125,7 @@ export const transitionInterestRequest = async (callerId, requestId, targetStatu FROM listings l WHERE ir.request_id = $1 AND ir.listing_id = l.listing_id - AND ir.status = 'pending' + AND ir.status = 'pending'::request_status_enum AND ir.deleted_at IS NULL ${ownershipClause} RETURNING @@ -188,19 +191,12 @@ export const transitionInterestRequest = async (callerId, requestId, targetStatu }; // ─── Accept interest request (internal) ────────────────────────────────────── -// Atomically: accepts the request, creates the connection row, increments -// current_occupants, and (if capacity exhausted) marks the listing 'filled' -// and expires all remaining pending requests. All within one BEGIN/COMMIT. -// Post-commit notifications are fire-and-forget via enqueueNotification. const _acceptInterestRequest = async (posterId, requestId) => { const client = await pool.connect(); try { await client.query("BEGIN"); - // Lock both the interest request and its parent listing simultaneously. - // FOR UPDATE on the JOIN prevents concurrent accepts or listing status - // changes for the duration of this transaction. const { rows: irRows } = await client.query( `SELECT ir.request_id, @@ -299,7 +295,7 @@ const _acceptInterestRequest = async (posterId, requestId) => { status = 'filled'::listing_status_enum, filled_at = NOW() WHERE listing_id = $2 - AND status = 'active' + AND status = 'active'::listing_status_enum AND deleted_at IS NULL`, [newOccupantCount, ir.listing_id], ); @@ -315,7 +311,7 @@ const _acceptInterestRequest = async (posterId, requestId) => { `UPDATE listings SET current_occupants = $1 WHERE listing_id = $2 - AND status = 'active' + AND status = 'active'::listing_status_enum AND deleted_at IS NULL`, [newOccupantCount, ir.listing_id], ); @@ -387,7 +383,6 @@ const _acceptInterestRequest = async (posterId, requestId) => { }; // ─── Get single interest request ───────────────────────────────────────────── -// Both the sender and the listing poster can fetch detail. Third parties get 404. export const getInterestRequest = async (callerId, requestId) => { const { rows } = await pool.query( `SELECT @@ -449,7 +444,6 @@ export const getInterestRequest = async (callerId, requestId) => { }; // ─── Get interest requests for a listing (poster's view) ───────────────────── -// Only the listing poster can call this. Returns keyset-paginated requests. export const getInterestRequestsForListing = async (posterId, listingId, filters) => { const { rows: listingRows } = await pool.query( `SELECT listing_id FROM listings @@ -543,7 +537,6 @@ export const getInterestRequestsForListing = async (posterId, listingId, filters }; // ─── Get my interest requests (student's view) ─────────────────────────────── -// Returns all requests the authenticated student has sent, keyset-paginated. export const getMyInterestRequests = async (studentId, filters) => { const { status, cursorTime, cursorId, limit = 20 } = filters; @@ -620,8 +613,6 @@ export const getMyInterestRequests = async (studentId, filters) => { }; // ─── Expire pending requests for a listing ─────────────────────────────────── -// Bulk-expires all pending interest requests on a listing. Called inside -// transactions when a listing is deactivated, deleted, or filled. export const expirePendingRequestsForListing = async (listingId, client = pool, excludeRequestId = null) => { const params = [listingId]; let excludeClause = ""; @@ -635,7 +626,7 @@ export const expirePendingRequestsForListing = async (listingId, client = pool, `UPDATE interest_requests SET status = 'expired'::request_status_enum, updated_at = NOW() WHERE listing_id = $1 - AND status = 'pending' + AND status = 'pending'::request_status_enum AND deleted_at IS NULL ${excludeClause}`, params, diff --git a/src/services/listing.service.js b/src/services/listing.service.js index 870dd99..0934e49 100644 --- a/src/services/listing.service.js +++ b/src/services/listing.service.js @@ -254,7 +254,8 @@ export const createListing = async (posterId, posterRoles, body) => { longitude, expires_at ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, + $1, $2, $3::listing_type_enum, $4, $5, $6, $7, $8, $9, $10::room_type_enum, + $11::bed_type_enum, $12, $13::gender_enum, $14, $15, $16, $17, $18, $19, $20, $21, $22, NOW() + INTERVAL '60 days' ) @@ -287,8 +288,8 @@ export const createListing = async (posterId, posterRoles, body) => { const listingId = rows[0].listing_id; - await bulkInsertListingAmenities(client, listingId, body.amenityIds); - await bulkInsertListingPreferences(client, listingId, body.preferences); + await bulkInsertListingAmenities(client, listingId, body.amenityIds ?? []); + await bulkInsertListingPreferences(client, listingId, body.preferences ?? []); await client.query("COMMIT"); @@ -297,8 +298,8 @@ export const createListing = async (posterId, posterRoles, body) => { posterId, listingId, listingType: body.listingType, - amenityCount: body.amenityIds.length, - preferenceCount: body.preferences.length, + amenityCount: (body.amenityIds ?? []).length, + preferenceCount: (body.preferences ?? []).length, }, "Listing created", ); @@ -314,10 +315,6 @@ export const createListing = async (posterId, posterRoles, body) => { }; // ─── Get single listing ─────────────────────────────────────────────────────── -// -// userId may be null for guest callers. The listing detail is returned -// regardless — guests see all listing data except compatibility scoring, -// which requires a logged-in user's preferences. export const getListing = async (listingId) => { const listing = await fetchListingDetail(listingId); if (!listing) throw new AppError("Listing not found", 404); @@ -332,12 +329,6 @@ export const getListing = async (listingId) => { }; // ─── Search listings ────────────────────────────────────────────────────────── -// -// userId may be null when called for a guest (optionalAuthenticate set no user). -// When null: -// - scoreListingsForUser is skipped entirely -// - every item gets compatibilityScore: 0 and compatibilityAvailable: false -// - all filter/sort/pagination logic is identical to the authenticated path export const searchListings = async (userId, filters) => { const { city, @@ -393,25 +384,25 @@ export const searchListings = async (userId, filters) => { } if (roomType !== undefined) { - clauses.push(`l.room_type = $${p}`); + clauses.push(`l.room_type = $${p}::room_type_enum`); params.push(roomType); p++; } if (bedType !== undefined) { - clauses.push(`l.bed_type = $${p}`); + clauses.push(`l.bed_type = $${p}::bed_type_enum`); params.push(bedType); p++; } if (preferredGender !== undefined) { - clauses.push(`(l.preferred_gender = $${p} OR l.preferred_gender IS NULL)`); + clauses.push(`(l.preferred_gender = $${p}::gender_enum OR l.preferred_gender IS NULL)`); params.push(preferredGender); p++; } if (listingType !== undefined) { - clauses.push(`l.listing_type = $${p}`); + clauses.push(`l.listing_type = $${p}::listing_type_enum`); params.push(listingType); p++; } @@ -486,8 +477,6 @@ export const searchListings = async (userId, filters) => { const hasNextPage = rows.length > limit; const items = hasNextPage ? rows.slice(0, limit) : rows; - // Compatibility scoring requires an authenticated user with saved preferences. - // For guests (userId === null), skip both DB queries and return zero scores. let scoreMap = {}; let userHasPreferences = false; let listingPreferenceCounts = new Map(); @@ -562,13 +551,21 @@ export const updateListing = async (posterId, listingId, body) => { longitude: "longitude", }; + // Enum columns need explicit casts + const enumCasts = { + room_type: "::room_type_enum", + bed_type: "::bed_type_enum", + preferred_gender: "::gender_enum", + }; + const setClauses = []; const values = []; let paramIndex = 1; for (const [key, column] of Object.entries(columnMap)) { if (body[key] !== undefined) { - setClauses.push(`${column} = $${paramIndex}`); + const cast = enumCasts[column] ?? ""; + setClauses.push(`${column} = $${paramIndex}${cast}`); values.push(body[key]); paramIndex++; } @@ -697,6 +694,8 @@ const ALLOWED_STATUS_TRANSITIONS = { deactivated: ["active"], }; +// FIX: All status enum values in UPDATE/WHERE clauses need explicit ::listing_status_enum casts +// to avoid "column is of type listing_status_enum but expression is of type text" error export const updateListingStatus = async (posterId, listingId, newStatus) => { const { rows: listingRows } = await pool.query( `SELECT status, expires_at, (expires_at <= NOW()) AS is_expired @@ -729,17 +728,19 @@ export const updateListingStatus = async (posterId, listingId, newStatus) => { try { await client.query("BEGIN"); + // FIX: Use explicit ::listing_status_enum cast on ALL status comparisons in this query + // Without this, PostgreSQL rejects the parameterized value as plain text const { rows: updatedRows } = await client.query( `UPDATE listings l - SET status = $1, - filled_at = CASE WHEN $1 = 'filled' THEN NOW() ELSE l.filled_at END + SET status = $1::listing_status_enum, + filled_at = CASE WHEN $1::listing_status_enum = 'filled'::listing_status_enum THEN NOW() ELSE l.filled_at END WHERE l.listing_id = $2 AND l.posted_by = $3 - AND l.status = $4 + AND l.status = $4::listing_status_enum AND l.deleted_at IS NULL - AND ($1 <> 'active' OR l.expires_at > NOW()) + AND ($1::listing_status_enum <> 'active'::listing_status_enum OR l.expires_at > NOW()) AND ( - $1 <> 'active' + $1::listing_status_enum <> 'active'::listing_status_enum OR l.property_id IS NULL OR EXISTS ( SELECT 1 From 298da0a02223dac11cfb3b3f71cfa387a9418d56 Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Fri, 24 Apr 2026 23:24:53 +0530 Subject: [PATCH 16/54] chore: clean up comments and improve code readability across multiple files --- src/db/client.js | 18 +++----------- src/db/utils/institutions.js | 4 +--- src/middleware/authenticate.js | 2 -- src/middleware/authorize.js | 4 ---- src/middleware/errorHandler.js | 4 ---- src/middleware/guestListingGate.js | 14 +---------- src/middleware/upload.js | 7 ------ src/middleware/validate.js | 9 ------- src/routes/auth.js | 3 --- src/server.js | 30 ----------------------- src/workers/bullConnection.js | 2 -- src/workers/mediaProcessor.js | 1 - src/workers/notificationQueue.js | 10 ++------ src/workers/notificationWorker.js | 31 ++---------------------- src/workers/queue.js | 7 ------ src/workers/verificationEventWorker.js | 33 ++++---------------------- 16 files changed, 13 insertions(+), 166 deletions(-) diff --git a/src/db/client.js b/src/db/client.js index 4c6f612..1de4d13 100644 --- a/src/db/client.js +++ b/src/db/client.js @@ -1,32 +1,23 @@ -// 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. 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. 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. @@ -49,14 +40,11 @@ pool.on("error", (err) => { 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/utils/institutions.js b/src/db/utils/institutions.js index 6cd1472..2c56aee 100644 --- a/src/db/utils/institutions.js +++ b/src/db/utils/institutions.js @@ -1,7 +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. // diff --git a/src/middleware/authenticate.js b/src/middleware/authenticate.js index 278ab4b..dcf74af 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"; diff --git a/src/middleware/authorize.js b/src/middleware/authorize.js index 66cfd60..766cd33 100644 --- a/src/middleware/authorize.js +++ b/src/middleware/authorize.js @@ -1,10 +1,6 @@ -// 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 diff --git a/src/middleware/errorHandler.js b/src/middleware/errorHandler.js index 8f82bb4..d519e62 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"; @@ -112,7 +110,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 +117,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..8b98d9d 100644 --- a/src/middleware/guestListingGate.js +++ b/src/middleware/guestListingGate.js @@ -1,7 +1,4 @@ -// src/middleware/guestListingGate.js -// -// ─── GUEST BROWSING POLICY ──────────────────────────────────────────────────── -// +// 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 @@ -12,8 +9,6 @@ // 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. @@ -22,8 +17,6 @@ // // 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 @@ -36,15 +29,10 @@ 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/upload.js b/src/middleware/upload.js index 13c83a5..28daf07 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"; @@ -15,16 +13,11 @@ const MIME_TO_EXTENSIONS = { "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) { diff --git a/src/middleware/validate.js b/src/middleware/validate.js index 72f5bd7..557160e 100644 --- a/src/middleware/validate.js +++ b/src/middleware/validate.js @@ -1,8 +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. // @@ -28,13 +23,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/auth.js b/src/routes/auth.js index f25139b..32ecb57 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"; @@ -57,5 +55,4 @@ 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/server.js b/src/server.js index 0d0a5de..5777767 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"; @@ -29,32 +23,18 @@ 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()]; 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: @@ -82,18 +62,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 +80,6 @@ const start = async () => { } logger.info("Cron jobs stopped"); - // Step 3: await HTTP drain. try { await serverClosePromise; logger.info("HTTP server closed"); @@ -112,7 +88,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 +101,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 +108,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 +115,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 +122,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 +129,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/workers/bullConnection.js b/src/workers/bullConnection.js index 202b7fc..f395a85 100644 --- a/src/workers/bullConnection.js +++ b/src/workers/bullConnection.js @@ -1,5 +1,3 @@ -// src/workers/bullConnection.js -// // Parses config.REDIS_URL into a BullMQ/ioredis connection object. Extracts // host, port, username, password, TLS flag, and DB index. All five fields are // derived here once at startup so queue.js and the worker files never duplicate diff --git a/src/workers/mediaProcessor.js b/src/workers/mediaProcessor.js index 81f42ad..4178a8f 100644 --- a/src/workers/mediaProcessor.js +++ b/src/workers/mediaProcessor.js @@ -1,4 +1,3 @@ -// src/workers/mediaProcessor.js // BullMQ worker for async photo processing: compress with Sharp, write to storage, // update DB, elect cover photo, and clean up the staging file. diff --git a/src/workers/notificationQueue.js b/src/workers/notificationQueue.js index 2ea2852..b96305b 100644 --- a/src/workers/notificationQueue.js +++ b/src/workers/notificationQueue.js @@ -1,5 +1,3 @@ -// src/workers/notificationQueue.js -// // Single shared BullMQ Queue instance for the notification delivery queue. // Previously, connection.service.js, interest.service.js, and rating.service.js // each created their own `new Queue(NOTIFICATION_QUEUE_NAME, ...)` instance, @@ -7,10 +5,6 @@ // This module replaces all three by routing through the getQueue() singleton // registry already used by the media processing queue. // -// Usage in service files: -// import { enqueueNotification } from "../workers/notificationQueue.js"; -// enqueueNotification({ recipientId, type, entityType, entityId }); -// // The helper is fire-and-forget: it catches Redis errors, logs them, and // never throws — a missed notification is a UX inconvenience, not a data // integrity problem. The worker retries up to 5 times with exponential backoff @@ -36,13 +30,13 @@ export const enqueueNotification = (payload) => { .catch((err) => { logger.error( { err, type: payload?.type, entityType: payload?.entityType, entityId: payload?.entityId }, - "Failed to enqueue notification" + "Failed to enqueue notification", ); }); } catch (err) { logger.error( { err, type: payload?.type, entityType: payload?.entityType, entityId: payload?.entityId }, - "Failed to enqueue notification" + "Failed to enqueue notification", ); } }; diff --git a/src/workers/notificationWorker.js b/src/workers/notificationWorker.js index 0bff3e7..fd455f1 100644 --- a/src/workers/notificationWorker.js +++ b/src/workers/notificationWorker.js @@ -1,9 +1,5 @@ -// src/workers/notificationWorker.js -// // BullMQ worker for reliable async notification delivery. // -// ─── WHY THIS EXISTS ───────────────────────────────────────────────────────── -// // Before this branch, notification INSERTs were fire-and-forget pool.query calls // made directly in the service layer after a transaction committed. That pattern // is fast but unreliable: if the process crashed between the commit and the @@ -15,22 +11,16 @@ // with exponential backoff, and exhausted jobs land in the failed set for // manual inspection and replay. // -// ─── CONCURRENCY = 10 ──────────────────────────────────────────────────────── -// // Each notification job is a single SQL INSERT — pure I/O with no CPU cost. // Running 10 concurrent jobs keeps the queue draining quickly during bursts // without risking connection pool exhaustion (10 concurrent inserts against a // pool of 20 leaves comfortable headroom for HTTP handlers). // -// ─── ON CONFLICT (idempotency_key) DO NOTHING ──────────────────────────────── -// // Protects against the retry-after-crash duplicate scenario: if the worker // crashed after the INSERT succeeded but before BullMQ received the completion // acknowledgement, the retry would re-run with the same job.id. The UNIQUE // index on idempotency_key turns that retry into a silent no-op. // -// ─── NOTIFICATION CONTRACT ──────────────────────────────────────────────────── -// // The NOTIFICATION_MESSAGES map below is the authoritative contract between // the enum values defined in the DB schema and the messages shown to users. // Each entry is annotated with its lifecycle status so the surface area is @@ -56,8 +46,6 @@ import { bullConnection } from "./bullConnection.js"; export const NOTIFICATION_QUEUE_NAME = "notification-delivery"; -// ─── Notification message contract ──────────────────────────────────────────── -// // Maps every notification_type_enum value to its human-readable message. // Stored at insert time so the feed always shows the message that was current // when the notification was created — old notifications do not retroactively @@ -68,43 +56,28 @@ export const NOTIFICATION_QUEUE_NAME = "notification-delivery"; // PLANNED — enum value reserved; no emitter yet. Implement the emitter and // remove this annotation when the feature ships. const NOTIFICATION_MESSAGES = { - // ── ACTIVE: fired from interest.service.js ───────────────────────────────── interest_request_received: "Someone expressed interest in your listing", interest_request_accepted: "Your interest request was accepted", interest_request_declined: "Your interest request was declined", interest_request_withdrawn: "An interest request was withdrawn", - // ── ACTIVE: fired from connection.service.js ─────────────────────────────── connection_confirmed: "Your connection has been confirmed by both parties", - // ── ACTIVE: fired from rating.service.js ────────────────────────────────── rating_received: "You received a new rating", - // ── ACTIVE: fired from cron/expiryWarning.js ──────────────────────────────── listing_expiring: "One of your listings is expiring soon", - // ── ACTIVE: fired from cron/listingExpiry.js ──────────────────────────────── listing_expired: "One of your listings has expired", - // ── ACTIVE: fired from interest.service.js when a listing becomes filled ─── - // Emitted post-commit in _acceptInterestRequest when capacity is exhausted. listing_filled: "A listing has been marked as filled", - // ── ACTIVE: fired from verificationEventWorker.js (CDC pipeline) ─────────── - // Emitted when verification_requests.status transitions to verified. verification_approved: "Your verification request was approved", - // ── ACTIVE: fired from verificationEventWorker.js (CDC pipeline) ─────────── - // Emitted when verification_requests.status transitions to rejected. verification_rejected: "Your verification request was rejected", - // ── PLANNED: wire emitter once in-app messaging is implemented (Phase 6+) ── - // The 'new_message' type is reserved for the future WebSocket/messaging phase. + // Reserved for the future in-app messaging phase. new_message: "You have a new message", - // ── PLANNED: no emitter yet — connection_requested would be appropriate for ─ - // an admin-created connection or a future "request to connect" feature. - // Currently all connections are created by the accept flow in interest.service.js - // which emits interest_request_accepted instead. + // Reserved for a future "request to connect" flow. connection_requested: "You have a new connection request", }; diff --git a/src/workers/queue.js b/src/workers/queue.js index edc41f3..ffdb8f1 100644 --- a/src/workers/queue.js +++ b/src/workers/queue.js @@ -1,16 +1,9 @@ -// src/workers/queue.js -// Named singleton registry for BullMQ Queue instances — prevents opening a new -// Redis connection on every enqueue call by reusing one Queue per queue name. - import { Queue } from "bullmq"; import { logger } from "../logger/index.js"; import { bullConnection } from "./bullConnection.js"; const queues = new Map(); -// Returns the existing Queue instance for `name`, or creates and caches one on first call. -// All queues share the same Redis connection config sourced from bullConnection.js, -// which correctly handles ACL usernames, non-zero DB indices, and TLS. export const getQueue = (name) => { if (queues.has(name)) return queues.get(name); diff --git a/src/workers/verificationEventWorker.js b/src/workers/verificationEventWorker.js index 74e3efb..8a71d7c 100644 --- a/src/workers/verificationEventWorker.js +++ b/src/workers/verificationEventWorker.js @@ -1,9 +1,5 @@ -// src/workers/verificationEventWorker.js -// // CDC (Change Data Capture) outbox drainer for verification events. // -// ─── WHAT THIS WORKER DOES ──────────────────────────────────────────────────── -// // The Postgres trigger trg_verification_status_changed (created in migration // 002) writes a row to verification_event_outbox whenever // verification_requests.status changes — regardless of whether that change @@ -17,8 +13,6 @@ // 2. Enqueuing an in-app notification via the existing BullMQ infrastructure. // 3. Enqueuing a transactional email via the existing email queue. // -// ─── SELECT FOR UPDATE SKIP LOCKED ──────────────────────────────────────────── -// // This is the key concurrency primitive. When the worker claims a batch of // outbox rows, it locks them with FOR UPDATE. SKIP LOCKED tells Postgres: // "don't wait for rows that another transaction has already locked — just skip @@ -26,16 +20,12 @@ // (e.g. during a rolling deploy) will never process the same event twice. // They cooperate automatically without any application-level coordination. // -// ─── RETRY SEMANTICS ───────────────────────────────────────────────────────── -// // If processEvent() throws (e.g. a transient DB error), the worker increments // the event's `attempts` counter and records the error message, then continues // to the next event. The failed event will be retried on the next poll cycle. // After MAX_ATTEMPTS failures, the event is permanently skipped (marked with // a terminal error message) so a broken event cannot block the queue forever. // -// ─── POLLING VS LISTEN/NOTIFY ──────────────────────────────────────────────── -// // This implementation uses setInterval polling (every POLL_INTERVAL_MS = 5s). // For verification events, this is perfectly fine — verification decisions are // low-frequency admin actions, not high-volume user events. The 5-second delay @@ -51,8 +41,6 @@ import { logger } from "../logger/index.js"; import { enqueueNotification } from "./notificationQueue.js"; import { enqueueEmail } from "./emailQueue.js"; -// ─── Configuration ──────────────────────────────────────────────────────────── - // How often the worker wakes up and checks for pending events. // 5 seconds is a good balance between responsiveness and DB query overhead // at Roomies' scale. Lower this (e.g. 1000ms) if you need faster reactions. @@ -67,8 +55,6 @@ const BATCH_SIZE = 10; // The error_message column records why, for manual operator inspection. const MAX_ATTEMPTS = 5; -// ─── Core event processor ───────────────────────────────────────────────────── -// // Handles a single outbox event. Called inside the drain transaction, so any // DB writes here (the profile consistency update) participate in the same // transaction as the processed_at acknowledgement. @@ -116,7 +102,6 @@ const processEvent = async (event, client) => { const { email, owner_full_name, business_name, verification_status } = userRows[0]; if (event_type === "verification_approved") { - // ── Profile consistency guard ────────────────────────────────────── // If the admin ran SQL that only updated verification_requests but forgot // to also update pg_owner_profiles (easy mistake), this ensures the profile // reflects the correct state before any notification goes out. @@ -137,7 +122,6 @@ const processEvent = async (event, client) => { ); } - // In-app notification — fire and forget (BullMQ handles retry) enqueueNotification({ recipientId: user_id, type: "verification_approved", @@ -145,7 +129,6 @@ const processEvent = async (event, client) => { entityId: request_id, }); - // Transactional email — fire and forget (BullMQ handles retry) enqueueEmail({ type: "verification_approved", to: email, @@ -157,7 +140,6 @@ const processEvent = async (event, client) => { "verificationEventWorker: approval event processed — notification + email enqueued", ); } else if (event_type === "verification_rejected") { - // ── Profile consistency guard ────────────────────────────────────── if (verification_status !== "rejected") { await client.query( `UPDATE pg_owner_profiles @@ -194,8 +176,7 @@ const processEvent = async (event, client) => { "verificationEventWorker: rejection event processed — notification + email enqueued", ); } else if (event_type === "verification_pending") { - // Acknowledgement email only — no in-app notification needed for "we got - // your documents." The admin dashboard is the primary channel for this state. + // Acknowledgement email only — no in-app notification needed for this state. enqueueEmail({ type: "verification_pending", to: email, @@ -213,8 +194,6 @@ const processEvent = async (event, client) => { } }; -// ─── Drain cycle ────────────────────────────────────────────────────────────── -// // Claims a batch of unprocessed events, processes each one, and marks // successfully processed events with processed_at = NOW(). Failed events // have their attempts counter incremented and error recorded for retry. @@ -229,8 +208,8 @@ const drainOutbox = async () => { try { await client.query("BEGIN"); - // Claim the next batch of pending events using SKIP LOCKED so concurrent - // worker instances do not process the same events simultaneously. + // Claim rows with SKIP LOCKED so concurrent worker instances do not + // process the same events simultaneously. const { rows: events } = await client.query( `SELECT event_id, @@ -248,7 +227,6 @@ const drainOutbox = async () => { [MAX_ATTEMPTS, BATCH_SIZE], ); - // Nothing pending — release the connection without any work. if (!events.length) { await client.query("ROLLBACK"); return; @@ -313,8 +291,7 @@ const drainOutbox = async () => { await client.query("COMMIT"); } catch (err) { - // Outer catch: something went wrong with the transaction infrastructure - // itself (not just one event). Roll back and let the next poll cycle retry. + // Outer catch: transaction-level failure, not a single-event failure. try { await client.query("ROLLBACK"); } catch (_) { @@ -326,8 +303,6 @@ const drainOutbox = async () => { } }; -// ─── Public API ─────────────────────────────────────────────────────────────── -// // Called from server.js after Redis and Postgres are confirmed healthy. // Returns an object with a close() method so server.js can stop the poll // loop during graceful shutdown. From 01bb881a7310623af185d840e35f2bae92b2f18d Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Fri, 24 Apr 2026 23:27:21 +0530 Subject: [PATCH 17/54] Remove outdated documentation files for Tier 2 deployment guide, project plan, and service contracts --- docs/API.md | 185 ---- docs/Deployment.md | 797 ------------------ docs/ImplementationPlan.md | 635 -------------- docs/README.md | 54 -- docs/TechStack.md | 318 ------- docs/api/admin.md | 309 ------- docs/api/appendix-middleware.md | 199 ----- docs/api/appendix-workers-crons.md | 185 ---- docs/api/auth.md | 1035 ----------------------- docs/api/authz-matrix.md | 189 ----- docs/api/connections.md | 177 ---- docs/api/conventions.md | 387 --------- docs/api/frontend-type-guide.md | 112 --- docs/api/health.md | 129 --- docs/api/interests.md | 472 ----------- docs/api/listings.api.md | 1263 ---------------------------- docs/api/notifications.md | 257 ------ docs/api/preferences.md | 203 ----- docs/api/profiles-and-contact.md | 593 ------------- docs/api/properties.md | 315 ------- docs/api/ratings-and-reports.md | 436 ---------- docs/deployment/tier0.md | 911 -------------------- docs/deployment/tier1.md | 210 ----- docs/deployment/tier2.md | 833 ------------------ docs/roomies_project_plan.md | 101 --- docs/services/README.md | 70 -- 26 files changed, 10375 deletions(-) delete mode 100644 docs/API.md delete mode 100644 docs/Deployment.md delete mode 100644 docs/ImplementationPlan.md delete mode 100644 docs/README.md delete mode 100644 docs/TechStack.md delete mode 100644 docs/api/admin.md delete mode 100644 docs/api/appendix-middleware.md delete mode 100644 docs/api/appendix-workers-crons.md delete mode 100644 docs/api/auth.md delete mode 100644 docs/api/authz-matrix.md delete mode 100644 docs/api/connections.md delete mode 100644 docs/api/conventions.md delete mode 100644 docs/api/frontend-type-guide.md delete mode 100644 docs/api/health.md delete mode 100644 docs/api/interests.md delete mode 100644 docs/api/listings.api.md delete mode 100644 docs/api/notifications.md delete mode 100644 docs/api/preferences.md delete mode 100644 docs/api/profiles-and-contact.md delete mode 100644 docs/api/properties.md delete mode 100644 docs/api/ratings-and-reports.md delete mode 100644 docs/deployment/tier0.md delete mode 100644 docs/deployment/tier1.md delete mode 100644 docs/deployment/tier2.md delete mode 100644 docs/roomies_project_plan.md delete mode 100644 docs/services/README.md diff --git a/docs/API.md b/docs/API.md deleted file mode 100644 index 94ec772..0000000 --- a/docs/API.md +++ /dev/null @@ -1,185 +0,0 @@ -# Roomies API Reference - -This file is the API front door. Endpoint-by-endpoint contracts live in `docs/api/*`. - -## Base URLs - -- Live API base URL: `https://roomies-api.onrender.com/api/v1` -- Live health check: `https://roomies-api.onrender.com/api/v1/health` -- Local base URL: `http://localhost:3000/api/v1` - -## Feature Docs - -- [Shared conventions](./api/conventions.md) -- [Auth/Authz matrix](./api/authz-matrix.md) -- [Frontend TS/Zod type guide](./api/frontend-type-guide.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) _(currently not mounted in this workspace; calls return 404)_ -- [Health](./api/health.md) - -## Service-owned Documentation Model - -To reduce drift, each backend service now has a dedicated service contract page in `docs/services/README.md` that maps: - -- route entrypoints (`src/routes/*`) -- validation contracts (`src/validators/*`) -- business rules (`src/services/*`) -- worker side effects (`src/workers/*`) - -API consumers should still start with feature docs in `docs/api/*`, then use service docs when they need -implementation-backed edge-case behavior. - -## Response Envelopes - -Success with data: - -```json -{ - "status": "success", - "data": {} -} -``` - -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" - } - ] -} -``` - -## Auth Transport Modes - -### Cookie mode - -- Auth endpoints set `accessToken` and `refreshToken` as `HttpOnly` cookies. -- Browser clients receive a safe body (no raw token strings). -- Silent refresh is cookie-only. - -### Bearer mode - -- Send `X-Client-Transport: bearer` on auth endpoints. -- Send `Authorization: Bearer ` on protected endpoints. -- Auth responses include raw access/refresh tokens in JSON. - -## Gate and Error Semantics - -- Route gates (`contactRevealGate`, role gates, `guestListingGate`) are first-class outcomes and documented in feature - docs. -- PostgreSQL error mapping in global error handler: - - `23505` → `409` - - `23503` → `409` - - `23514` → `400` - -## Pagination Conventions - -Feed endpoints use keyset pagination with: - -- `limit` -- `cursorTime` -- `cursorId` - -Typical 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`. - -### Cursor pairing rule - -`cursorTime` and `cursorId` must be sent together (or both omitted). Supplying only one returns `400`. - -Example: - -```json -{ - "status": "error", - "message": "Validation failed", - "errors": [ - { - "field": "query.cursorTime", - "message": "cursorTime and cursorId must be provided together" - } - ] -} -``` - -## Background Jobs That Affect API Behavior - -### Listing expiry - -- File: `src/cron/listingExpiry.js` -- Default schedule: `0 2 * * *` -- Env override: `CRON_LISTING_EXPIRY` -- Behavior: expires active listings where `expires_at < NOW()`, bulk-expires pending interests, enqueues - `listing_expired` notifications. - -### Expiry warning - -- File: `src/cron/expiryWarning.js` -- Default schedule: `0 1 * * *` -- Env override: `CRON_EXPIRY_WARNING` -- Behavior: enqueues `listing_expiring` notifications for listings expiring within 7 days. -- Idempotency key format: `expiry_warning:{listing_id}:{YYYY-MM-DD}` - -### Hard-delete cleanup - -- File: `src/cron/hardDeleteCleanup.js` -- Default schedule: `0 4 * * 0` -- Env override: `CRON_HARD_DELETE` -- Behavior: hard-deletes old soft-deleted rows. -- Retention env var: `SOFT_DELETE_RETENTION_DAYS` (default `90`). Must be plain decimal integer only (for example - `"90"`). - -## HTTP Status Codes - -| Code | Meaning | -| ----- | ------------------------------------------------------------------------------------------------------------------------ | -| `200` | Success | -| `201` | Created | -| `202` | Accepted (async processing started; for example photo upload) | -| `400` | Validation or malformed request | -| `401` | Missing/invalid/expired authentication | -| `403` | Forbidden | -| `404` | Resource not found (includes privacy-preserving not-found patterns) | -| `409` | Conflict (duplicates, concurrent transitions, already-resolved states) | -| `413` | Payload too large | -| `422` | Semantic business-rule violation | -| `429` | Rate-limited / gate-limited | -| `500` | Internal server error | -| `503` | Service unavailable (dependency unhealthy/timeouts, and temporary queue-unavailable paths such as photo enqueue failure) | diff --git a/docs/Deployment.md b/docs/Deployment.md deleted file mode 100644 index 64aaf7f..0000000 --- a/docs/Deployment.md +++ /dev/null @@ -1,797 +0,0 @@ -# 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. - ---- - -## 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. - -``` -Tier 0 — Completely Free (start here) - ├── App Server → Render free web service (512 MB, sleeps when idle) - ├── PostgreSQL → Neon free tier (0.5 GB, PostGIS, never expires) - ├── Redis → Upstash free tier (500K commands/month — monitor this) - ├── File Storage → Azure Blob Storage always-free 5 GB (existing adapter) - └── Email → Brevo free (300 emails/day, forever) - -Tier 1 — Paid external (when free limits hit) - ├── Redis > 500K cmds/month → Upstash Fixed $10/month (~₹840) - └── DB > 0.5 GB → Azure PostgreSQL on student credits - -Tier 2 — Azure student credits (last resort, or when external paid cost - exceeds the Azure equivalent) - ├── App Server → Azure App Service B1 (~₹1,092/month) - ├── PostgreSQL → Azure PostgreSQL Flexible B1ms (~₹1,043/month) - ├── Redis → Azure Cache for Redis C0 (~₹1,050/month) - └── (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 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. -- Cold starts are ~1–2 seconds on Render free tier. Acceptable for ≤50 users/day. - ---- - -## Part 0 — Critical Decisions - -### 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. - -### 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: - -``` -Estimate for a low-traffic app (server awake ~3 hours/day): - 3 workers × 12 BZPOPMIN calls/min × 60 min × 3 hr × 30 days - = ~194,400 commands/month — comfortably under 500K. -``` - -**Do not add UptimeRobot or any ping-to-keep-alive mechanism.** Letting the server sleep is what keeps Redis usage low. - -### 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. - -### 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. - -### 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). - -### 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 | - ---- - -## Part 1 — Tier 0 Architecture - -``` -Internet - │ - ▼ -[Render Free Web Service — Singapore region] - Node.js 22 LTS - src/server.js — Express + BullMQ workers + node-cron (single process) - Sleeps after 15 min idle. Cold-starts in ~1–2s on next request. - │ - ├─────────────────────────────────────────┐ - │ │ - ▼ ▼ -[Neon Free — PostgreSQL 16 + PostGIS] [Upstash Free Redis] - Serverless, scale-to-zero Singapore region - 0.5 GB storage 500K commands/month - Wakes in ~500ms on first query rediss:// TLS - │ - ▼ -[Azure Blob Storage — always-free 5 GB] - Standard LRS, Central India - Container: roomies-uploads (public blob read) - Uses existing AzureBlobAdapter — no code changes needed - │ - ▼ -[Brevo SMTP — free 300 emails/day] - smtp-relay.brevo.com:587 - OTPs, verification emails -``` - -API base URL: `https://roomies-api.onrender.com/api/v1` - ---- - -## Part 2 — Pre-Deployment Checklist - -### 2.1 Create Accounts (all free, no credit card required) - -- [ ] **Render** — [render.com](https://render.com). Sign up with GitHub. No card needed for free tier. -- [ ] **Neon** — [neon.com](https://neon.com). Sign up free. No card needed. -- [ ] **Upstash** — [upstash.com](https://upstash.com). Sign up free. No card needed for free tier. -- [ ] **Azure Portal** — [portal.azure.com](https://portal.azure.com) (you already have this for Blob Storage). -- [ ] **Brevo** — [brevo.com](https://brevo.com) (you may already have this configured). - -### 2.2 Generate Secrets - -```bash -# JWT secrets (run twice to get two different values) -node -e "console.log(require('crypto').randomBytes(48).toString('base64'))" -``` - ---- - -## Part 3 — Step-by-Step Tier 0 Setup - -### Phase 1: Neon PostgreSQL - -#### 1.1 Create the Database - -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 -4. Click **Create Project** - -Neon creates a default `neondb` database. Note the connection string from the dashboard — it looks like: - -``` -postgresql://neondb_owner:PASSWORD@ep-ENDPOINT.ap-southeast-1.aws.neon.tech/neondb?sslmode=require -``` - -#### 1.2 Enable PostGIS and pgcrypto - -In the Neon SQL Editor (Dashboard → SQL Editor): - -```sql -CREATE EXTENSION IF NOT EXISTS postgis; -CREATE EXTENSION IF NOT EXISTS pgcrypto; -``` - -Verify PostGIS works: - -```sql -SELECT ST_AsText(ST_MakePoint(77.21, 28.63)); --- Expected: POINT(77.21 28.63) -``` - -#### 1.3 Run Your Schema - -From your local terminal (with `DATABASE_URL` pointing to Neon): - -```bash -# Set Neon URL temporarily for migration -export DATABASE_URL="postgresql://neondb_owner:PASSWORD@ep-ENDPOINT.ap-southeast-1.aws.neon.tech/neondb?sslmode=require" - -# Run migrations -node src/db/migrate.js -# or with ENV_FILE: -ENV_FILE=.env.neon node src/db/migrate.js -``` - -After configuring `.env.neon` (see Phase 5): - -```bash -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. - ---- - -### Phase 2: Upstash Redis - -#### 2.1 Create the Database - -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) -4. Click **Create** - -#### 2.2 Get the Connection String - -From the database overview, copy: - -- **Endpoint**: `SOMETHING.upstash.io` -- **Password**: a long random string - -Your `REDIS_URL`: - -``` -rediss://default:YOUR_PASSWORD@SOMETHING.upstash.io:6379 -``` - -Format: `rediss://default:PASSWORD@ENDPOINT:6379` - -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. - -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. - -#### 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)** -4. **Advanced tab:** - - Allow blob anonymous access: **Enabled** - - Minimum TLS: **TLS 1.2** -5. **Review + create** → **Create** - -#### 3.2 Create the Blob Container - -1. Storage account → **Containers** → **+ Container** -2. Name: `roomies-uploads` -3. Public access level: **Blob (anonymous read access for blobs only)** -4. Click **Create** - -#### 3.3 Get the Connection String - -1. Storage account → **Access keys** (left menu under Security) -2. Click **Show** next to **Connection string** under key1 -3. Copy the full string — this is `AZURE_STORAGE_CONNECTION_STRING` - -This does **not** consume any student credits. Azure Blob Standard LRS first 5 GB is always free. - ---- - -### Phase 4: Brevo Email Setup - -Skip if already configured. Otherwise: - -1. Log in to [brevo.com](https://brevo.com) -2. Go to **Settings** → **SMTP & API** → **SMTP** -3. Copy your **SMTP login** (e.g., `12345xyz@smtp-brevo.com`) → `BREVO_SMTP_LOGIN` -4. Under **SMTP Keys**, generate a new key (starts with `xsmtpsib-`) → `BREVO_SMTP_KEY` -5. Go to **Senders & Domains** → add and verify your sender email → `BREVO_SMTP_FROM` - ---- - -### Phase 5: Local Test Environment (`.env.render`) - -Create `.env.render` at the project root. **Add to `.gitignore` immediately.** - -```env -# .env.render — local testing against Tier 0 services -# NEVER COMMIT THIS FILE - -NODE_ENV=production -PORT=3000 -ENV_FILE=.env.render - -# Neon PostgreSQL -DATABASE_URL=postgresql://neondb_owner:PASSWORD@ep-ENDPOINT.ap-southeast-1.aws.neon.tech/neondb?sslmode=require - -# Upstash Redis -REDIS_URL=rediss://default:YOUR_UPSTASH_PASSWORD@YOUR-ENDPOINT.upstash.io:6379 - -# JWT -JWT_SECRET=YOUR_GENERATED_JWT_SECRET -JWT_REFRESH_SECRET=YOUR_GENERATED_JWT_REFRESH_SECRET -JWT_EXPIRES_IN=15m -JWT_REFRESH_EXPIRES_IN=7d - -# Azure Blob Storage (always-free 5 GB) -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 - -# CORS — allow everything for local testing -ALLOWED_ORIGINS=http://localhost:5173 - -# Google OAuth (optional, skip if not ready) -GOOGLE_CLIENT_ID=YOUR_CLIENT_ID -GOOGLE_CLIENT_SECRET=YOUR_CLIENT_SECRET - -TRUST_PROXY=false -``` - -Test locally: - -```bash -npm run dev:azure # reuse the azure script — just point to .env.render -# or add to package.json: -# "dev:render": "ENV_FILE=.env.render nodemon src/server.js" - -curl http://localhost:3000/api/v1/health -# Expected: {"status":"ok","services":{"database":"ok","redis":"ok"}} -``` - ---- - -### Phase 6: Render Web Service - -#### 6.1 Connect GitHub Repository - -1. Go to [dashboard.render.com](https://dashboard.render.com) -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` - -5. Click **Create Web Service** - -Render assigns a URL like `https://roomies-api.onrender.com`. - -#### 6.2 Set Environment Variables - -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: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` | -| `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. - -#### 6.3 Deploy - -Render auto-deploys on every push to `main`. To trigger the first deploy manually: - -1. Render dashboard → your web service → **Manual Deploy** → **Deploy latest commit** -2. Watch the **Logs** tab - -Expected successful output: - -``` -PostgreSQL connected -Redis connected -Media processing worker started -Notification delivery worker started -Email delivery worker started -Verification event worker started -cron:listingExpiry — registered -cron:expiryWarning — registered -cron:hardDeleteCleanup — registered -Server running on port 10000 [production] -``` - ---- - -## Part 4 — Post-Deployment Verification - -Run these in order. - -### 4.1 Health Check - -```bash -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. - -### 4.2 Test Registration - -```bash -curl -X POST https://roomies-api.onrender.com/api/v1/auth/register \ - -H "Content-Type: application/json" \ - -d '{ - "email": "test@example.com", - "password": "TestPass1", - "role": "student", - "fullName": "Test User" - }' -``` - -Expected: `201` with a `data.user` object and `sid`. - -### 4.3 Verify Photo Upload - -1. Create a listing via API -2. Upload a photo: `POST /listings/:id/photos` (multipart, field `photo`) -3. Immediate response should be `202` with `status: "processing"` -4. Wait 10–15 seconds, then `GET /listings/:id/photos` -5. A photo with a `blob.core.windows.net` URL should appear - -### 4.4 Verify Email - -1. Register with a real email address -2. Call `POST /api/v1/auth/otp/send` (requires auth token) -3. Check Brevo dashboard → **Transactional** → **Logs** for delivery -4. Check your inbox for the OTP email - ---- - -## Part 5 — Monitoring and Migration Triggers - -### 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 | - -### 5.2 Migration Checklist — Redis - -When Upstash free hits the limit: - -``` -Option A: Upstash Fixed 250MB plan ($10/month ≈ ₹840) - → Just change the billing plan in the Upstash console. - → No code changes, no URL change. - -Option B: Azure Cache for Redis C0 Basic (student credits, ~₹1,050/month) - → See Part 7 (Tier 2 Azure) for setup instructions. - → Update REDIS_URL in Render environment variables. -``` - -### 5.3 Migration Checklist — Database - -When Neon exceeds 0.5 GB: - -``` -Option A: Neon paid plan (~$19/month ≈ ₹1,600) - → Upgrade in Neon console. - → No migration needed — same connection string. - -Option B: Azure PostgreSQL Flexible B1ms (student credits) - → See Part 7 (Tier 2 Azure) for setup instructions. - → Run migrations against the new DB, then update DATABASE_URL in Render. -``` - -### 5.4 Migration Checklist — App Server - -When Render free becomes insufficient (high traffic, or you need always-on): - -``` -Option A: Render Starter plan ($7/month ≈ ₹580) - → Always-on, 512 MB RAM, same deployment. - -Option B: Azure App Service B1 (student credits, ~₹1,092/month) - → See Part 7 (Tier 2 Azure) for full Azure migration. -``` - ---- - -## Part 6 — CI/CD with GitHub Actions on Render - -Render auto-deploys on push to `main` once the repo is connected. No extra setup needed. For manual control: - -**Render deploy hook (optional):** - -1. Render dashboard → your service → **Settings** → **Deploy Hook** -2. Copy the webhook URL -3. Add to GitHub Actions as a secret `RENDER_DEPLOY_HOOK` - -```yaml -# .github/workflows/deploy.yml -name: Deploy to Render - -on: - push: - branches: [main] - -jobs: - 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. - -> **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 - -```bash -az login -az account set --subscription "Azure for Students" -az configure --defaults location=centralindia group=roomies-rg -az group create --name roomies-rg --location centralindia -``` - -### Azure PostgreSQL Flexible Server (B1ms) - -**Portal steps:** - -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** -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. - -Enable extensions: - -```bash -az postgres flexible-server parameter set \ - --resource-group roomies-rg --server-name roomies-db \ - --name azure.extensions --value POSTGIS,PGCRYPTO -``` - -Create database: - -```bash -az postgres flexible-server db create \ - --resource-group roomies-rg --server-name roomies-db \ - --database-name roomies_db -``` - -Run your schema: - -```bash -psql "host=roomies-db.postgres.database.azure.com port=5432 dbname=roomies_db user=roomiesadmin password=YOUR_PASSWORD sslmode=require" \ - -f migrations/001_initial_schema.sql -psql "..." -f migrations/002_verification_event_outbox.sql -``` - -**Cost:** ~₹1,043/month compute + ~₹280/month storage. Stop when not developing: - -```bash -az postgres flexible-server stop --resource-group roomies-rg --name roomies-db -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. - -If you do want Azure Redis: - -1. Search **"Azure Cache for Redis"** → Create Basic C0 -2. Region: Central India -3. Copy the primary connection string (starts with `rediss://`) - -### Azure App Service (B1 Linux) - -**Portal steps:** - -1. Create App Service Plan: B1 Linux, Central India, name `roomies-plan` -2. Create Web App: name `roomies-api`, Node 22 LTS, Linux, uses `roomies-plan` -3. Enable **Managed Identity** (Identity → System assigned → On) -4. Create **Key Vault** `roomies-kv` (Standard, RBAC, Central India) -5. Grant App Service **Key Vault Secrets User** on the vault -6. Store all secrets in Key Vault -7. Reference them in App Service Configuration as `@Microsoft.KeyVault(SecretUri=...)` - -**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` | - -**General settings:** - -- Startup Command: `node src/server.js` -- Always On: **On** -- Health check path: `/api/v1/health` -- HTTPS Only: **On** - -**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: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) - -When migrating from Render to Azure: - -```bash -# Create the deployment ZIP -zip -r roomies-deploy.zip . \ - -x "node_modules/*" ".git/*" ".env*" "uploads/*" "*.zip" - -# Deploy -az webapp deploy \ - --resource-group roomies-rg --name roomies-api \ - --src-path roomies-deploy.zip --type zip - -# Watch startup logs -az webapp log tail --resource-group roomies-rg --name roomies-api -``` - -Update your frontend's API base URL from `https://roomies-api.onrender.com` to `https://roomies-api.azurewebsites.net`. - -### 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** | - -With ₹9,480 credits: **~2.9 months** at full Tier 2. By then the project should have real users. - ---- - -## 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 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:6379` | -| 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` | - ---- - -## Part 10 — Quick Reference - -```bash -# View Render logs (install Render CLI: npm i -g @render/cli) -render logs --service roomies-api --tail - -# Manual Render deploy trigger -curl -X POST "YOUR_RENDER_DEPLOY_HOOK_URL" - -# Run migrations against Neon -ENV_FILE=.env.render node src/db/migrate.js - -# Run amenity seed against Neon -ENV_FILE=.env.render node src/db/seeds/amenities.js - -# Test health check -curl https://roomies-api.onrender.com/api/v1/health - -# Update Render env var via API (alternative to dashboard) -# Use the Render dashboard → Environment tab for this -``` - ---- - -## 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 | -| 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) | - ---- - -_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 0fb0780..0000000 --- a/docs/ImplementationPlan.md +++ /dev/null @@ -1,635 +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 + email workers + verificationEvent worker; - 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 - guestListingGate.js ✅ Guest cap for listing browse (`limit` silently capped at 20) - 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 - workers/ - mediaProcessor.js ✅ BullMQ worker: image compression + storage upload + DB URL update - notificationWorker.js ✅ BullMQ worker (`notification-delivery`), concurrency 10 - emailWorker.js ✅ BullMQ worker (`email-delivery`), concurrency 3; handles otp + verification emails - emailQueue.js ✅ Enqueue helper for `email-delivery` queue - verificationEventWorker.js ✅ CDC outbox drainer (5s polling, FOR UPDATE SKIP LOCKED, retries up to 5) - 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; POST /:userId/contact/reveal; POST /:userId/documents - 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`). Admin -queue/resolve controller + service paths exist, but the admin router is not yet mounted in `src/routes/index.js`. - -`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 (planned route mount) -PATCH /api/v1/admin/reports/:reportId/resolve (planned route mount) -``` - ---- - -## 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: `src/routes/admin.js` and route wiring in `src/routes/index.js` for admin-only endpoints. - -**What needs building:** - -- `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) - -**Already completed and present in source:** - -- `src/workers/emailWorker.js` -- `src/workers/emailQueue.js` -- `src/workers/verificationEventWorker.js` - ---- - -## 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 | -| `trg_verification_status_changed` | `verification_requests` | AFTER UPDATE OF `status` | Inserts verification lifecycle rows into `verification_event_outbox` (`verified`, `rejected`, `pending`) for CDC processing | - -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 1803f91..0000000 --- a/docs/README.md +++ /dev/null @@ -1,54 +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](./roomies_project_plan.md) — product goals, persona model, and phase status -- [ImplementationPlan.md](./ImplementationPlan.md) — implementation inventory, conventions, route surface, and codebase - status -- [API.md](./API.md) — API entrypoint, transport rules, shared response conventions, and links to feature docs -- [TechStack.md](./TechStack.md) — runtime, database, queue, storage, validation, and operational decisions -- [Deployment.md](./Deployment.md) — broader deployment guidance -- [deployment/tier0.md](./deployment/tier0.md) — current Render/Neon/Upstash production baseline -- [deployment/tier1.md](./deployment/tier1.md) — first scale-up tier -- [deployment/tier2.md](./deployment/tier2.md) — higher-scale architecture guidance - -## Detailed API Docs - -Feature-level API documentation lives in `docs/api/`. - -- [api/conventions.md](./api/conventions.md) — shared response envelopes, auth failures, pagination, and example - conventions -- [api/authz-matrix.md](./api/authz-matrix.md) — route-level + service-level authentication and authorization matrix -- [api/frontend-type-guide.md](./api/frontend-type-guide.md) — TypeScript/Zod-focused guidance for stable DTO and enum - modeling -- [api/auth.md](./api/auth.md) — registration, login, refresh, OTP, sessions, logout, and Google OAuth flows -- [api/profiles-and-contact.md](./api/profiles-and-contact.md) — student and PG owner profiles, contact reveal, and - verification document submission -- [api/properties.md](./api/properties.md) — property CRUD and ownership rules -- [api/listings.api.md](./api/listings.api.md) — listing search, CRUD, lifecycle changes, saved listings, preferences, - photo flows, and listing-scoped interests -- [api/interests.md](./api/interests.md) — interest request creation, dashboards, and transition state machine -- [api/connections.md](./api/connections.md) — connection dashboard, detail, and two-sided confirmation -- [api/notifications.md](./api/notifications.md) — notification feed, unread count, and mark-read modes -- [api/ratings-and-reports.md](./api/ratings-and-reports.md) — ratings, public reputation views, and rating report - submission -- [api/preferences.md](./api/preferences.md) — preferences metadata and student preferences endpoints -- [api/admin.md](./api/admin.md) — admin surface scenarios (and current mount status) -- [api/health.md](./api/health.md) — dependency health probe behavior -- [api/appendix-middleware.md](./api/appendix-middleware.md) — middleware-to-route reference and chain behavior notes -- [api/appendix-workers-crons.md](./api/appendix-workers-crons.md) — worker/cron responsibilities, triggers, retries, - and endpoint-visible effects - -## Service Contracts - -- [services/README.md](./services/README.md) — service-by-service ownership map linking routes, validators, service - logic, and workers - -## 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 5c76571..0000000 --- a/docs/TechStack.md +++ /dev/null @@ -1,318 +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 | Email transport layer for Ethereal/Brevo SMTP + Brevo API-backed sends | -| 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 | -| `email-delivery` | `emailWorker.js` | 3 (I/O-bound) | OTP + verification lifecycle emails via async queue delivery | - -**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:** Workers are started in `server.js` after Redis connects. On shutdown, `mediaWorker.close()`, -`notificationWorker.close()`, and `emailWorker.close()` are called before `closeAllQueues()` — workers drain in-flight -jobs before queue connections are torn down. - -### Verification CDC outbox worker (non-BullMQ) - -`src/workers/verificationEventWorker.js` is a `setInterval` polling loop (5s), not a BullMQ worker. - -- Reads from `verification_event_outbox` (written by Postgres trigger `trg_verification_status_changed` from migration - `002_verification_event_outbox.sql`). -- Uses `SELECT ... FOR UPDATE SKIP LOCKED` for safe multi-instance concurrent processing without double-processing. -- On each event, enforces profile consistency (`pg_owner_profiles.verification_status`), then enqueues: - - in-app notification (`notification-delivery`) - - transactional email (`email-delivery`) -- Retries failures up to `MAX_ATTEMPTS = 5`. -- On permanent failure, stores `error_message`, sets `processed_at`, and stops retrying that row. -- Started in `server.js` after DB/Redis health checks and stopped on graceful shutdown via - `verificationEventWorker.close()`. - ---- - -## 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 — Current Production Stack - -| Service | Usage | -| ------------------ | ----------------------------------------------------------------------------- | -| Render | Live API host (`https://roomies-api.onrender.com`) | -| Neon PostgreSQL | Managed PostgreSQL 16 (+ PostGIS), pooler endpoint used in `DATABASE_URL` | -| Upstash Redis | Managed Redis over TLS (`rediss://...:6379`) for app cache + BullMQ | -| Azure Blob Storage | Photo storage (`AZURE_STORAGE_CONNECTION_STRING` + `AZURE_STORAGE_CONTAINER`) | -| Brevo API / SMTP | Email delivery with provider switch via `EMAIL_PROVIDER` | - -### `EMAIL_PROVIDER` modes - -- `ethereal`: local fake SMTP testing (`SMTP_*` + `SMTP_FROM`) -- `brevo`: Brevo SMTP relay (`BREVO_SMTP_LOGIN`, `BREVO_SMTP_KEY`, `BREVO_SMTP_FROM`) -- `brevo-api`: Brevo REST API (`BREVO_API_KEY`, `BREVO_SMTP_FROM`) — used on Render free tier where SMTP is blocked - -Credential prefix reminder: - -- SMTP key: starts with `xsmtpsib-` (for `EMAIL_PROVIDER=brevo`) -- API key: starts with `xkeysib-` (for `EMAIL_PROVIDER=brevo-api`) - ---- - -## 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 | Provider switch: Ethereal (dev), Brevo SMTP, or Brevo API | diff --git a/docs/api/admin.md b/docs/api/admin.md deleted file mode 100644 index 845ab7b..0000000 --- a/docs/api/admin.md +++ /dev/null @@ -1,309 +0,0 @@ -# Admin API - -Shared conventions: [conventions.md](./conventions.md) - -## Current implementation status - -`/admin` routes are **not mounted** in `src/routes/index.js` in this workspace, so live calls to `/api/v1/admin/*` -currently return `404`. - -This file documents the intended scenario contracts already backed by: - -- `src/services/verification.service.js` -- `src/services/report.service.js` -- `src/validators/verification.validators.js` -- `src/validators/report.validators.js` -- `src/controllers/verification.controller.js` -- `src/controllers/report.controller.js` - -When the admin router is mounted with `authenticate + authorize("admin")`, the scenarios below apply. - -## Router-level admin gate - -### Scenario: caller is not an admin - -Status: `403` - -```json -{ - "status": "error", - "message": "Forbidden" -} -``` - ---- - -## `GET /admin/verification-queue` - -Oldest-first paginated queue of pending PG owner verification requests. - -### Scenario: success - -Status: `200` - -```json -{ - "status": "success", - "data": { - "items": [ - { - "request_id": "99999999-9999-4999-8999-999999999999", - "user_id": "22222222-2222-4222-8222-222222222222", - "document_type": "owner_id", - "document_url": "https://roomiesblob.blob.core.windows.net/roomies-uploads/verifications/owner-id.webp", - "submitted_at": "2026-04-11T10:00:00.000Z", - "business_name": "Sunrise PG", - "owner_full_name": "Rohan Mehta", - "verification_status": "pending", - "email": "owner@sunrisepg.in" - } - ], - "nextCursor": null - } -} -``` - -### Scenario: partial cursor supplied - -Status: `400` - -```json -{ - "status": "error", - "message": "Validation failed", - "errors": [ - { - "field": "query.cursorTime", - "message": "cursorTime and cursorId must be provided together" - } - ] -} -``` - ---- - -## `POST /admin/verification-queue/:requestId/approve` - -### Scenario: request approved - -Status: `200` - -```json -{ - "status": "success", - "data": { - "requestId": "99999999-9999-4999-8999-999999999999", - "status": "verified" - } -} -``` - -### Scenario: request already resolved concurrently - -Status: `409` - -```json -{ - "status": "error", - "message": "Verification request not found or already resolved" -} -``` - ---- - -## `POST /admin/verification-queue/:requestId/reject` - -### Scenario: request rejected - -Status: `200` - -```json -{ - "status": "success", - "data": { - "requestId": "99999999-9999-4999-8999-999999999999", - "status": "rejected" - } -} -``` - -### Scenario: rejection reason missing - -Status: `400` - -```json -{ - "status": "error", - "message": "Validation failed", - "errors": [ - { - "field": "body.rejectionReason", - "message": "Rejection reason is required" - } - ] -} -``` - -### Scenario: request already resolved concurrently - -Status: `409` - -```json -{ - "status": "error", - "message": "Verification request not found or already resolved" -} -``` - ---- - -## `GET /admin/report-queue` - -Oldest-first paginated queue of open rating reports. - -### Scenario: success - -Status: `200` - -```json -{ - "status": "success", - "data": { - "items": [ - { - "reportId": "99999999-9999-4999-8999-999999999999", - "reporterId": "11111111-1111-4111-8111-111111111111", - "ratingId": "88888888-8888-4888-8888-888888888888", - "reason": "fake", - "explanation": "The review does not match the actual stay.", - "status": "open", - "submittedAt": "2026-04-11T10:00:00.000Z", - "rating": { - "overallScore": 1, - "cleanlinessScore": 1, - "communicationScore": 1, - "reliabilityScore": 1, - "valueScore": 1, - "comment": "Fake review", - "revieweeType": "user", - "revieweeId": "22222222-2222-4222-8222-222222222222", - "isVisible": true, - "createdAt": "2026-04-10T10:00:00.000Z", - "reviewer": { - "fullName": "Priya Sharma", - "profilePhotoUrl": null - }, - "reviewee": { - "fullName": "Rohan Mehta", - "profilePhotoUrl": null - } - }, - "reporter": { - "fullName": "Priya Sharma", - "profilePhotoUrl": null - } - } - ], - "nextCursor": null - } -} -``` - ---- - -## `PATCH /admin/reports/:reportId/resolve` - -### Scenario: resolve and remove rating - -Request: - -```json -{ - "resolution": "resolved_removed", - "adminNotes": "Evidence confirmed. Rating removed." -} -``` - -Status: `200` - -```json -{ - "status": "success", - "data": { - "reportId": "99999999-9999-4999-8999-999999999999", - "resolution": "resolved_removed", - "ratingId": "88888888-8888-4888-8888-888888888888" - } -} -``` - -### Scenario: `resolved_removed` without `adminNotes` - -Status: `400` - -```json -{ - "status": "error", - "message": "Validation failed", - "errors": [ - { - "field": "body.adminNotes", - "message": "adminNotes is required when resolution is resolved_removed" - } - ] -} -``` - -### Scenario: report already resolved - -Status: `409` - -```json -{ - "status": "error", - "message": "Report not found or already resolved" -} -``` - -## CDC Outbox: Verification Side Effects - -Verification status changes trigger a CDC (Change Data Capture) outbox pipeline, not direct synchronous effects from the -API. - -### Trigger - -The Postgres trigger `trg_verification_status_changed` fires on `AFTER UPDATE OF status ON verification_requests` for -any status change to `verified`, `rejected`, or `pending`. This fires regardless of whether the change comes from the -API or direct SQL. - -### Worker behavior (`src/workers/verificationEventWorker.js`) - -| Property | Value | -| ------------------ | -------------------------------------------------------------------------- | -| Poll interval | 5 seconds | -| Batch size | 10 events per cycle | -| Max retry attempts | 5 (then event is abandoned with error recorded) | -| Concurrency safe | Yes — `FOR UPDATE SKIP LOCKED` prevents double-processing across instances | -| Startup behavior | Runs one immediate drain on startup to catch accumulated events | - -### Per-event actions - -| Event type | In-app notification | Email | -| ----------------------- | ------------------- | ------------------------------------- | -| `verification_approved` | Yes | Yes (`sendVerificationApprovedEmail`) | -| `verification_rejected` | Yes | Yes (`sendVerificationRejectedEmail`) | -| `verification_pending` | No | Yes (`sendVerificationPendingEmail`) | - -### Profile consistency guard - -Before enqueuing notifications, the worker verifies `pg_owner_profiles.verification_status` matches the event type. If -inconsistent (for example, admin ran partial SQL), the worker corrects the profile status in the same DB transaction as -the event acknowledgement. - -### Failure behavior - -- If `processEvent()` throws, `attempts` is incremented and `error_message` recorded. -- After `MAX_ATTEMPTS = 5` failures, `processed_at` is set — event is permanently skipped but remains in table for - inspection. -- Graceful shutdown (`SIGTERM`) calls `clearInterval` only — in-flight events are not drained and will be retried on - next startup. diff --git a/docs/api/appendix-middleware.md b/docs/api/appendix-middleware.md deleted file mode 100644 index ae03f7b..0000000 --- a/docs/api/appendix-middleware.md +++ /dev/null @@ -1,199 +0,0 @@ -# Appendix: Middleware Reference - -Maps each middleware to every route that uses it, with its role in the chain. - ---- - -## `authenticate` (`src/middleware/authenticate.js`) - -Requires a valid access token. Sets `req.user`. Attempts silent refresh for expired cookie tokens only. - -| Route | Method | -| -------------------------------------------------- | ------ | -| `POST /auth/logout/current` | POST | -| `POST /auth/logout/all` | POST | -| `GET /auth/sessions` | GET | -| `DELETE /auth/sessions/:sid` | DELETE | -| `POST /auth/otp/send` | POST | -| `POST /auth/otp/verify` | POST | -| `GET /auth/me` | GET | -| `POST /listings` | POST | -| `PUT /listings/:listingId` | PUT | -| `DELETE /listings/:listingId` | DELETE | -| `PATCH /listings/:listingId/status` | PATCH | -| `GET /listings/:listingId/preferences` | GET | -| `PUT /listings/:listingId/preferences` | PUT | -| `POST /listings/:listingId/save` | POST | -| `DELETE /listings/:listingId/save` | DELETE | -| `GET /listings/me/saved` | GET | -| `GET /listings/:listingId/photos` | GET | -| `POST /listings/:listingId/photos` | POST | -| `DELETE /listings/:listingId/photos/:photoId` | DELETE | -| `PATCH /listings/:listingId/photos/:photoId/cover` | PATCH | -| `PUT /listings/:listingId/photos/reorder` | PUT | -| `POST /listings/:listingId/interests` | POST | -| `GET /listings/:listingId/interests` | GET | -| `GET /interests/me` | GET | -| `GET /interests/:interestId` | GET | -| `PATCH /interests/:interestId/status` | PATCH | -| `GET /connections/me` | GET | -| `GET /connections/:connectionId` | GET | -| `POST /connections/:connectionId/confirm` | POST | -| `GET /notifications` | GET | -| `GET /notifications/unread-count` | GET | -| `POST /notifications/mark-read` | POST | -| `GET /students/:userId/profile` | GET | -| `PUT /students/:userId/profile` | PUT | -| `GET /students/:userId/preferences` | GET | -| `PUT /students/:userId/preferences` | PUT | -| `GET /pg-owners/:userId/profile` | GET | -| `PUT /pg-owners/:userId/profile` | PUT | -| `POST /pg-owners/:userId/documents` | POST | -| `GET /properties` | GET | -| `GET /properties/:propertyId` | GET | -| `POST /properties` | POST | -| `PUT /properties/:propertyId` | PUT | -| `DELETE /properties/:propertyId` | DELETE | -| `GET /ratings/me/given` | GET | -| `GET /ratings/connection/:connectionId` | GET | -| `POST /ratings` | POST | -| `POST /ratings/:ratingId/report` | POST | -| `GET /preferences/meta` | GET | - ---- - -## `optionalAuthenticate` (`src/middleware/optionalAuthenticate.js`) - -Tries to set `req.user` if a valid token is present. Never returns 401. Any failure (expired, invalid, user -deleted/suspended) -> request continues as guest. - -Token extraction order: cookie -> Authorization header (same as `authenticate`). - -| Route | Method | -| ---------------------------------------- | ------ | -| `GET /listings` | GET | -| `GET /listings/:listingId` | GET | -| `GET /students/:userId/contact/reveal` | GET | -| `POST /pg-owners/:userId/contact/reveal` | POST | - ---- - -## `authorize(role)` (`src/middleware/authorize.js`) - -Must run after `authenticate`. Checks `req.user.roles.includes(role)`. Returns `403 Forbidden` if role missing. - -**Note:** `authorize()` validates the role argument at route registration time (startup), not per-request. -Misconfiguration throws at startup. - -| Route | Required Role | -| ------------------------------------- | ------------- | -| `GET /listings/me/saved` | `student` | -| `POST /listings/:listingId/save` | `student` | -| `DELETE /listings/:listingId/save` | `student` | -| `POST /listings/:listingId/interests` | `student` | -| `GET /interests/me` | `student` | -| `PUT /pg-owners/:userId/profile` | `pg_owner` | -| `POST /pg-owners/:userId/documents` | `pg_owner` | -| `GET /properties` | `pg_owner` | -| `POST /properties` | `pg_owner` | -| `PUT /properties/:propertyId` | `pg_owner` | -| `DELETE /properties/:propertyId` | `pg_owner` | - ---- - -## `contactRevealGate` (`src/middleware/contactRevealGate.js`) - -Must run after `optionalAuthenticate` and `validate`. Sets `req.contactReveal = { emailOnly, verified }`. - -**Behavior:** - -- Verified users (`req.user?.isEmailVerified === true`): passes through, sets `emailOnly: false` -> controller returns - full bundle (email + phone) -- Guests/unverified: checks Redis counter `contactRevealAnon:{sha256(ip|ua)}`, then checks HttpOnly cookie - `contactRevealAnonCount` -- If count >= 10: returns `429 CONTACT_REVEAL_LIMIT_REACHED` immediately -- Otherwise: installs a pre-response hook on `res.json`/`res.send`/`res.end` to increment quota after a successful 2xx - response only -- Quota is incremented atomically via Lua script: `INCR key; if count==1 then EXPIRE key 30days` -- `Cache-Control: no-store` must be set on the response before this middleware runs (see route files) - -| Route | Method | -| ---------------------------------------- | ------ | -| `GET /students/:userId/contact/reveal` | GET | -| `POST /pg-owners/:userId/contact/reveal` | POST | - ---- - -## `guestListingGate` (`src/middleware/guestListingGate.js`) - -Must run after `validate` (relies on Zod-coerced `req.query.limit` being a number). - -**Behavior:** - -- Authenticated (`req.user` set): no-op, passes through -- Guest: silently caps `req.query.limit` to `20` if the requested value exceeds `20` -- Does not block, does not count, does not touch Redis - -| Route | Method | -| -------------------------- | ------ | -| `GET /listings` | GET | -| `GET /listings/:listingId` | GET | - ---- - -## `validate(schema)` (`src/middleware/validate.js`) - -Runs Zod's `schema.safeParse({ body, query, params })`. On success, writes `result.data` back to `req.body`, -`req.params`, and `req.query` (using `Object.defineProperty` for query in Express 5 compatibility). On failure, passes -`ZodError` to `next(err)` -> global error handler. - -Used on virtually every route. Not individually listed here — see per-endpoint docs for the schema name. - ---- - -## `authLimiter` (`src/middleware/rateLimiter.js`) - -10 requests / 15-minute window. Redis-backed (`rl:auth:{ip}`). `passOnStoreError: true` — degrades to no limiting if -Redis is down. - -| Route | Method | -| ---------------------------- | ------ | -| `POST /auth/register` | POST | -| `POST /auth/login` | POST | -| `POST /auth/logout/all` | POST | -| `POST /auth/refresh` | POST | -| `GET /auth/sessions` | GET | -| `DELETE /auth/sessions/:sid` | DELETE | -| `POST /auth/google/callback` | POST | - ---- - -## `otpLimiter` (`src/middleware/rateLimiter.js`) - -5 requests / 15-minute window. Redis-backed (`rl:otp:{ip}`). `passOnStoreError: true`. - -| Route | Method | -| --------------------- | ------ | -| `POST /auth/otp/send` | POST | - ---- - -## `publicRatingsLimiter` (`src/middleware/rateLimiter.js`) - -120 requests / 15-minute window. Redis-backed (`rl:ratings:public:{ip}`). `passOnStoreError: true`. - -| Route | Method | -| ----------------------------------- | ------ | -| `GET /ratings/user/:userId` | GET | -| `GET /ratings/property/:propertyId` | GET | - ---- - -## `upload.single("photo")` (`src/middleware/upload.js`) - -Multer disk storage. Writes to `uploads/staging/{uuid}.ext`. Validates MIME type and file extension cross-match. Limits: -10MB file size, 1 file per request. - -| Route | Method | -| ---------------------------------- | ------ | -| `POST /listings/:listingId/photos` | POST | diff --git a/docs/api/appendix-workers-crons.md b/docs/api/appendix-workers-crons.md deleted file mode 100644 index f65d329..0000000 --- a/docs/api/appendix-workers-crons.md +++ /dev/null @@ -1,185 +0,0 @@ -# Appendix: Workers and Cron Jobs Reference - ---- - -## BullMQ Workers - -### `media-processing` queue — `src/workers/mediaProcessor.js` - -**Concurrency:** 1 (CPU-bound Sharp processing) - -**Triggered by:** `POST /listings/:listingId/photos` (via `photo.service.enqueuePhotoUpload`) - -**Job payload:** `{ listingId, photoId, stagingPath, posterId }` - -**Processing steps:** - -1. `sharp(stagingPath).resize(1200, 1200, { fit: "inside" }).webp({ quality: 80 }).withMetadata(false).toBuffer()` -2. `storageService.upload(buffer, listingId, filename)` — AzureBlobAdapter with 30s AbortController timeout -3. `UPDATE listing_photos SET photo_url = finalUrl WHERE photo_id = ?` -4. Cover election: `UPDATE ... SET is_cover = TRUE WHERE NOT EXISTS (SELECT 1 ... WHERE is_cover = TRUE)` -5. `fs.unlink(stagingPath)` - -**Failure handling:** - -- If photo row was soft-deleted before processing completed: deletes the already-uploaded permanent file, skips DB - update -- `storageService.upload` timeout (30s): throws `AppError 504`, job fails -> BullMQ retries (max 3 attempts) -- Stage file cleanup failures: logged as warn, does not fail the job - -**Affects these endpoint-visible states:** - -- `GET /listings/:listingId/photos` — photo becomes visible only after worker sets real URL (placeholder - `processing:{photoId}` is filtered out) -- `GET /listings/:listingId` — cover photo in `photos` array - ---- - -### `notification-delivery` queue — `src/workers/notificationWorker.js` - -**Concurrency:** 10 (I/O-bound DB inserts) - -**Job options:** 5 attempts, exponential backoff (2s base) - -**Triggered by:** `enqueueNotification()` calls in: - -- `interest.service.js` — `createInterestRequest`, `transitionInterestRequest`, `_acceptInterestRequest` -- `connection.service.js` — `confirmConnection` -- `rating.service.js` — `submitRating` -- `cron/listingExpiry.js` — listing expired -- `cron/expiryWarning.js` — listing expiring soon -- `verificationEventWorker.js` — verification approved/rejected - -**Processing:** `INSERT INTO notifications ... ON CONFLICT (idempotency_key) DO NOTHING` - -Idempotency key = BullMQ `job.id`. Retry-safe: duplicate inserts are silently skipped. - -**Affects:** `GET /notifications`, `GET /notifications/unread-count` - ---- - -### `email-delivery` queue — `src/workers/emailWorker.js` - -**Concurrency:** 3 (I/O-bound SMTP/REST) - -**Job options:** 3 attempts, exponential backoff (5s base) - -**Triggered by:** `enqueueEmail()` calls in: - -- `auth.service.js` -> `sendOtp()` — triggers on `POST /auth/otp/send` -- `verificationEventWorker.js` — all three verification event types - -**Handlers:** - -| Job type | Email function | Triggered by | -| ----------------------- | --------------------------------- | ----------------------- | -| `otp` | `sendOtpEmail()` | `POST /auth/otp/send` | -| `verification_approved` | `sendVerificationApprovedEmail()` | verificationEventWorker | -| `verification_rejected` | `sendVerificationRejectedEmail()` | verificationEventWorker | -| `verification_pending` | `sendVerificationPendingEmail()` | verificationEventWorker | - -Unknown job type: logged at warn level, job marked complete without sending. - ---- - -### `verificationEventWorker` — `src/workers/verificationEventWorker.js` - -**Type:** Not BullMQ — `setInterval` polling loop - -**Poll interval:** 5 seconds - -**Batch size:** 10 events per cycle - -**Max attempts per event:** 5 - -**Triggered by:** Postgres trigger `trg_verification_status_changed` which fires on -`AFTER UPDATE OF status ON verification_requests` - -**API routes that can trigger the trigger:** - -- `POST /admin/verification-queue/:requestId/approve` -> `verification.service.approveRequest()` -- `POST /admin/verification-queue/:requestId/reject` -> `verification.service.rejectRequest()` -- `POST /pg-owners/:userId/documents` -> `verification.service.submitDocument()` -> sets status to `pending` - -**Also triggered by:** Any direct SQL UPDATE on `verification_requests.status` (migration scripts, admin psql sessions) - -**Actions per event:** - -- `verification_approved`: correct `pg_owner_profiles.verification_status` if needed -> `enqueueNotification` + - `enqueueEmail` -- `verification_rejected`: correct profile status -> `enqueueNotification` + `enqueueEmail` -- `verification_pending`: `enqueueEmail` only (no in-app notification) - -**Concurrency safety:** `SELECT ... FOR UPDATE SKIP LOCKED` — safe for multi-instance deployments - -**Shutdown behavior:** `clearInterval` only — does not drain in-flight events. Events retried on next startup. - ---- - -## Cron Jobs - -### `listingExpiry` — `src/cron/listingExpiry.js` - -**Schedule:** `0 2 * * *` (02:00 daily). Override: `CRON_LISTING_EXPIRY` env var. - -**What it does:** - -```sql -UPDATE listings SET status = 'expired' -WHERE status = 'active' AND expires_at < NOW() AND deleted_at IS NULL -RETURNING listing_id, posted_by -``` - -Then in the same transaction: - -```sql -UPDATE interest_requests SET status = 'expired' -WHERE listing_id = ANY($expiredIds) AND status = 'pending' AND deleted_at IS NULL -``` - -Post-commit: `enqueueNotification({ type: "listing_expired" })` for each expired listing's poster. - -**API impact:** - -- Expired listings no longer appear in `GET /listings` search results -- `POST /listings/:listingId/save` and `POST /listings/:listingId/interests` return 422 for expired listings -- Pending interest requests on expired listings become `expired` status - ---- - -### `expiryWarning` — `src/cron/expiryWarning.js` - -**Schedule:** `0 1 * * *` (01:00 daily). Override: `CRON_EXPIRY_WARNING` env var. - -**What it does:** Finds active listings expiring within 7 days that have NOT already received a warning today. Inserts -notification rows directly (not via BullMQ) inside a transaction with idempotency key -`expiry_warning:{listing_id}:{YYYY-MM-DD}`. Uses advisory lock `pg_try_advisory_xact_lock(7001)` to prevent concurrent -runs. - -Post-commit: `enqueueNotification({ type: "listing_expiring" })` for each newly inserted warning. - -**Idempotent:** Re-running on the same day produces no duplicates (ON CONFLICT DO NOTHING on idempotency_key). - -**API impact:** Adds `listing_expiring` notification to poster's feed. - ---- - -### `hardDeleteCleanup` — `src/cron/hardDeleteCleanup.js` - -**Schedule:** `0 4 * * 0` (04:00 Sundays). Override: `CRON_HARD_DELETE` env var. - -**Retention period:** `SOFT_DELETE_RETENTION_DAYS` env var (default `90`). Must be a plain decimal integer (for example -`"90"`). Values like `"90days"` or `"-30"` fall back to 90 with a warn log. - -**What it does:** Hard-deletes rows with `deleted_at < NOW() - N days` across all soft-delete tables, in dependency -order to avoid FK violations: - -1. `rating_reports` -> 2. `ratings` -> 3. `notifications` -> 4. `connections` (guarded: skips if not-yet-aged rating - references it) -> 5. `interest_requests` -> 6. `saved_listings` -> 7. `listing_photos` -> 8. `listings` (guarded: - skips if child rows not yet aged) -> 9. `verification_requests` -> 10. `pg_owner_profiles` -> 11. `student_profiles` - -> 12. `properties` (guarded) -> 13. `institutions` -> 14. `users` (guarded: skips if any not-yet-aged child - references it) - -All in one transaction. Rollback on any error. - -**API impact:** Reduces DB size. Rows aged past retention are permanently unrecoverable. No direct endpoint impact. diff --git a/docs/api/auth.md b/docs/api/auth.md deleted file mode 100644 index 0712302..0000000 --- a/docs/api/auth.md +++ /dev/null @@ -1,1035 +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). - -## Auth Transport Reference - -### Token extraction priority - -`authenticate` and `optionalAuthenticate` extract the access token in this exact order: - -1. `req.cookies.accessToken` (HttpOnly cookie) — takes priority -2. `Authorization: Bearer ` header — used only if no cookie is present - -If both are present, the cookie always wins. - -### Transport mode: cookie (default, browser) - -- No special request header needed -- Auth endpoints set `accessToken` (TTL: `JWT_EXPIRES_IN`, default `15m`) and `refreshToken` (TTL: - `JWT_REFRESH_EXPIRES_IN`, default `7d`) as `HttpOnly; SameSite=Strict` cookies -- Response body for auth endpoints contains `{ user, sid }` only — no raw token strings -- `secure: true` in production, `secure: false` in development - -### Transport mode: bearer (mobile/API) - -- Set request header: `X-Client-Transport: bearer` (case-sensitive, lowercase `"bearer"`) -- Response body for auth endpoints includes `{ accessToken, refreshToken, user, sid }` -- For protected endpoints, send: `Authorization: Bearer ` - -### Silent refresh (cookie mode only) - -Triggers automatically inside `authenticate` middleware when: - -- `err.name === "TokenExpiredError"` and -- token came from `req.cookies.accessToken` (not from `Authorization` header) - -Silent refresh process: - -1. Reads `req.cookies.refreshToken` -2. Calls `verifyRefreshTokenPayload()` (supports legacy tokens missing `sid`) -3. Checks Redis key `refreshToken:{userId}:{sid}` and verifies exact token match -4. Loads fresh user state from DB -5. Signs new access token and refresh token -6. CAS-rotates refresh token atomically via Lua script -7. Sets new cookies on the response - -Silent refresh never triggers for an expired bearer header token. - -### Error response matrix by scenario - -| Scenario | Middleware | Response | -| --------------------------------------- | ---------------------- | ----------------------------------------------------------------------------- | -| No token present | `authenticate` | `401 { "message": "No token provided" }` | -| Bearer token expired | `authenticate` | `401 { "message": "Token has expired" }` | -| Cookie token expired + refresh succeeds | `authenticate` | Request continues transparently | -| Cookie token expired + refresh fails | `authenticate` | `401 { "message": "Session expired" }` | -| Token invalid (malformed/wrong secret) | `authenticate` | `401 { "message": "Invalid token" }` | -| User not found in DB | `authenticate` | `401 { "message": "User not found" }` | -| User suspended/banned/deactivated | `authenticate` | `401 { "message": "Account is suspended" }` (or corresponding account status) | -| Any token error | `optionalAuthenticate` | Request continues as guest (`req.user` is undefined) | -| Invalid/expired token | `optionalAuthenticate` | Request continues as guest — never returns `401` | -| Suspended user token | `optionalAuthenticate` | Request continues as guest | - -## `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 (cookie clients can omit body entirely) - -### Request body behavior - -The request body is fully optional for browser clients using cookie transport. - -- Cookie mode (browser): send no body; controller reads `req.cookies.refreshToken` -- Bearer mode (mobile/API): send `{ "refreshToken": "..." }` in body - -Resolution order in controller: `req.body?.refreshToken ?? req.cookies?.refreshToken`. - -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. This endpoint intentionally does **not** require -`authenticate`, so clients with expired access tokens can still revoke refresh-token sessions. - -### Request - -Cookie-based browser request can send no body. - -The request body is optional in cookie mode and accepted in bearer mode: - -- Cookie mode (browser): send no body; controller reads `req.cookies.refreshToken` -- Bearer mode (mobile/API): send `{ "refreshToken": "..." }` - -Resolution order in controller: `req.body?.refreshToken ?? req.cookies?.refreshToken`. - -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. This endpoint **does** require `authenticate` and is session-scoped: -the refresh token must belong to the same authenticated user/session context. - -### 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, OTP limiter (**5 requests / 15 minutes**) -- Body: none (if a body is sent, it is ignored) - -### 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. -- JWT TTL env values accept both duration strings (`15m`, `7d`) and numeric seconds (`900`, `604800`). -- Legacy refresh tokens that predate per-session `sid` are migrated transparently on first successful refresh; callers - do not need any migration logic. diff --git a/docs/api/authz-matrix.md b/docs/api/authz-matrix.md deleted file mode 100644 index 829aba7..0000000 --- a/docs/api/authz-matrix.md +++ /dev/null @@ -1,189 +0,0 @@ -# Authentication & Authorization Matrix - -Shared conventions: [conventions.md](./conventions.md) - -This page is the **single auth/authz reference** for the mounted API surface in this workspace. It documents both: - -1. **Route-level enforcement** (middleware in `src/routes/*`), and -2. **Service-level enforcement** (ownership, party checks, verification checks, privacy-preserving 404 behavior). - -> Source of truth order: `src/routes/*` → `src/validators/*` → `src/services/*`. - -## Auth legend - -- **Public**: no token required. -- **Optional auth**: token optional; behavior changes when authenticated. -- **Authenticated**: valid access token required. -- **Role gate**: `authorize("role")` middleware. -- **Service gate**: authorization enforced inside service query logic/business rules. - ---- - -## Optional-authenticate exception semantics - -For routes using `optionalAuthenticate`, token handling is intentionally non-blocking: - -- If no token is sent, request continues as guest. -- If token is malformed/expired/invalid, request still continues as guest (no `401` from this middleware). -- If token is valid but user is missing/inactive, request still continues as guest. -- Only a valid active user token populates `req.user`. - -This behavior affects: - -- `GET /listings` -- `GET /listings/:listingId` -- `GET /students/:userId/contact/reveal` -- `POST /pg-owners/:userId/contact/reveal` - -## Route exceptions and special-case behaviors - -These are intentional exceptions to common auth patterns and should be handled explicitly by clients: - -- `POST /auth/logout` is public by design (refresh-token revocation should still work when access token is expired). -- `GET /listings` and `GET /listings/:listingId` allow guest access via optional auth. -- `GET /listings/:listingId/photos` requires auth even though listing detail is public. -- `contactRevealGate` endpoints are optional-auth but quota-gated for guest/unverified users; verified users are - unlimited. -- Contact reveal quota is charged only on successful 2xx responses. -- `/admin/*` docs exist but routes are not mounted in this workspace. -- `/test-utils/*` mounts only when `NODE_ENV !== "production"`. - ---- - -## Auth routes (`/auth`) - -| Endpoint | Route-level auth | Service-level auth/authorization notes | -| ---------------------------- | ---------------- | ---------------------------------------------------------------------------------------- | -| `POST /auth/register` | Public | Registration role validation in service (`student` / `pg_owner`). | -| `POST /auth/login` | Public | Credential + account-state validation in service. | -| `POST /auth/refresh` | Public | Refresh token required (body or cookie), token/session validity enforced in service. | -| `POST /auth/logout` | Public | Intentionally public so expired access-token clients can still revoke via refresh token. | -| `POST /auth/logout/current` | Authenticated | Refresh token must belong to current authenticated user/session. | -| `POST /auth/logout/all` | Authenticated | Revokes all sessions for authenticated user only. | -| `GET /auth/sessions` | Authenticated | Lists sessions for authenticated user only. | -| `DELETE /auth/sessions/:sid` | Authenticated | Revokes only sessions belonging to authenticated user. | -| `POST /auth/otp/send` | Authenticated | OTP issued only for authenticated user account. | -| `POST /auth/otp/verify` | Authenticated | OTP verification + attempt throttling bound to authenticated user. | -| `GET /auth/me` | Authenticated | Returns authenticated user profile envelope. | -| `POST /auth/google/callback` | Public | Service handles login/register flow based on Google identity + optional role. | - ---- - -## Listings routes (`/listings`) - -| Endpoint | Route-level auth | Service-level auth/authorization notes | -| -------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------ | -| `GET /listings` | Optional auth | Guest users are capped by `guestListingGate`; compatibility fields require auth + preferences context. | -| `GET /listings/:listingId` | Optional auth | Public read; service increments view count asynchronously. | -| `POST /listings` | Authenticated | Listing type allowed by caller role (`student_room` vs PG-owner-only types). | -| `PUT /listings/:listingId` | Authenticated | Ownership and listing-type/location constraints enforced in service. | -| `DELETE /listings/:listingId` | Authenticated | Owner-only soft delete in service. | -| `PATCH /listings/:listingId/status` | Authenticated | Owner-only + transition rules enforced in service. | -| `GET /listings/:listingId/preferences` | Authenticated | Read requires authenticated caller; listing existence checks in service. | -| `PUT /listings/:listingId/preferences` | Authenticated | Owner-only update behavior in service. | -| `POST /listings/:listingId/save` | Authenticated + role `student` | Student-only save behavior; listing state checks in service. | -| `DELETE /listings/:listingId/save` | Authenticated + role `student` | Student-only unsave behavior. | -| `GET /listings/me/saved` | Authenticated + role `student` | Student-only saved feed for caller identity. | -| `GET /listings/:listingId/photos` | Authenticated | Read requires auth (not public listing parity). | -| `POST /listings/:listingId/photos` | Authenticated | Ownership enforced in photo service. | -| `DELETE /listings/:listingId/photos/:photoId` | Authenticated | Ownership + photo existence enforced in photo service. | -| `PATCH /listings/:listingId/photos/:photoId/cover` | Authenticated | Ownership + non-processing photo checks enforced in photo service. | -| `PUT /listings/:listingId/photos/reorder` | Authenticated | Ownership + full-set reorder invariants enforced in photo service. | -| `POST /listings/:listingId/interests` | Authenticated + role `student` | Student cannot interest own listing; duplicate/state gates in service. | -| `GET /listings/:listingId/interests` | Authenticated | Service enforces listing ownership (`403`) vs not found (`404`). | - ---- - -## Interests routes (`/interests`) - -| Endpoint | Route-level auth | Service-level auth/authorization notes | -| ------------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------- | -| `GET /interests/me` | Authenticated + role `student` | Sender dashboard only. | -| `GET /interests/:interestId` | Authenticated | Sender/poster party check in service; outsiders receive privacy-preserving `404`. | -| `PATCH /interests/:interestId/status` | Authenticated | Party-only transitions in service (poster accept/decline, student withdraw). Non-parties get `404`. | - ---- - -## Connections routes (`/connections`) - -| Endpoint | Route-level auth | Service-level auth/authorization notes | -| ----------------------------------------- | ---------------- | -------------------------------------------------------------------------------- | -| `GET /connections/me` | Authenticated | Feed scoped to caller as initiator or counterpart. | -| `GET /connections/:connectionId` | Authenticated | Party-only access in service; outsiders receive `404`. | -| `POST /connections/:connectionId/confirm` | Authenticated | Party-only confirmation; service determines caller side and transition validity. | - ---- - -## Notifications routes (`/notifications`) - -| Endpoint | Route-level auth | Service-level auth/authorization notes | -| --------------------------------- | ---------------- | ------------------------------------------------- | -| `GET /notifications` | Authenticated | Feed is always scoped to authenticated recipient. | -| `GET /notifications/unread-count` | Authenticated | Count is scoped to authenticated recipient. | -| `POST /notifications/mark-read` | Authenticated | Updates only caller-owned notification rows. | - ---- - -## Preferences routes (`/preferences`) - -| Endpoint | Route-level auth | Service-level auth/authorization notes | -| ----------------------- | ---------------- | ---------------------------------------------------------------- | -| `GET /preferences/meta` | Authenticated | Metadata read requires authentication in current implementation. | - ---- - -## Student routes (`/students`) - -| Endpoint | Route-level auth | Service-level auth/authorization notes | -| -------------------------------------- | ----------------------------------- | ----------------------------------------------------------------------- | -| `GET /students/:userId/profile` | Authenticated | Own profile includes private fields; others receive redacted fields. | -| `PUT /students/:userId/profile` | Authenticated | Caller can update only own profile (`403` otherwise). | -| `GET /students/:userId/contact/reveal` | Optional auth + `contactRevealGate` | Gate controls guest/unverified quotas and verified full-contact access. | -| `GET /students/:userId/preferences` | Authenticated | Caller/target checks enforced in service contract. | -| `PUT /students/:userId/preferences` | Authenticated | Caller/target checks enforced in service contract. | - ---- - -## PG owner routes (`/pg-owners`) - -| Endpoint | Route-level auth | Service-level auth/authorization notes | -| ---------------------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------- | -| `GET /pg-owners/:userId/profile` | Authenticated | Read available to authenticated users; private fields behavior in service. | -| `POST /pg-owners/:userId/contact/reveal` | Optional auth + `contactRevealGate` | Gate controls quota + full-contact eligibility; `Cache-Control: no-store` applied. | -| `PUT /pg-owners/:userId/profile` | Authenticated + role `pg_owner` | Owner profile update limited to authenticated PG owner identity. | -| `POST /pg-owners/:userId/documents` | Authenticated + role `pg_owner` | Verification submission restricted to own PG owner account; service enforces lifecycle checks. | - ---- - -## Property routes (`/properties`) - -| Endpoint | Route-level auth | Service-level auth/authorization notes | -| -------------------------------- | ------------------------------- | ----------------------------------------------------------------- | -| `GET /properties/:propertyId` | Authenticated | Any authenticated user may view detail; not ownership-restricted. | -| `GET /properties` | Authenticated + role `pg_owner` | Owner management feed; verification/ownership checks in service. | -| `POST /properties` | Authenticated + role `pg_owner` | Verified PG owner required in service. | -| `PUT /properties/:propertyId` | Authenticated + role `pg_owner` | Owner-only modification enforced in service. | -| `DELETE /properties/:propertyId` | Authenticated + role `pg_owner` | Owner-only delete enforced in service. | - ---- - -## Ratings routes (`/ratings`) - -| Endpoint | Route-level auth | Service-level auth/authorization notes | -| --------------------------------------- | --------------------- | -------------------------------------------------------------------------- | -| `GET /ratings/me/given` | Authenticated | Caller-scoped given-ratings feed. | -| `GET /ratings/user/:userId` | Public (rate-limited) | Public profile reputation read. | -| `GET /ratings/property/:propertyId` | Public (rate-limited) | Public property reputation read. | -| `GET /ratings/connection/:connectionId` | Authenticated | Party checks in service (`404` privacy-preserving for outsiders). | -| `POST /ratings` | Authenticated | Service enforces connection status + party rules + duplicate constraints. | -| `POST /ratings/:ratingId/report` | Authenticated | Service enforces reporter party membership and duplicate-open-report rule. | - ---- - -## Health route (`/health`) - -| Endpoint | Route-level auth | Service-level auth/authorization notes | -| ------------- | ---------------- | ---------------------------------------------------- | -| `GET /health` | Public | No auth required; operational dependency probe only. | - ---- diff --git a/docs/api/connections.md b/docs/api/connections.md deleted file mode 100644 index c33bad8..0000000 --- a/docs/api/connections.md +++ /dev/null @@ -1,177 +0,0 @@ -# Connections API - -Shared conventions: [conventions.md](./conventions.md) - -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 e930612..0000000 --- a/docs/api/conventions.md +++ /dev/null @@ -1,387 +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. - -## Auth Transport Header Convention - -Auth endpoints support two client transport modes: - -- default cookie mode (no special header) -- bearer mode via `X-Client-Transport: bearer` - -When bearer mode is requested on auth endpoints, token responses include `accessToken` and `refreshToken` in JSON. -Without that header, browser-safe cookie mode is used and token strings are not exposed in the response body. - -## Money Units Convention - -- Database storage uses paise (integer). -- Public API request/response payloads use rupees (integer). - -Example: `rent_per_month = 950000` in the database corresponds to `rentPerMonth = 9500` in the API. - -## 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" -} -``` - -## Endpoint-specific Rate Limits - -Use these values for client retry behavior and UX copy: - -- `authLimiter`: **10 requests / 15 minutes** (`/auth/register`, `/auth/login`, `/auth/refresh`, `/auth/logout/all`, - Google auth endpoints). -- `otpLimiter`: **5 requests / 15 minutes** (`/auth/otp/send`). -- OTP verify IP throttle: **20 attempts / 15 minutes per IP** (`/auth/otp/verify`). -- `publicRatingsLimiter`: **120 requests / 15 minutes** (public ratings reads). - -## 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" - } - ] -} -``` - -### Cursor pairing enforcement - -All keyset-paginated endpoints enforce that `cursorTime` and `cursorId` must be supplied together or both omitted. -Validation runs before controller logic. - -This rule applies to: - -- `GET /listings` -- `GET /listings/:listingId/interests` -- `GET /interests/me` -- `GET /notifications` -- `GET /ratings/user/:userId` -- `GET /ratings/property/:propertyId` -- `GET /ratings/me/given` -- `GET /ratings/connection/:connectionId` -- `GET /connections/me` -- `GET /properties` -- `GET /listings/me/saved` - -## 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` -- Alternate session ID: `bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb` -- Student profile ID: `bc4cc5f8-93cb-4700-80f4-4f404410242f` -- PG owner profile ID: `abf62d6a-2783-4dd1-a808-72d758fb18da` -- Institution ID: `a11f71df-dbc0-4b7c-a9a8-9718eebdd9d1` -- Verification request ID: `c478b4c9-f4cf-4d58-b577-c5bca50d6f34` -- Amenity IDs: `2db2f8fc-d90c-47a1-aebb-c6fa9ea4450a`, `eec6f390-2906-4d50-bf26-4f937833c6f8` -- Photo IDs: `6fd7cf0b-d6a7-4f31-bd0a-fdbcf5a5ef2e`, `bcf8f73b-b1bd-4e30-9a7e-a82a18252d28` -- Notification IDs: `31f907ca-df84-4f9d-9c4a-fd67eb1dfe7d`, `efb6d828-3725-4caf-aa15-5a8ec749f590` - -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/frontend-type-guide.md b/docs/api/frontend-type-guide.md deleted file mode 100644 index 813c330..0000000 --- a/docs/api/frontend-type-guide.md +++ /dev/null @@ -1,112 +0,0 @@ -# Frontend Type Safety Guide (TypeScript + Zod) - -This guide is for frontend teams generating/maintaining strong API types. - -It complements endpoint docs by focusing on **type modeling decisions**: - -- which fields are stable contract fields, -- where casing is mixed, -- where enums come from SQL schema, -- and where optional-auth / privacy behaviors affect client type unions. - -## Source-of-truth order for frontend types - -When in doubt, model from these layers in this order: - -1. `src/routes/*` for mounted endpoint surface and auth middleware. -2. `src/validators/*` for request shape/requiredness. -3. `src/services/*` for response shape and business-rule branches. -4. `migrations/*.sql` for enum domains and persisted state values. - -## Scalar contract conventions - -- IDs are UUID strings. -- Timestamps are ISO strings in API responses. -- Money in API payloads is rupees (integer); DB stores paise. -- Pagination uses keyset cursors: `{ cursorTime: string, cursorId: string } | null`. - -## Casing expectations - -Most API response payloads are camelCase in docs. - -Known exception to model explicitly: - -- `GET /listings/me/saved` currently returns mixed casing (legacy snake_case SQL fields + camelCase money fields). - -For TypeScript apps, prefer defining a dedicated DTO type for this endpoint instead of reusing generic `ListingCard`. - -## SQL enum domains you should mirror in frontend types - -These enums are defined in schema migrations and are useful as `z.enum([...])` or string-union TS types. - -### Account / identity enums - -- `account_status_enum`: `active | suspended | banned | deactivated` -- `role_enum`: `student | pg_owner | admin` -- `gender_enum`: `male | female | other | prefer_not_to_say` -- `verification_status_enum`: `unverified | pending | verified | rejected` - -### Listing / property enums - -- `listing_type_enum`: `student_room | pg_room | hostel_bed` -- `room_type_enum`: `single | double | triple | entire_flat` -- `bed_type_enum`: `single_bed | double_bed | bunk_bed` -- `listing_status_enum`: `active | filled | expired | deactivated` -- `property_type_enum`: `pg | hostel | shared_apartment` -- `property_status_enum`: `active | inactive | under_review` - -### Trust / workflow enums - -- `request_status_enum`: `pending | accepted | declined | withdrawn | expired` -- `confirmation_status_enum`: `pending | confirmed | denied | expired` -- `connection_type_enum`: `student_roommate | pg_stay | hostel_stay | visit_only` -- `reviewee_type_enum`: `user | property` -- `report_reason_enum`: `fake | abusive | conflict_of_interest | other` -- `report_status_enum`: `open | resolved_removed | resolved_kept` -- `document_type_enum`: `property_document | rental_agreement | owner_id | trade_license` -- `amenity_category_enum`: `utility | safety | comfort` - -### Notification enums - -- `notification_type_enum` initial values are defined in migration 001. -- Migration 002 adds `verification_pending`. - -Frontend recommendation: treat notification `type` as a discriminated union that includes `verification_pending`, even -if UI behavior is currently email-only for that event. - -## Optional-auth endpoints: model as unions - -For endpoints with `optionalAuthenticate`, clients should not assume invalid tokens produce `401`. - -Recommended modeling: - -- Request can be made with or without token. -- Response may still be success guest-shape even if token is stale/invalid. -- Client state machine should support `authenticated-success` and `guest-success` for the same endpoint. - -Primary affected endpoints: - -- `GET /listings` -- `GET /listings/:listingId` -- `GET /students/:userId/contact/reveal` -- `POST /pg-owners/:userId/contact/reveal` - -## Suggested frontend package structure - -- `api/types/common.ts` - - `UUID`, `ISODateTimeString`, `Cursor` -- `api/types/enums.ts` - - all SQL-backed string unions / Zod enums -- `api/types/auth.ts`, `listings.ts`, `interests.ts`, ... - - endpoint DTOs per feature -- `api/types/legacy.ts` - - mixed-case DTOs (for transitional endpoints like saved listings) -- `api/zod/*` - - runtime response parsers for high-risk endpoints - -## High-value guardrails for TS + Zod teams - -- Parse all cursor responses with Zod before writing pagination state. -- Keep endpoint-specific DTOs where response casing diverges. -- Use discriminated unions for privacy-preserving errors (`404` as not-found-or-not-allowed). -- Keep notification type unions synced with migrations when new enum labels are added. diff --git a/docs/api/health.md b/docs/api/health.md deleted file mode 100644 index a38e33f..0000000 --- a/docs/api/health.md +++ /dev/null @@ -1,129 +0,0 @@ -# Health API - -Shared conventions: [conventions.md](./conventions.md) - -## 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 - -### Scenario: all services healthy - -Status: `200` - -```json -{ - "status": "ok", - "timestamp": "2026-04-11T09:40:00.000Z", - "services": { - "database": "ok", - "redis": "ok" - } -} -``` - -### Scenario: database degraded - -Status: `503` - -```json -{ - "status": "degraded", - "timestamp": "2026-04-11T09:40:00.000Z", - "services": { - "database": "unhealthy", - "redis": "ok" - } -} -``` - -### Scenario: database timed out - -Status: `503` - -```json -{ - "status": "degraded", - "timestamp": "2026-04-11T09:40:00.000Z", - "services": { - "database": "timeout", - "redis": "ok" - } -} -``` - -### Scenario: redis degraded - -Status: `503` - -```json -{ - "status": "degraded", - "timestamp": "2026-04-11T09:40:00.000Z", - "services": { - "database": "ok", - "redis": "unhealthy" - } -} -``` - -### Scenario: redis timed out - -Status: `503` - -```json -{ - "status": "degraded", - "timestamp": "2026-04-11T09:40:00.000Z", - "services": { - "database": "ok", - "redis": "timeout" - } -} -``` - -### Scenario: 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 c53f7ee..0000000 --- a/docs/api/interests.md +++ /dev/null @@ -1,472 +0,0 @@ -# Interest Requests API - -Shared conventions: [conventions.md](./conventions.md) - -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 - -Body is optional. You can send no request body at all, or send `{}`. - -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, and non-parties always get `404` (not `403`). - -## `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 - -`whatsappLink` can be `null` when the poster has no phone number on file; this is not an error and does not block acceptance. - -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 bca3eef..0000000 --- a/docs/api/listings.api.md +++ /dev/null @@ -1,1263 +0,0 @@ -# Listings API - -Shared conventions: [conventions.md](./conventions.md) - -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 (server silently caps higher `limit`) | ❌ 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. - -Optional-auth exception: on these two read endpoints, invalid/expired bearer tokens are treated as guest access (the -request is not rejected with `401` by `optionalAuthenticate`). - -### Exact middleware chain for read endpoints - -`GET /listings`: - -`optionalAuthenticate -> validate(searchListingsSchema) -> guestListingGate -> listingController.searchListings` - -`GET /listings/:listingId`: - -`optionalAuthenticate -> validate(listingParamsSchema) -> guestListingGate -> listingController.getListing` - -`validate` runs before `guestListingGate` intentionally, so Zod-coerced numeric `limit` is available when the gate reads -it. - -`guestListingGate` behavior: - -- Authenticated users (`req.user` set): passes through unchanged -- Guests: silently caps `req.query.limit` to `20` only when requested value exceeds `20` -- Does not block browsing, does not touch Redis, does not count requests - -## 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. - -Compatibility semantics for authenticated callers: - -- `compatibilityAvailable = false` means either side has no saved preferences, so no comparison was possible. -- `compatibilityAvailable = true` with `compatibilityScore = 0` means comparison happened, but no preferences matched. - -#### 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 (fire-and-forget side effect). Avoid polling -this endpoint for background status checks. - -#### 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. - -Integration note: this endpoint currently returns a mixed casing payload (legacy `snake_case` fields from SQL rows plus -camelCase rent/deposit fields). Treat the example response as authoritative until the response is normalized in code. - -#### 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": { - "cursorTime": "2026-04-11T10:30:00.000Z", - "cursorId": "55555555-5555-4555-8555-555555555555" - } - } -} -``` - -## Photos - -Photo uploads are asynchronous. The HTTP request inserts a provisional row and queues a worker job. Clients should poll -`GET /listings/:listingId/photos`. Use this endpoint (not listing detail polling) to avoid inflating `views_count`. - -**All photo endpoints require authentication.** - -### `GET /listings/:listingId/photos` - -Returns completed photos only. Processing placeholders are hidden (`photo_url LIKE 'processing:%'` rows are filtered out -until processing completes). - -#### 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`. - -#### Middleware chain - -`authenticate -> upload.single("photo") -> validate(uploadPhotoSchema) -> photoController.uploadPhoto` - -#### Multipart requirements - -- field name: `photo` -- accepted MIME types: `image/jpeg`, `image/png`, `image/webp` -- max size: `10MB` -- extension must match declared MIME type (cross-checked by upload middleware) -- staged file path: `uploads/staging/{uuid}.ext` - -#### Service flow (`src/services/photo.service.js` -> `enqueuePhotoUpload`) - -1. Acquires row lock on listing (`SELECT ... FOR UPDATE`) while validating ownership -2. Allocates `display_order` server-side via `SELECT COALESCE(MAX(display_order), -1) + 1` -3. Inserts provisional row with `photo_url = 'processing:{photoId}'` -4. Commits transaction -5. Enqueues BullMQ job on `media-processing` queue (`process-photo`) - -#### Queue failure path - -If queue enqueue fails after DB commit (for example Redis unavailable): - -- provisional `listing_photos` row is soft-deleted -- endpoint returns `503` with retryable message - -#### Async processing (`src/workers/mediaProcessor.js`, concurrency: 1) - -- Sharp resize to max `1200x1200`, convert WebP quality 80, strip EXIF metadata -- Upload to Azure Blob via storage adapter (30s timeout) -- Update `listing_photos.photo_url` with final URL -- Elect cover photo if none exists -- Delete staging file (best-effort) - -#### 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 074a3aa..0000000 --- a/docs/api/notifications.md +++ /dev/null @@ -1,257 +0,0 @@ -# Notifications API - -Notifications are created asynchronously by the worker. The HTTP API only reads them and marks them as read. - -Shared conventions: [conventions.md](./conventions.md) - -## Notification types (worker source of truth) - -The `NOTIFICATION_MESSAGES` map in `src/workers/notificationWorker.js` is authoritative. - -| Type | Message | Status | -| ---------------------------- | -------------------------------------------------- | ------------------------------------------- | -| `interest_request_received` | Someone expressed interest in your listing | ACTIVE | -| `interest_request_accepted` | Your interest request was accepted | ACTIVE | -| `interest_request_declined` | Your interest request was declined | ACTIVE | -| `interest_request_withdrawn` | An interest request was withdrawn | ACTIVE | -| `connection_confirmed` | Your connection has been confirmed by both parties | ACTIVE | -| `rating_received` | You received a new rating | ACTIVE | -| `listing_expiring` | One of your listings is expiring soon | ACTIVE | -| `listing_expired` | One of your listings has expired | ACTIVE | -| `listing_filled` | A listing has been marked as filled | ACTIVE | -| `verification_approved` | Your verification request was approved | ACTIVE (in-app + email) | -| `verification_rejected` | Your verification request was rejected | ACTIVE (in-app + email) | -| `verification_pending` | We received your verification documents | ACTIVE (email only, no in-app notification) | -| `new_message` | You have a new message | PLANNED — no emitter | -| `connection_requested` | You have a new connection request | PLANNED — no emitter | - -### Delivery pipeline for verification events - -Verification notifications are not triggered directly by the API controller. They are driven by a CDC (Change Data -Capture) outbox pattern: - -1. Admin calls `POST /admin/verification-queue/:requestId/approve|reject` -2. `verification.service.js` commits `UPDATE verification_requests SET status = 'verified'|'rejected'|'pending'` -3. Postgres trigger `trg_verification_status_changed` (migration `002`) writes to `verification_event_outbox` -4. `src/workers/verificationEventWorker.js` polls the outbox every 5 seconds using `SELECT ... FOR UPDATE SKIP LOCKED` -5. On each event, the worker calls: - - `enqueueNotification()` -> `notification-delivery` BullMQ queue -> in-app notification row - - `enqueueEmail()` -> `email-delivery` BullMQ queue -> Brevo REST/SMTP email -6. `verification_pending` emits email only (no `enqueueNotification` call in source) - -This means verification notifications can originate from direct SQL on the database (not just the API), as long as the -trigger fires. The worker handles retry with up to `MAX_ATTEMPTS = 5` retries per event. - -## `GET /notifications` - -Returns the authenticated user's notification feed. - -### Request Contract - -- Auth required: Yes -- Query params: - - `isRead` - - `limit` - - `cursorTime` - - `cursorId` - -### Query parameter details - -`isRead` (optional boolean filter): - -- String: `"true"` or `"false"` (case-insensitive: `"TRUE"`, `"False"` also work) -- Numeric: `0` (false) or `1` (true) -- Omit entirely to receive all notifications (read + unread) - -Values like `"yes"`, `"1"` (string), and `2` are rejected with `400 Validation failed`. - -### 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: client sends `{ "all": false }` - -Status: `400` - -```json -{ - "status": "error", - "message": "Validation failed", - "errors": [ - { - "field": "body.all", - "message": "Invalid input: expected true" - } - ] -} -``` - -### 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 3761d2c..0000000 --- a/docs/api/preferences.md +++ /dev/null @@ -1,203 +0,0 @@ -# Preferences API - -Shared conventions: [conventions.md](./conventions.md) - -`user_preferences` is optional. Empty preference state is represented by `[]` (never `null`). - -## `GET /preferences/meta` - -Returns the current preference catalog (`preferenceMetadata`). - -### 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" } - ] - }, - { - "preferenceKey": "food_habit", - "label": "Food Habit", - "values": [ - { "value": "vegetarian", "label": "Vegetarian" }, - { "value": "non_vegetarian", "label": "Non-vegetarian" }, - { "value": "vegan", "label": "Vegan" } - ] - }, - { - "preferenceKey": "sleep_schedule", - "label": "Sleep Schedule", - "values": [ - { "value": "early_bird", "label": "Early bird" }, - { "value": "night_owl", "label": "Night owl" } - ] - }, - { - "preferenceKey": "alcohol", - "label": "Alcohol", - "values": [ - { "value": "okay", "label": "Okay" }, - { "value": "not_okay", "label": "Not okay" } - ] - }, - { - "preferenceKey": "cleanliness_level", - "label": "Cleanliness Level", - "values": [ - { "value": "low", "label": "Low" }, - { "value": "medium", "label": "Medium" }, - { "value": "high", "label": "High" } - ] - }, - { - "preferenceKey": "noise_tolerance", - "label": "Noise Tolerance", - "values": [ - { "value": "low", "label": "Low" }, - { "value": "medium", "label": "Medium" }, - { "value": "high", "label": "High" } - ] - }, - { - "preferenceKey": "guest_policy", - "label": "Guest Policy", - "values": [ - { "value": "rarely", "label": "Rarely" }, - { "value": "occasionally", "label": "Occasionally" }, - { "value": "frequently", "label": "Frequently" } - ] - } - ] - } -} -``` - -## `GET /students/:userId/preferences` - -Owner-only read of the student's current profile preferences. - -### Request Contract - -- Auth required: Yes -- Owner-only: `req.user.userId` must match `:userId` - -### Scenario: no preferences configured - -Status: `200` - -```json -{ - "status": "success", - "data": [] -} -``` - -### Scenario: preferences exist - -Status: `200` - -```json -{ - "status": "success", - "data": [ - { "preferenceKey": "food_habit", "preferenceValue": "vegetarian" }, - { "preferenceKey": "smoking", "preferenceValue": "non_smoker" } - ] -} -``` - -### Scenario: caller reads another user's preferences - -Status: `403` - -```json -{ - "status": "error", - "message": "Forbidden" -} -``` - -## `PUT /students/:userId/preferences` - -Full replace semantics. - -- Empty `preferences` clears all rows. -- Duplicate keys are deduped by `dedupePreferencesByKey` with **last-write-wins**. - -### Scenario: update with unique keys - -Status: `200` - -```json -{ - "status": "success", - "data": [ - { "preferenceKey": "sleep_schedule", "preferenceValue": "early_bird" }, - { "preferenceKey": "smoking", "preferenceValue": "non_smoker" } - ] -} -``` - -### Scenario: duplicate preference key submitted — last value wins, no error - -Request: - -```json -{ - "preferences": [ - { "preferenceKey": "smoking", "preferenceValue": "non_smoker" }, - { "preferenceKey": "smoking", "preferenceValue": "smoker" } - ] -} -``` - -Status: `200` - -```json -{ - "status": "success", - "data": [{ "preferenceKey": "smoking", "preferenceValue": "smoker" }] -} -``` - -### Scenario: clear all - -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'" - } - ] -} -``` diff --git a/docs/api/profiles-and-contact.md b/docs/api/profiles-and-contact.md deleted file mode 100644 index 353aa6b..0000000 --- a/docs/api/profiles-and-contact.md +++ /dev/null @@ -1,593 +0,0 @@ -# Profiles and Contact Reveal API - -Shared conventions: [conventions.md](./conventions.md) - -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 -- Optional-auth exception: invalid/expired tokens fall back to guest/unverified behavior instead of returning `401` from auth middleware. -- 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 receives full contact bundle (service-enforced behavior) - -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 -- Optional-auth exception: invalid/expired tokens fall back to guest/unverified behavior instead of returning `401` from auth middleware. -- Auth transport accepted: - - Cookie mode (`accessToken` cookie) - - Bearer mode (`Authorization: Bearer `) -- Quota-gated for guests and unverified users -- 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 1fc974d..0000000 --- a/docs/api/properties.md +++ /dev/null @@ -1,315 +0,0 @@ -# Properties API - -Shared conventions: [conventions.md](./conventions.md) - -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 84d3814..0000000 --- a/docs/api/ratings-and-reports.md +++ /dev/null @@ -1,436 +0,0 @@ -# Ratings and Reports API - -Shared conventions: [conventions.md](./conventions.md) - -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 deleted file mode 100644 index f117497..0000000 --- a/docs/deployment/tier0.md +++ /dev/null @@ -1,911 +0,0 @@ -# Tier 0 Deployment Guide — Roomies Backend - -## Render (Singapore) + Neon + Upstash + Azure Blob + Brevo API - -> **Status:** Your production starting point. Everything here is free or always-free. **Audience:** Step-by-step for -> 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. - ---- - -## Read This First — What You're Actually Doing - -Before touching any dashboard, understand the big picture. You are deploying one Node.js process on Render's free tier. -That process connects to four external services: a PostgreSQL database (Neon), a Redis cache (Upstash), a file storage -bucket (Azure Blob), and an email provider (Brevo). The code for all of this already exists. Your job is to create -accounts, collect connection strings, point them at each other, and then connect your domain. - -**One critical fact about Render's free tier you must know:** In September 2025, Render blocked outbound SMTP traffic -(ports 25, 465, 587) on all free services. Your current email code uses Nodemailer with SMTP, which means emails will -fail silently on the free tier. The fix is to add a new email provider option that uses Brevo's HTTP REST API (port 443, -never blocked). This is a small but required code change covered in Phase 1 below. - -**One more fact about cold starts:** Render's free tier sleeps your service after 15 minutes of no traffic. The first -request after sleep takes about one minute to respond — Render shows a loading page to the user during this time. This -is the core trade-off of the free tier. It is acceptable for a low-traffic app and acceptable for early users who -understand they're on a new platform. When it becomes unacceptable (users complaining, losing signups), the upgrade path -is in tier1.md. - ---- - -## Phase 0 — Accounts to Create Before Anything Else - -You need five accounts. All are free. None require a credit card except Azure (which you already have). - -**Render** — [render.com](https://render.com) Sign up with GitHub. This is important: use the same GitHub account that -owns your repository. Render connects to GitHub to deploy your code automatically. - -**Neon** — [neon.tech](https://neon.tech) Sign up with GitHub or email. No credit card needed. - -**Upstash** — [upstash.com](https://upstash.com) Sign up with GitHub or email. No credit card needed for the free tier. - -**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. - ---- - -## Phase 1 — Required Code Changes - -These changes must be made to your codebase before you deploy. Without them, emails will not work on Render's free tier. - -### 1.1 Update `src/config/env.js` - -Find the `EMAIL_PROVIDER` line in the `envSchema` object. Change it to include `brevo-api` as a valid option: - -```javascript -// Find this line: -EMAIL_PROVIDER: z.enum(["ethereal", "brevo"], { ... }).default("ethereal"), - -// Change it to: -EMAIL_PROVIDER: z.enum(["ethereal", "brevo", "brevo-api"], { - error: 'EMAIL_PROVIDER must be "ethereal", "brevo", or "brevo-api"', -}).default("ethereal"), -``` - -Then add `BREVO_API_KEY` to the schema (add it near the other Brevo vars): - -```javascript -// Add after BREVO_SMTP_FROM: -BREVO_API_KEY: z.string().min(1).optional(), -``` - -Then add a cross-field guard for `brevo-api` after the existing Brevo guard block: - -```javascript -// 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); - } -} -``` - -### 1.2 Update `src/services/email.service.js` - -Add a new `sendViaBrevoAPI` helper function. This uses Node.js's built-in `fetch` (available since Node 18, you're on -Node 22) — no new npm packages needed. Add this function anywhere in the file before it is used: - -```javascript -// Brevo REST API transport — used when EMAIL_PROVIDER=brevo-api. -// Uses HTTPS (port 443), which is never blocked, unlike SMTP ports. -// Brevo API docs: https://developers.brevo.com/reference/send-transac-email -const sendViaBrevoAPI = async (to, subject, html, text) => { - const maskedTo = maskEmail(to); - logger.info({ to: maskedTo, provider: "brevo-api" }, "Sending email via Brevo REST API"); - - const response = await fetch("https://api.brevo.com/v3/smtp/email", { - method: "POST", - headers: { - "Content-Type": "application/json", - "api-key": config.BREVO_API_KEY, - }, - body: JSON.stringify({ - sender: { name: "Roomies", email: config.BREVO_SMTP_FROM }, - to: [{ email: to }], - subject, - htmlContent: html, - textContent: text, - }), - }); - - if (!response.ok) { - const errorBody = await response.json().catch(() => ({})); - logger.error({ to: maskedTo, status: response.status, error: errorBody }, "Brevo API: email send failed"); - throw new AppError("Failed to send email via Brevo API — try again shortly", 502); - } - - const result = await response.json(); - logger.info({ to: maskedTo, messageId: result.messageId }, "Brevo API: email sent successfully"); - return result.messageId; -}; -``` - -Now update `sendOtpEmail` to route to this new function when `EMAIL_PROVIDER=brevo-api`. Find the `try` block inside -`sendOtpEmail` and add the routing logic at the top: - -```javascript -export const sendOtpEmail = async (to, otp) => { - // ... existing input validation guards stay as-is ... - - // Route to Brevo REST API when SMTP is not available (e.g. Render free tier). - if (config.EMAIL_PROVIDER === "brevo-api") { - return sendViaBrevoAPI( - to, - "Your Roomies verification code", - /* html — reuse the same HTML string from below */ - buildOtpHtml(otp), - `Your Roomies verification code is: ${otp}\n\nThis code expires in 10 minutes.`, - ); - } - - // ... rest of the existing function with Nodemailer transport ... -``` - -To avoid duplicating the HTML, extract it into a helper: - -```javascript -// Add this helper above sendOtpEmail: -const buildOtpHtml = (otp) => ` - - -`; -``` - -Then replace the literal HTML string inside `sendOtpEmail` with `buildOtpHtml(otp)`. - -Do the same routing for `sendVerificationApprovedEmail`, `sendVerificationRejectedEmail`, and -`sendVerificationPendingEmail` — each needs an early-return branch for `brevo-api` that calls `sendViaBrevoAPI` with the -appropriate subject, HTML, and text content. The HTML for each already exists in the file; just move it to a -`buildVerificationApprovedHtml(ownerName, businessName)` style helper and call `sendViaBrevoAPI` when the provider is -`brevo-api`. - -### 1.3 Update `package.json` - -Remove the migration scripts (you run migrations directly with psql, not through npm) and add a `dev:render` script for -local testing against your live Tier 0 services: - -```json -{ - "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", - "test": "node --experimental-vm-modules node_modules/.bin/jest" - } -} -``` - -The five lines removed are: `migrate`, `migrate:azure`, `migrate:status`, `migrate:status:azure`, and `migrate:dry-run`. -The file `src/db/migrate.js` stays — it can still be run directly with `node src/db/migrate.js` if you ever need it. - -### 1.4 Update `.gitignore` - -Make sure `.env.render` is listed so you never accidentally commit it: - -``` -# Environment files — never commit -.env -.env.local -.env.azure -.env.render -.env.*.local -``` - -Commit all of these changes to your `main` branch before proceeding. Render will deploy from `main`, so the code must be -there. - ---- - -## Phase 2 — Neon PostgreSQL Setup - -Neon is a serverless PostgreSQL service. "Serverless" means the compute (CPU that processes queries) shuts down when no -queries have arrived for a few minutes and starts back up when one does. The data itself is always stored safely. This -cold-start for the database adds about 300–500ms to your first query after idle. During active use (queries coming in -every few seconds from BullMQ workers), Neon stays warm. - -### 2.1 Create Your Neon Project - -Go to [console.neon.tech](https://console.neon.tech) and sign in. - -Click **New Project** in the top-right corner. Fill in: - -- **Project name:** `roomies` -- **Postgres version:** select **16** from the dropdown (your schema requires 16) -- **Region:** Select **AWS Asia Pacific (Singapore)** — this is the closest AWS region to India and matches your Render - region, which minimises latency between your app and your database. -- **Database name:** leave as `neondb` (you can rename it, but keeping the default is simpler) - -Click **Create Project**. Neon will show you a connection string immediately. **Copy it and store it somewhere safe** — -you will need it multiple times. - -The connection string looks like this: - -``` -postgresql://neondb_owner:SOME_LONG_PASSWORD@ep-SOMETHING-12345678.ap-southeast-1.aws.neon.tech/neondb?sslmode=require -``` - -Everything in this string matters. Do not modify it. - -### 2.2 Enable PostGIS and pgcrypto - -Your schema requires two PostgreSQL extensions: `postgis` (for proximity search based on latitude/longitude) and -`pgcrypto` (for generating UUIDs as primary keys). You need to enable them before running the schema. - -In the Neon dashboard, click on your project → click **SQL Editor** in the left sidebar. - -Run these two commands one at a time (you can paste both at once and click Run): - -```sql -CREATE EXTENSION IF NOT EXISTS postgis; -CREATE EXTENSION IF NOT EXISTS pgcrypto; -``` - -You should see `CREATE EXTENSION` in the result. Verify they worked: - -```sql -SELECT name, installed_version -FROM pg_extension -WHERE name IN ('postgis', 'pgcrypto'); -``` - -You should see two rows. If you see `postgis` with a version like `3.5.2`, you are ready. Also verify spatial functions -work: - -```sql -SELECT ST_AsText(ST_MakePoint(77.21, 28.63)); --- Should return: POINT(77.21 28.63) -``` - -### 2.3 Run Your Schema Migrations - -On your local machine, open your terminal inside your project directory. You will connect to the Neon database and run -your SQL migration files. You need `psql` installed — check with `psql --version`. If not installed: -`sudo apt install postgresql-client`. - -Set your Neon connection string as a temporary environment variable to avoid typing it repeatedly: - -```bash -export NEON_URL="postgresql://neondb_owner:YOUR_PASSWORD@ep-YOUR-ENDPOINT.ap-southeast-1.aws.neon.tech/neondb?sslmode=require" -``` - -Replace the entire string with your actual connection string from step 2.1. - -Now run the migrations in order: - -```bash -# Migration 1: initial schema (creates all tables, indexes, triggers) -psql "$NEON_URL" -f migrations/001_initial_schema.sql - -# Watch the output. You should see lines like: -# CREATE TABLE -# CREATE INDEX -# CREATE TRIGGER -# If you see any ERROR lines, stop and fix them before proceeding. - -# Migration 2: verification event outbox (CDC pipeline for email notifications) -psql "$NEON_URL" -f migrations/002_verification_event_outbox.sql -``` - -If both succeed without errors, verify the tables were created: - -```bash -psql "$NEON_URL" -c "\dt" -# Should list all your tables: users, student_profiles, listings, etc. -``` - -### 2.4 Seed Amenities - -You need to seed the amenities table before the app can create listings. First, create a temporary `.env.render` file -(you'll fill it out fully in Phase 7, but for the seed you just need the DB URL). Create the file with just this line to -test: - -```bash -# Temporary - just enough for the seed -echo "DATABASE_URL=$NEON_URL" > .env.render.tmp -ENV_FILE=.env.render.tmp node src/db/seeds/amenities.js -rm .env.render.tmp -``` - -You should see `Amenity seed complete` with `inserted: 19` in the output. - ---- - -## Phase 3 — Upstash Redis Setup - -Upstash provides managed Redis with a simple HTTP-over-TLS interface. Your existing `bullConnection.js` and -`cache/client.js` already handle `rediss://` TLS URLs, so no code changes are needed. - -### 3.1 Create Your Redis Database - -Go to [console.upstash.com](https://console.upstash.com) and sign in. - -Click **Create database**. Fill in: - -- **Name:** `roomies-redis` -- **Type:** **Regional** (not Global — you don't need multi-region replication at this stage) -- **Region:** **AWS ap-southeast-1 (Singapore)** — same region as Neon and Render, so all three services communicate - within the same data centre -- **Plan:** **Free** — start here -- **Enable TLS:** leave **On** (required — your code uses `rediss://` which requires TLS) - -Click **Create**. The database will be ready in about 30 seconds. - -### 3.2 Get the Redis URL - -On your database overview page, find the **REST API** section. But you don't want the REST URL — you want the Redis URL -for the standard Redis protocol. Look for: - -- **Endpoint:** something like `definite-robin-12345.upstash.io` -- **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:6379 -``` - -Note the double `s` in `rediss://` — this signals TLS. The username is always `default` for Upstash. - -You can also find this pre-formatted in the Upstash console. Look for **"Connection String"** or click the **Connect** -button — it will show the URL in various formats. Copy the one starting with `rediss://`. - -### 3.3 Monitor Your Command Usage - -This is important. The free tier allows 500,000 Redis commands per month. BullMQ uses Redis commands constantly while -your server is awake. To understand your usage: - -Go to your Upstash database → **Analytics** tab. You will see a chart of daily commands. Check this weekly for the first -month. If you see usage approaching 400,000 commands/month, move to Phase 3 of tier1.md (the Upstash Fixed plan) before -you hit the cap. A hard stop at 500,000 will cause BullMQ to stop processing jobs entirely. - -If your server is sleeping most of the time (no UptimeRobot pings), you should comfortably stay under 500,000 -commands/month. - ---- - -## Phase 4 — Azure Blob Storage Setup - -Blob Storage is where your listing photos are stored. The always-free tier gives you 5 GB of Standard LRS storage, which -will cover thousands of listing photos before you need to worry about cost. - -### 4.1 Create a Storage Account (if not done yet) - -Go to [portal.azure.com](https://portal.azure.com) and search for **"Storage accounts"** in the top search bar. Click -**+ Create**. - -On the Basics tab: - -- **Subscription:** Azure for Students -- **Resource group:** create one called `roomies-rg` if it doesn't exist (this keeps all your Azure resources in one - place for easy deletion later) -- **Storage account name:** `roomiesblob` — this must be globally unique across all of Azure, so if it's taken, try - `roomiesblob2` or add your initials -- **Region:** Central India -- **Performance:** Standard -- **Redundancy:** Locally-redundant storage (LRS) — the cheapest, your photos don't need geographic replication - -On the Advanced tab: - -- **Allow Blob anonymous access:** turn this **On** — this is what allows photo URLs to be publicly viewable in a - browser without authentication - -Leave all other tabs as defaults. Click **Review + create**, then **Create**. Deployment takes about 30 seconds. - -### 4.2 Create the Photo Container - -After creation, go to your storage account → find **Containers** in the left sidebar under "Data storage" → click **+ -Container**. - -- **Name:** `roomies-uploads` -- **Public access level:** **Blob (anonymous read access for blobs only)** - -The "blob" level means anyone with a direct photo URL can view the photo, but no one can list all files in the -container. This is exactly what you need — frontend can show photos without authentication, but nobody can scrape your -entire photo library. - -### 4.3 Get the Connection String - -Go to your storage account → **Access keys** in the left sidebar (under "Security + networking") → click **Show** next -to the Connection string under **key1**. - -Copy the entire string. It starts with `DefaultEndpointsProtocol=https;AccountName=roomiesblob;AccountKey=...`. This is -your `AZURE_STORAGE_CONNECTION_STRING`. - ---- - -## Phase 5 — Brevo Email Setup - -As explained in Phase 1, Render's free tier blocks SMTP. You need to use Brevo's REST API instead of their SMTP relay. -The REST API uses port 443 (standard HTTPS) which is always open. - -### 5.1 Get Your Brevo API Key - -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`. - -This is different from your SMTP key (which starts with `xsmtpsib-`). The API key is what the REST API uses. - -### 5.2 Verify Your Sender Address - -Your emails must be sent from a verified sender address. Go to **Settings** → **Senders & Domains** → check that -`sumitly1642@gmail.com` (or whatever you set as `BREVO_SMTP_FROM`) appears as verified. - -If your sender address is not verified, click **Add a sender** and follow the verification steps. You will receive a -verification email at that address. - -**Important for production:** Gmail addresses work fine for testing, but for a professional app, you eventually want to -send from `noreply@roomies.sumitly.app`. You can set this up in Brevo under Senders & Domains → "Add a domain" and -follow their DNS instructions. This can be done later — it doesn't block deployment. - ---- - -## Phase 6 — Local Testing Against Tier 0 Services - -Before deploying to Render, test that your code works against all the live services from your local machine. This -catches configuration problems before they become Render deploy failures. - -### 6.1 Create `.env.render` - -Create this file at your project root. Fill in all the values you collected in phases 2–5: - -```env -# .env.render — local testing against live Tier 0 services -# NEVER COMMIT THIS FILE — it contains your database password and API keys - -NODE_ENV=production -PORT=3000 -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 - -# ─── Upstash Redis (TLS required — note the double-s in rediss://) ───────────── -REDIS_URL=rediss://default:YOUR_UPSTASH_PASSWORD@YOUR-ENDPOINT.upstash.io:6379 - -# ─── JWT ────────────────────────────────────────────────────────────────────── -JWT_SECRET=YOUR_GENERATED_64_CHAR_SECRET -JWT_REFRESH_SECRET=YOUR_OTHER_GENERATED_64_CHAR_SECRET -JWT_EXPIRES_IN=15m -JWT_REFRESH_EXPIRES_IN=7d - -# ─── Azure Blob Storage ──────────────────────────────────────────────────────── -STORAGE_ADAPTER=azure -AZURE_STORAGE_CONNECTION_STRING=DefaultEndpointsProtocol=https;AccountName=roomiesblob;AccountKey=YOUR_KEY;EndpointSuffix=core.windows.net -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 - -# ─── CORS (allow everything for local testing) ──────────────────────────────── -ALLOWED_ORIGINS=http://localhost:5173 - -# ─── Google OAuth ───────────────────────────────────────────────────────────── -GOOGLE_CLIENT_ID=xxxxxxxxxxxxxxxxxxxx -GOOGLE_CLIENT_SECRET=xxxxxxxxxxxxxxxx - -# ─── 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 -node -e "console.log(require('crypto').randomBytes(48).toString('base64'))" -``` - -### 6.2 Run the Local Test - -```bash -npm run dev:render -``` - -Watch the startup output. You should see: - -``` -PostgreSQL connected -Redis connected -Media processing worker started -Notification delivery worker started -Email delivery worker started -Verification event worker started -cron:listingExpiry — registered -cron:expiryWarning — registered -cron:hardDeleteCleanup — registered -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 `:6379`. - -Test the health endpoint: - -```bash -curl http://localhost:3000/api/v1/health -# Expected: {"status":"ok","services":{"database":"ok","redis":"ok"}} -``` - -Test a registration to confirm the full flow: - -```bash -curl -X POST http://localhost:3000/api/v1/auth/register \ - -H "Content-Type: application/json" \ - -d '{"email":"yourtest@example.com","password":"TestPass1","role":"student","fullName":"Test User"}' -# Expected: 201 with a data.user object -``` - -If this works locally against Neon and Upstash, you are ready to deploy to Render. - ---- - -## Phase 7 — Render Web Service Setup - -### 7.1 Connect GitHub and Create the Service - -Go to [dashboard.render.com](https://dashboard.render.com) and sign in with GitHub. - -Click **New** → **Web Service**. - -Click **Connect account** if this is your first time — this grants Render permission to read your GitHub repositories. -Then select your roomies backend repository from the list. - -Fill in the service settings: - -- **Name:** `roomies-api` (this becomes `roomies-api.onrender.com` before you add the custom domain) -- **Region:** `Singapore (Southeast Asia)` — choose this explicitly; it defaults to Oregon which adds 250ms+ latency for - India -- **Branch:** `main` -- **Root Directory:** leave empty (your `package.json` is at the root) -- **Environment:** `Node` -- **Build Command:** `npm install` -- **Start Command:** `node src/server.js` -- **Plan:** `Free` - -Click **Create Web Service**. Render will begin the first deployment. This takes 2–4 minutes the first time. - -### 7.2 Set Environment Variables - -While the first deployment runs (or after it finishes), go to your service page → click **Environment** in the left -sidebar. - -Click **Add Environment Variable** for each of the following. Use the **Secret** checkbox for anything sensitive -(passwords, keys, connection strings) — this encrypts the value and prevents it from showing in logs. - -Add these variables: - -| Variable | Value | Secret? | -| --------------------------------- | ---------------------------------------------------- | ------- | -| `NODE_ENV` | `production` | No | -| `PORT` | `10000` | No | -| `DATABASE_URL` | your Neon connection string | **Yes** | -| `REDIS_URL` | `rediss://default:PASSWORD@ENDPOINT.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 | -| `JWT_REFRESH_EXPIRES_IN` | `7d` | No | -| `STORAGE_ADAPTER` | `azure` | No | -| `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_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` | `http://localhost:5173` (initial) | 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` starts as `http://localhost:5173` during backend-only rollout. After frontend deployment, update it to -production domains (comma-separated), for example: - -`https://roomies.vercel.app,https://www.roomies.in` - -**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. - -After adding all variables, Render will ask if you want to redeploy with the new variables. Click **Save Changes** → -then **Manual Deploy** → **Deploy latest commit** to trigger a fresh deployment with all variables set. - -### 7.3 Watch the Deployment Logs - -Click **Logs** in the left sidebar. You should see the startup sequence. The expected output is identical to what you -saw locally: - -``` -PostgreSQL connected -Redis connected -Media processing worker started -... -Server running on port 10000 [production] -``` - -If you see any errors here, the most common causes are: - -- Environment variable name typo — variable names are case-sensitive; `DATABASE_URL` is not the same as `Database_URL` -- Connection string copied incompletely — make sure there are no line breaks in the middle of a connection string -- Missing `BREVO_API_KEY` while `EMAIL_PROVIDER=brevo-api` — the startup validation will exit with a clear error message - ---- - -## Phase 8 — DNS Configuration on name.com - -This phase connects `api.roomies.sumitly.app` to your Render service. DNS changes propagate across the internet -gradually — most records update within 5–30 minutes, but the full global propagation can take up to 48 hours (in -practice it's usually under an hour for name.com). - -Understanding what you're doing: A DNS record tells the internet "when someone types `api.roomies.sumitly.app`, send -them to this address." You will add a CNAME record, which is an alias — it says "point this name to that other name", -rather than directly pointing to an IP address. CNAME is correct for Render because Render uses a hostname, not a fixed -IP. - -### 8.1 Access Your DNS Settings - -Go to [name.com](https://www.name.com) and log in. - -Click **My Domains** in the top navigation. Find `sumitly.app` in your domain list. Click the three-dot menu (⋮) next to -it → click **Manage Domain**. In the domain management page, find the **DNS** section and click **Manage DNS Records**. - -### 8.2 Add the Backend CNAME Record - -You are adding a record for `api.roomies.sumitly.app`. In DNS terms, the "host" for a subdomain of your domain is just -the part before your domain. So for `api.roomies.sumitly.app`, the host is `api.roomies`. - -Click **Add Record**. Fill in: - -- **Type:** `CNAME` -- **Host:** `api.roomies` (name.com automatically appends `.sumitly.app`, so you only enter the prefix) -- **Answer/Value/Target:** `roomies-api.onrender.com` (the `.onrender.com` address of your Render service — find this on - your Render service overview page) -- **TTL:** leave at the default (300 seconds) - -Click **Add Record**. - -### 8.3 Add the Frontend CNAME Record (Placeholder for Now) - -When your frontend is deployed (on Vercel or another service), you will add another CNAME for `roomies.sumitly.app`. For -now, skip this unless you already know your Vercel deployment URL. Come back to this when the frontend is deployed. - -If using Vercel: Vercel will give you a CNAME target like `cname.vercel-dns.com`. You would add: - -- **Type:** `CNAME` -- **Host:** `roomies` -- **Answer:** `cname.vercel-dns.com` - -### 8.4 Verify DNS Propagation - -After adding your records, check propagation using this tool in your browser: - -``` -https://dnschecker.org/#CNAME/api.roomies.sumitly.app -``` - -Or from your terminal: - -```bash -dig CNAME api.roomies.sumitly.app -# Should show: api.roomies.sumitly.app → roomies-api.onrender.com -``` - -Wait until you see the CNAME resolve before proceeding to Phase 9. - ---- - -## Phase 9 — Connect Custom Domain on Render - -Once DNS has propagated, tell Render about your custom domain so it can issue an SSL certificate for it. - -### 9.1 Add the Custom Domain - -In your Render dashboard → your `roomies-api` service → **Settings** (left sidebar) → scroll down to **Custom Domains**. - -Click **+ Add Custom Domain**. Enter: - -``` -api.roomies.sumitly.app -``` - -Click **Save**. - -Render will verify that the CNAME record points to it (this requires DNS to have propagated first). If it shows -"Verification pending", wait a few minutes and refresh the page. - -Once verified, Render automatically issues a free SSL certificate from Let's Encrypt. This takes 1–5 minutes. You will -see the status change to **"Certificate issued"** when it's done. - -### 9.2 Test Your Custom Domain - -```bash -curl https://api.roomies.sumitly.app/api/v1/health -# Expected: {"status":"ok","services":{"database":"ok","redis":"ok"}} -``` - -If this returns a valid JSON response over HTTPS, your entire Tier 0 stack is working. Congratulations. - ---- - -## Phase 10 — Update Google OAuth Callback URL - -Your Google OAuth app needs to know about the new domain so users can sign in. - -Go to [console.cloud.google.com](https://console.cloud.google.com) → **APIs & Services** → **Credentials** → click your -OAuth client ID. - -Under **Authorised redirect URIs**, add: - -``` -https://api.roomies.sumitly.app/api/v1/auth/google/callback -``` - -Save. Google OAuth changes take a few minutes to propagate. - ---- - -## Phase 11 — Post-Deployment Verification Checklist - -Run these checks in order. Each one tests a different layer of the stack. - -**Health check:** - -```bash -curl https://api.roomies.sumitly.app/api/v1/health -# Both services should show "ok" -``` - -**User registration (tests database write):** - -```bash -curl -X POST https://api.roomies.sumitly.app/api/v1/auth/register \ - -H "Content-Type: application/json" \ - -d '{"email":"verify@yourdomain.com","password":"TestPass1","role":"student","fullName":"Verification Test"}' -# Expected: 201 Created -``` - -**OTP email (tests Brevo REST API):** - -```bash -# Use the access token from the registration response -curl -X POST https://api.roomies.sumitly.app/api/v1/auth/otp/send \ - -H "Authorization: Bearer YOUR_ACCESS_TOKEN" -# Expected: 200 with "OTP sent to your email" -# Then check your inbox — the email should arrive within 30 seconds -``` - -**Photo upload (tests Azure Blob Storage + BullMQ):** Create a test listing and upload a photo to -`POST /api/v1/listings/:id/photos`. Wait 10–15 seconds, then `GET /api/v1/listings/:id/photos`. You should see a photo -with a URL containing `blob.core.windows.net`. - -**Cold start test:** Wait 20 minutes without making any requests. Then make a request and observe. The first response -will take about 1 minute. Subsequent requests will be fast. This is expected behaviour. - ---- - -## Phase 12 — Auto-Deploy via GitHub Actions (Optional) - -Render already auto-deploys when you push to `main`. You don't need any GitHub Actions for this to work. However, if you -want to add a deployment status badge to your repository or need the deploy triggered only after tests pass, here's the -minimal config: - -First, get your Render deploy hook: Render dashboard → your service → **Settings** → scroll to **Deploy Hook** → copy -the URL. - -Add it to GitHub: your repo → **Settings** → **Secrets and variables** → **Actions** → **New repository secret** → name -it `RENDER_DEPLOY_HOOK` and paste the URL. - -Create `.github/workflows/deploy.yml`: - -```yaml -name: Deploy to Render -on: - push: - branches: [main] -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - name: Trigger Render deploy - run: curl -X POST "${{ secrets.RENDER_DEPLOY_HOOK }}" -``` - -With this, every push to `main` triggers a deploy. Without this, every push to `main` also triggers a deploy — so this -is truly optional. - ---- - -## Ongoing Monitoring - -**Weekly checks (5 minutes each):** - -Upstash Console → your database → **Analytics** tab: Look at the "Daily Commands" chart. If any day exceeds -15,000–20,000 commands, your server is staying awake more than expected (possibly someone is pinging it). Check whether -Render is sleeping properly by looking at the Render logs — if you see continuous traffic, find out where it's coming -from. - -Neon Console → your project → **Project Dashboard**: Look at storage usage. You start with 0.5 GB. With real users -uploading photos, you won't hit this quickly (photos go to Azure Blob, not Neon), but your user data, listings, and -connections all live in Neon. At 50 users/day with heavy listing activity, expect to use ~1–5 MB/day. - -Azure Portal → your storage account → **Overview** → **Monitoring**: Check blob storage usage. You get 5 GB free. At 100 -KB per photo (after Sharp compression to WebP), 5 GB holds 50,000 photos before you start paying. - -Brevo Dashboard → **Transactional** → **Statistics**: Check email delivery rates. If you see high bounce rates, your -sender domain verification may need attention. - ---- - -## Known Limitations of Tier 0 - -**Cold starts:** The first request after 15 minutes of idle takes about 1 minute. Render shows a loading page to the -user during this time. Users who sign up, go away, and come back 30 minutes later will hit a cold start. This is the -fundamental trade-off of the free tier. - -**No persistent local filesystem:** Render's free tier restarts regularly and doesn't support persistent disk. Your app -already handles this correctly — photos go to Azure Blob, not local disk. The `uploads/staging/` folder (where Multer -writes files before processing) is ephemeral and that's fine — if the server restarts mid-upload, the BullMQ job is -retried. - -**750 hours/month cap:** Render's free tier gives 750 hours of compute per month. One month has 720–744 hours. So you -effectively have enough for continuous operation — but if you also have other free Render services, the 750 hours are -shared across all of them in your workspace. - -**No zero-downtime deploys:** Render's free tier restarts the process on each deploy. During the few seconds of restart, -requests may fail. For a low-traffic early-stage app, this is acceptable. If you need zero-downtime deploys, that -requires Render Starter tier or Azure App Service. - ---- - -## Troubleshooting Common Problems - -| 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 `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 | -| 500 on register | Missing env var | Check Render logs for the specific error — env.js will print clearly what's missing | -| Cron jobs not running | Server was sleeping at scheduled time | Expected — crons are idempotent and catch up on next wake | -| Cold start taking 2+ minutes | Neon compute also cold-starting | Normal — Render cold start (1 min) + Neon cold start (0.5s) happen together | diff --git a/docs/deployment/tier1.md b/docs/deployment/tier1.md deleted file mode 100644 index dca35f7..0000000 --- a/docs/deployment/tier1.md +++ /dev/null @@ -1,210 +0,0 @@ -# Tier 1 Deployment Guide — Roomies Backend - -## When and How to Upgrade From Free Tier - -> **You are reading this because** something about Tier 0 is no longer good enough. **What this guide covers:** The -> three upgrade paths at Tier 1, and how to execute each one without losing user data or causing prolonged downtime. -> **Backend URL stays the same:** `https://api.roomies.sumitly.app` — your users never see any of this. - ---- - -## When Is It Time for Tier 1? - -Tier 0 will serve your app well during early growth. Tier 1 becomes necessary when one or more of the following happens. -Each trigger has its own upgrade path. - -**Trigger A — Upstash commands exceeding ~400K/month.** You see this in the Upstash Analytics tab. If daily command -usage is consistently above ~13,000 commands, you will hit 500K/month within the month. You need to upgrade before the -hard limit cuts off BullMQ. - -**Trigger B — Users complaining about the 1-minute cold start.** When your user base grows and real users are hitting -sleeping servers, cold starts become a UX problem that drives abandonment. This is the most human trigger — you will -feel it before you measure it. - -**Trigger C — Neon storage approaching 0.5 GB.** Go to your Neon Console → Project Settings → Storage. When this shows -0.4 GB or more, plan a migration. You have some buffer, but do not wait until you actually hit the limit. - -**Trigger D — Multiple triggers happening at once.** At this point, consider jumping straight to Tier 2 (Azure App -Service + Azure PostgreSQL on student credits), which solves all problems simultaneously. - ---- - -## Understanding the Tier 1 Options - -There are three distinct problems you might be solving, and they require different (but sometimes combined) fixes. Think -of them as three independent dials you can turn: - -**Dial 1 — Redis capacity:** Upstash Free → Upstash Fixed $10/month. No code changes. No server restart needed. Just a -billing plan change in the Upstash dashboard. - -**Dial 2 — Server always-on:** Render Free (sleeps) → Render Starter ($7/month, always on). No code changes. Just a plan -upgrade in Render. But: if the server is now always awake, Dial 1 (Redis) almost certainly needs to be turned at the -same time. - -**Dial 3 — Database capacity:** Neon Free (0.5 GB) → Neon Launch plan ($19/month). This requires a brief maintenance -period if you want to do it carefully, or can be done as a zero-impact upgrade directly in the Neon dashboard. - -The most common Tier 1 progression is: first Trigger A fires (Redis), so you turn Dial 1. Then Trigger B fires (cold -starts are hurting growth), so you turn Dials 2 and 1 together. Trigger C is usually the last to fire unless you are -storing a lot of data early on. - ---- - -## Option A — Fix Redis Capacity Only (Upstash Fixed Plan) - -**When:** Upstash commands approaching 400K/month. Server sleeps are still acceptable. - -**Cost added:** $10/month (~₹840) for Upstash Fixed 250MB. Student credits are not consumed — this is paid to Upstash. - -**Zero downtime.** Your existing `REDIS_URL` does not change. Your app does not need to redeploy. - -### Steps - -**Step 1:** Log in to [console.upstash.com](https://console.upstash.com) → click on your `roomies-redis` database → -click the **Plan** tab or find the **Upgrade** button on the overview page. - -**Step 2:** Select **Fixed 250MB** plan ($10/month). This plan has unlimited commands within its memory and bandwidth -limits, which is exactly what you need for BullMQ. - -**Step 3:** Confirm the upgrade. Upstash upgrades your plan instantaneously — no data loss, no connection interruption. -Your existing `REDIS_URL` (host, port, password) stays the same. - -**Step 4:** Update your local `.env.render` file to add a note that you are on the Fixed plan now. No actual value needs -to change. - -**Verify:** Check Upstash Analytics the next day. Command counts should be the same as before — the difference is that -you are no longer at risk of hitting a hard cap. - ---- - -## Option B — Fix Cold Starts (Always-On Server) - -**When:** User cold start complaints are affecting retention or conversions. - -**Cost added:** Render Starter at $7/month (~₹580) + Upstash Fixed at $10/month (~₹840) = **$17/month total (~₹1,420)**. -This is less than Azure App Service B1 alone (~₹1,092/month from student credits), and doesn't touch your student credit -balance. - -**Why both at once:** If the server never sleeps, BullMQ workers poll Redis continuously. A -sleeping-BullMQ-plus-Upstash-Free uses ~150K commands/month. An always-on-BullMQ-plus-Upstash-Free will blow through -500K in about 10–12 days. So upgrading to always-on requires the Redis upgrade simultaneously. - -**There will be a brief restart** (~30 seconds) when you change the Render plan, during which requests may get 502 -errors. Pick a low-traffic time (midnight India time, for example). - -### Steps - -**Step 1 — Upgrade Upstash first** (follow Option A steps above). - -**Step 2 — Upgrade Render plan:** - -Go to [dashboard.render.com](https://dashboard.render.com) → your `roomies-api` service → **Settings** (left sidebar) → -scroll down to **Instance Type**. - -Click **Change**. Select **Starter ($7/month)**. The Starter plan has: - -- Always-on (no sleeping) -- 512 MB RAM (same as free) -- 0.5 CPU -- No limits on outbound ports (though you don't need SMTP since you're using Brevo API) - -Click **Save**. Render will restart your service. Watch the logs tab — you should see the full startup sequence within -30 seconds. - -**Step 3 — Verify always-on behaviour:** - -Wait 20 minutes without making any requests. Then make a request and check the response time. It should respond in under -2 seconds (fast, no cold start). If it still cold-starts, check the Render dashboard — the Starter plan indicator should -show "Always On". - -**Step 4 — Update your ALLOWED_ORIGINS if your frontend is now live:** - -If your frontend is deployed at `roomies.sumitly.app`, update the `ALLOWED_ORIGINS` environment variable in Render from -`*` to `https://roomies.sumitly.app`. This improves security. - ---- - -## Option C — Fix Database Capacity (Neon Storage Upgrade) - -**When:** Neon storage showing 0.4 GB or higher. - -**Cost added:** Neon Launch plan at $19/month (~₹1,590). This is paid to Neon. Student credits not consumed. - -**Zero downtime.** This is a billing plan change in the Neon dashboard — the connection string stays the same, the data -stays in place. - -### Steps - -**Step 1:** Go to [console.neon.tech](https://console.neon.tech) → your `roomies` project → click **Settings** or the -plan indicator in the top area → find the **Billing** or **Upgrade** section. - -**Step 2:** Select **Launch plan** ($19/month). This gives you: - -- 10 GB storage (20x more than the free tier) -- More compute hours per month -- Point-in-time recovery for up to 7 days (important for production) - -**Step 3:** Confirm the upgrade. Neon upgrades instantly. Your `DATABASE_URL` connection string is unchanged. Your app -on Render is unaffected — it is already connected and will continue running without interruption. - -**Step 4:** Go back to Render → your service → **Environment** tab. No changes needed here. The app is already using the -same connection. - -**Verify:** Check Neon Console → Project Settings → Storage the next day. You should now see the storage used out of 10 -GB rather than 0.5 GB. - ---- - -## Option D — Migrating to a Different Neon Region (If Needed) - -This is an edge case, but worth documenting. If your Neon database is in Singapore and your users are accessing it via -Render Singapore, the latency is ~1ms. If for some reason Neon had availability issues in Singapore (rare but possible), -you might want to switch regions. - -**This requires actual data migration and causes downtime.** Only do this if there is a compelling reason. - -For a low-traffic app, the safest approach is: - -1. Set a maintenance page on Render by temporarily returning 503 from your health endpoint (or by suspending the service - in the Render dashboard) -2. Export your Neon data: `pg_dump "$NEON_URL" > roomies_backup_$(date +%Y%m%d).sql` -3. Create a new Neon project in the target region -4. Enable PostGIS and pgcrypto in the new project -5. Import: `psql "$NEW_NEON_URL" < roomies_backup_YYYYMMDD.sql` -6. Update `DATABASE_URL` in Render environment variables -7. Trigger a Render redeploy -8. Restore the health endpoint -9. Test everything - -Downtime window: approximately 5–15 minutes depending on database size. - ---- - -## Summary: Tier 1 Monthly Costs - -| Scenario | What Changed | Monthly Cost | -| -------------------------- | ------------------------------ | ---------------- | -| Redis upgrade only | Upstash Free → Fixed | $10/mo (~₹840) | -| Always-on (no cold starts) | Render Starter + Upstash Fixed | $17/mo (~₹1,420) | -| Database upgrade only | Neon Free → Launch | $19/mo (~₹1,590) | -| Full Tier 1 (all three) | All of the above | $36/mo (~₹3,020) | - -At $36/month full Tier 1, consider whether jumping to Tier 2 (Azure on student credits, ~₹3,255/month but with zero -ongoing external vendor cost beyond student credits) makes more sense for your runway. The decision depends on how -quickly your student credits are running down and how much time you want to spend managing billing across multiple -external vendors. - ---- - -## When Tier 1 Is Not Enough — Time to Go to Tier 2 - -Move to tier2.md when: - -- Your monthly external vendor spend (Upstash + Neon + Render) approaches the cost of Azure App Service B1 + Azure - PostgreSQL (~₹3,255/month from student credits) -- You need features only Azure provides: SLA guarantees, VNet integration, Azure Monitor, Always On with zero restarts -- Your student credits are abundant and you want to consolidate everything onto one bill -- You anticipate needing to scale quickly and want the headroom of Azure's autoscale - -The user-facing URL stays the same across all tiers. The only visible migration is updating DNS CNAME records to point -from `roomies-api.onrender.com` to `roomies-api.azurewebsites.net`. diff --git a/docs/deployment/tier2.md b/docs/deployment/tier2.md deleted file mode 100644 index 7252d27..0000000 --- a/docs/deployment/tier2.md +++ /dev/null @@ -1,833 +0,0 @@ -# Tier 2 Deployment Guide — Roomies Backend - -## Full Azure Migration with Zero-Downtime Cutover - -> **You are reading this because** you are migrating from Tier 0/1 (Render + external providers) to a full Azure setup -> using your student credits, OR you are setting up Azure from scratch for the first time. **Backend URL stays the -> same:** `https://api.roomies.sumitly.app` — the DNS change is the last step, done only after Azure is fully verified. -> **Key principle:** Set up the entire Azure environment in parallel. Only cut DNS over when everything is confirmed -> working. Downtime = 0–5 minutes. - ---- - -## When to Migrate to Tier 2 - -Move here when at least one of these is true: - -- External vendor spend (Upstash + Neon paid + Render Starter) approaches ₹2,500/month — at that point Azure is - similarly priced with better features -- You need "Always On" compute with an SLA guarantee (Render Starter has no uptime SLA; Azure B1 does) -- Your student credits balance is healthy (above ₹5,000) and you want to consolidate everything -- You are building features that integrate with other Azure services (Azure Monitor, VNet, etc.) -- Render has caused repeated reliability issues (the Singapore region has had occasional free-tier instability) - ---- - -## Understanding What Changes in Tier 2 - -You are replacing three external services with Azure equivalents: - -- Render free web service → Azure App Service B1 Linux (always-on, ~₹1,092/month) -- Neon PostgreSQL → Azure Database for PostgreSQL Flexible Server B1ms (always-on, ~₹1,323/month including storage) -- Upstash Redis → stays on Upstash Fixed OR moves to Azure Cache for Redis C0 (~₹1,050/month) - -**Azure Blob Storage and Brevo email stay unchanged.** Your existing `AzureBlobAdapter` already uses Azure Blob. Email -moves back to SMTP now that you are on Azure App Service (which does not block SMTP ports). Or keep using the Brevo API -— both work fine. Keeping the API is simpler since no code changes are needed. - -**The DNS change is the migration moment.** While Azure is being set up, your Render service continues serving all -traffic. You only flip the DNS CNAME record from `roomies-api.onrender.com` to `roomies-api.azurewebsites.net` after -Azure is fully tested. This is called a parallel deployment. - ---- - -## Pre-Migration Checklist - -Before touching Azure, complete these tasks on your local machine. - -**Take a database backup.** This is non-negotiable before any migration involving real user data: - -```bash -# Set your current Neon URL -export NEON_URL="postgresql://neondb_owner:PASSWORD@ep-ENDPOINT.ap-southeast-1.aws.neon.tech/neondb?sslmode=require" - -# Create a timestamped backup in your project root -pg_dump "$NEON_URL" > roomies_backup_$(date +%Y%m%d_%H%M%S).sql - -# Verify the backup is not empty -wc -l roomies_backup_*.sql -# Should show hundreds of lines, not 0 or a very small number -``` - -**Store this backup in a safe location** — not inside your Git repository (the file will be large and may contain hashed -passwords). Move it to a secure folder: - -```bash -mkdir -p ~/roomies-backups -mv roomies_backup_*.sql ~/roomies-backups/ -``` - -**Install the Azure CLI** if you don't have it: - -```bash -curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash -az --version # Verify installation -az login # Opens a browser — log in with your Azure student account -az account set --subscription "Azure for Students" -az configure --defaults location=centralindia group=roomies-rg -``` - ---- - -## Phase 1 — Resource Group - -A Resource Group is a logical container. Deleting `roomies-rg` deletes everything inside it at once. This is useful when -you want to clean up or start over. - -```bash -az group create --name roomies-rg --location centralindia -# Expected output: "provisioningState": "Succeeded" -``` - ---- - -## Phase 2 — Azure PostgreSQL Flexible Server - -### 2.1 Create the Server - -**Via Azure Portal:** - -1. Go to [portal.azure.com](https://portal.azure.com) and search for "Azure Database for PostgreSQL flexible server" in - the top search bar. -2. Click **+ Create** → **Flexible server**. -3. On the Basics tab, fill in: - - **Subscription:** Azure for Students - - **Resource group:** `roomies-rg` - - **Server name:** `roomies-db` (becomes `roomies-db.postgres.database.azure.com`) - - **Region:** Central India - - **PostgreSQL version:** **16** (must match your schema) - - **Workload type:** Development (this pre-selects Burstable tier — correct for your scale) - - Click **Configure server**: select **Standard_B1ms** (1 vCore, 2 GB RAM), storage **32 GiB**, storage auto-growth - **Enabled** -4. On the Authentication tab: - - **Authentication method:** PostgreSQL authentication only - - **Admin username:** `roomiesadmin` - - **Password:** generate a strong password (16+ characters, mix of uppercase, lowercase, numbers, symbols). **Write - it down immediately** — you cannot recover it and you will need it several times. -5. On the Networking tab: - - **Connectivity method:** Public access (allowed IP addresses) - - **Allow public access:** Yes - - Click **+ Add current client IP address** to add your home IP - - **Azure services access:** Yes (required for App Service to connect) -6. Click **Review + create** → **Create** - -Deployment takes 3–5 minutes. - -**Alternatively, via CLI:** - -```bash -az postgres flexible-server create \ - --resource-group roomies-rg \ - --name roomies-db \ - --location centralindia \ - --admin-user roomiesadmin \ - --admin-password "YOUR_STRONG_PASSWORD_HERE" \ - --sku-name Standard_B1ms \ - --tier Burstable \ - --storage-size 32 \ - --version 16 \ - --public-access 0.0.0.0 \ - --backup-retention 7 -``` - -### 2.2 Enable Required Extensions - -Your schema uses PostGIS and pgcrypto. Azure requires these to be allowlisted before you can `CREATE EXTENSION`. This is -a one-time setup: - -```bash -az postgres flexible-server parameter set \ - --resource-group roomies-rg \ - --server-name roomies-db \ - --name azure.extensions \ - --value POSTGIS,PGCRYPTO -``` - -Wait 1–2 minutes for the parameter change to apply. No server restart is needed. - -### 2.3 Create the Database - -```bash -az postgres flexible-server db create \ - --resource-group roomies-rg \ - --server-name roomies-db \ - --database-name roomies_db -``` - -### 2.4 Build Your Azure Connection String - -``` -postgresql://roomiesadmin:YOUR_PASSWORD@roomies-db.postgres.database.azure.com:5432/roomies_db?sslmode=require -``` - -### 2.5 Run Migrations Against Azure PostgreSQL - -Connect from your local machine (your IP was whitelisted in step 2.1): - -```bash -export AZURE_DB_URL="postgresql://roomiesadmin:YOUR_PASSWORD@roomies-db.postgres.database.azure.com:5432/roomies_db?sslmode=require" - -# Test connection first -psql "$AZURE_DB_URL" -c "SELECT version();" -# Should print PostgreSQL version info - -# Run migrations in order -psql "$AZURE_DB_URL" -f migrations/001_initial_schema.sql -psql "$AZURE_DB_URL" -f migrations/002_verification_event_outbox.sql - -# Verify tables exist -psql "$AZURE_DB_URL" -c "\dt" - -# Verify PostGIS works -psql "$AZURE_DB_URL" -c "SELECT ST_AsText(ST_MakePoint(77.21, 28.63));" -# Should return: POINT(77.21 28.63) -``` - -### 2.6 Zero-Downtime Data Migration From Neon - -This is the most delicate part of the migration. You need to move all existing user data from Neon to Azure PostgreSQL -without losing any writes that happen during the migration window. - -**The strategy:** Because your app uses Render and Neon until you flip DNS, you have a window where you can: - -1. Export a full Neon backup -2. Import it into Azure PostgreSQL -3. Accept that there may be a small gap of data (writes between export and import) -4. During the DNS cutover (which is the actual "downtime"), no writes happen - -For a low-traffic app, the simplest approach that gives effectively zero data loss is: - -**Step 1 — Export from Neon:** - -```bash -# Use your most recent backup, or create a fresh one right now -export NEON_URL="postgresql://neondb_owner:PASSWORD@ep-ENDPOINT.ap-southeast-1.aws.neon.tech/neondb?sslmode=require" -pg_dump "$NEON_URL" --no-owner --no-acl > roomies_migration_$(date +%Y%m%d_%H%M%S).sql -``` - -The `--no-owner` and `--no-acl` flags strip Neon-specific ownership and access control information that would cause -errors on Azure PostgreSQL. - -**Step 2 — Import into Azure PostgreSQL:** - -```bash -export AZURE_DB_URL="postgresql://roomiesadmin:YOUR_PASSWORD@roomies-db.postgres.database.azure.com:5432/roomies_db?sslmode=require" -psql "$AZURE_DB_URL" < roomies_migration_YYYYMMDD_HHMMSS.sql -``` - -This may take a few minutes depending on data size. Watch for any ERROR lines in the output. The most common error at -this step is a PostGIS extension function that exists in Neon but needed to be explicitly created on Azure first — but -since you already ran your migrations in step 2.5, these should resolve correctly. - -**Step 3 — Verify data integrity:** - -```bash -# Check row counts match between Neon and Azure -psql "$NEON_URL" -c "SELECT COUNT(*) FROM users;" -psql "$AZURE_DB_URL" -c "SELECT COUNT(*) FROM users;" -# These should match (or be very close — a few writes may have happened during migration) - -psql "$NEON_URL" -c "SELECT COUNT(*) FROM listings;" -psql "$AZURE_DB_URL" -c "SELECT COUNT(*) FROM listings;" -``` - -**Step 4 — Handle the gap:** Between the moment you took the Neon export and the moment you flip DNS, some writes may -have happened. For most low-traffic apps, this is 0–10 rows. The options are: - -- Accept the small gap (appropriate for early-stage apps where a few records being in Neon-but-not-Azure is acceptable) -- Do a second `pg_dump --data-only` immediately before the DNS cutover and import just the data, which will catch the - writes that happened since step 1 - -For most teams at this stage, accepting the small gap during the migration window is the right call. During the DNS -cutover itself (next phase), no traffic is hitting the server at all, so there is no gap during the actual switch. - ---- - -## Phase 3 — Redis Decision: Upstash Fixed vs Azure Cache for Redis - -At Tier 2, you have two options for Redis. Review both and make a decision before proceeding. - -**Keep Upstash Fixed ($10/month ~₹840):** Your existing `REDIS_URL` does not change. No code changes. No deployment -needed. Upstash Fixed gives you 250 MB Redis with unlimited commands — more than enough for your scale indefinitely. -Your `bullConnection.js` already handles `rediss://` TLS URLs correctly. - -**Switch to Azure Cache for Redis C0 (~₹1,050/month from student credits):** This consolidates billing onto Azure and -avoids a separate vendor. However, it costs ₹210/month more than Upstash Fixed, and the only benefit is consolidation. -There is no performance or reliability difference at your scale. - -**Recommendation:** Keep Upstash Fixed. The ₹210/month savings are not trivial when your student credit runway is -finite, and the operational simplicity of not migrating Redis is valuable. - -If you want Azure Redis anyway, create it via the portal: search "Azure Cache for Redis" → create a Basic C0 (250 MB) in -Central India. After creation, find the primary connection string in Access Keys → it looks like -`roomies-redis.redis.cache.windows.net:6380,password=XXXX,ssl=True`. Convert this to your app's URL format: - -``` -rediss://:YOUR_ACCESS_KEY@roomies-redis.redis.cache.windows.net:6380 -``` - -Note the `:` before the password (no username — Azure Redis Basic tier uses password-only auth). - ---- - -## Phase 4 — Azure App Service - -### 4.1 Create App Service Plan - -The plan is the underlying VM. The App Service (your app) runs on the plan. - -```bash -az appservice plan create \ - --resource-group roomies-rg \ - --name roomies-plan \ - --location centralindia \ - --is-linux \ - --sku B1 -``` - -### 4.2 Create the Web App - -```bash -az webapp create \ - --resource-group roomies-rg \ - --plan roomies-plan \ - --name roomies-api \ - --runtime "NODE:22-lts" -``` - -This creates `roomies-api.azurewebsites.net`. This is a temporary URL — you will eventually point your custom domain -here. - -### 4.3 Enable Managed Identity - -Managed Identity allows the App Service to authenticate to Key Vault without storing any credentials. It is the secure -way to manage secrets in Azure. - -```bash -az webapp identity assign \ - --resource-group roomies-rg \ - --name roomies-api -# This command outputs a principalId — write it down -``` - -### 4.4 Configure Startup and Runtime Settings - -```bash -# Set Node.js startup command -az webapp config set \ - --resource-group roomies-rg \ - --name roomies-api \ - --startup-file "node src/server.js" - -# Enable Always On (critical — without this, the B1 tier still sleeps) -az webapp config set \ - --resource-group roomies-rg \ - --name roomies-api \ - --always-on true - -# Set health check path -az webapp config set \ - --resource-group roomies-rg \ - --name roomies-api \ - --generic-configurations '{"healthCheckPath": "/api/v1/health"}' -``` - ---- - -## Phase 5 — Key Vault for Secrets - -Key Vault is a secure secret store. App Service reads secrets from it at runtime via Managed Identity — no secrets ever -appear in plaintext in the App Service configuration. This is the production-grade approach. - -### 5.1 Create the Key Vault - -```bash -az keyvault create \ - --resource-group roomies-rg \ - --name roomies-kv \ - --location centralindia \ - --sku standard \ - --enable-rbac-authorization true -``` - -### 5.2 Grant Yourself Admin Access - -```bash -# Get your Azure account's object ID -MY_OBJECT_ID=$(az ad signed-in-user show --query id -o tsv) - -# Assign Key Vault Administrator to yourself -az role assignment create \ - --role "Key Vault Administrator" \ - --assignee "$MY_OBJECT_ID" \ - --scope $(az keyvault show --name roomies-kv --query id -o tsv) -``` - -### 5.3 Grant App Service Access to Key Vault - -```bash -# Get the App Service's managed identity principal ID -APP_PRINCIPAL_ID=$(az webapp identity show \ - --resource-group roomies-rg \ - --name roomies-api \ - --query principalId -o tsv) - -# Grant it permission to read secrets -az role assignment create \ - --role "Key Vault Secrets User" \ - --assignee "$APP_PRINCIPAL_ID" \ - --scope $(az keyvault show --name roomies-kv --query id -o tsv) -``` - -### 5.4 Store All Secrets in Key Vault - -Run each of these commands, substituting your actual values: - -```bash -KV="--vault-name roomies-kv" - -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: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 -az keyvault secret set $KV --name "JWT-SECRET" \ - --value "$(node -e "console.log(require('crypto').randomBytes(48).toString('base64'))")" - -az keyvault secret set $KV --name "JWT-REFRESH-SECRET" \ - --value "$(node -e "console.log(require('crypto').randomBytes(48).toString('base64'))")" - -az keyvault secret set $KV --name "AZURE-STORAGE-CONNECTION-STRING" \ - --value "DefaultEndpointsProtocol=https;AccountName=roomiesblob;AccountKey=YOUR_KEY;EndpointSuffix=core.windows.net" - -az keyvault secret set $KV --name "AZURE-STORAGE-CONTAINER" --value "roomies-uploads" - -az keyvault secret set $KV --name "EMAIL-PROVIDER" --value "brevo-api" -# Keep brevo-api — no code change needed, and it works fine on Azure too - -az keyvault secret set $KV --name "BREVO-API-KEY" \ - --value "xkeysib-YOUR_API_KEY" - -az keyvault secret set $KV --name "BREVO-SMTP-FROM" \ - --value "your-verified-sender@example.com" - -az keyvault secret set $KV --name "GOOGLE-CLIENT-ID" \ - --value "YOUR_GOOGLE_CLIENT_ID" - -az keyvault secret set $KV --name "GOOGLE-CLIENT-SECRET" \ - --value "YOUR_GOOGLE_CLIENT_SECRET" - -az keyvault secret set $KV --name "ALLOWED-ORIGINS" \ - --value "https://roomies.sumitly.app" -``` - ---- - -## Phase 6 — App Service Environment Variables - -Azure App Service reads secrets from Key Vault using a special `@Microsoft.KeyVault(SecretUri=...)` reference syntax. -The App Service resolves these at startup — your Node.js process sees them as plain `process.env` values. - -Add these via the Azure Portal: App Service → **Configuration** (left sidebar) → **Application settings** tab → **+ New -application setting** for each row below. - -**Direct settings (not secrets — put these as plain values):** - -``` -NODE_ENV = production -PORT = 8080 -STORAGE_ADAPTER = azure -JWT_EXPIRES_IN = 15m -JWT_REFRESH_EXPIRES_IN = 7d -TRUST_PROXY = 1 -``` - -**Key Vault references (use this syntax exactly — replace `roomies-kv` with your actual vault name if different):** - -``` -DATABASE_URL -@Microsoft.KeyVault(SecretUri=https://roomies-kv.vault.azure.net/secrets/DATABASE-URL/) - -REDIS_URL -@Microsoft.KeyVault(SecretUri=https://roomies-kv.vault.azure.net/secrets/REDIS-URL/) - -JWT_SECRET -@Microsoft.KeyVault(SecretUri=https://roomies-kv.vault.azure.net/secrets/JWT-SECRET/) - -JWT_REFRESH_SECRET -@Microsoft.KeyVault(SecretUri=https://roomies-kv.vault.azure.net/secrets/JWT-REFRESH-SECRET/) - -AZURE_STORAGE_CONNECTION_STRING -@Microsoft.KeyVault(SecretUri=https://roomies-kv.vault.azure.net/secrets/AZURE-STORAGE-CONNECTION-STRING/) - -AZURE_STORAGE_CONTAINER -@Microsoft.KeyVault(SecretUri=https://roomies-kv.vault.azure.net/secrets/AZURE-STORAGE-CONTAINER/) - -EMAIL_PROVIDER -@Microsoft.KeyVault(SecretUri=https://roomies-kv.vault.azure.net/secrets/EMAIL-PROVIDER/) - -BREVO_API_KEY -@Microsoft.KeyVault(SecretUri=https://roomies-kv.vault.azure.net/secrets/BREVO-API-KEY/) - -BREVO_SMTP_FROM -@Microsoft.KeyVault(SecretUri=https://roomies-kv.vault.azure.net/secrets/BREVO-SMTP-FROM/) - -GOOGLE_CLIENT_ID -@Microsoft.KeyVault(SecretUri=https://roomies-kv.vault.azure.net/secrets/GOOGLE-CLIENT-ID/) - -GOOGLE_CLIENT_SECRET -@Microsoft.KeyVault(SecretUri=https://roomies-kv.vault.azure.net/secrets/GOOGLE-CLIENT-SECRET/) - -ALLOWED_ORIGINS -@Microsoft.KeyVault(SecretUri=https://roomies-kv.vault.azure.net/secrets/ALLOWED-ORIGINS/) -``` - -Click **Save** after adding all settings. - -To verify that Key Vault references resolved correctly: App Service → Configuration → look at the **Key Vault Reference -Status** column next to each KV-referenced setting. Each should show a green checkmark and "Resolved". If any show -"Unresolved", common causes are: - -- The Managed Identity role assignment (Phase 4.3) hasn't propagated yet — wait 5 minutes and refresh -- The secret name in Key Vault does not match exactly (names are case-sensitive) -- The Key Vault URI in the reference is wrong — check for typos - ---- - -## Phase 7 — First Azure Deployment - -### 7.1 Create the Deployment ZIP - -From your project root on your local machine: - -```bash -# Ensure you're on main branch with all changes committed -git checkout main -git pull origin main - -# Create the deployment archive -zip -r roomies-deploy.zip . \ - -x "node_modules/*" \ - -x ".git/*" \ - -x ".env*" \ - -x "uploads/*" \ - -x "*.zip" \ - -x "*.sql" - -# Verify the archive is reasonable (should be tens of MB, not hundreds) -ls -lh roomies-deploy.zip -``` - -### 7.2 Deploy to Azure - -```bash -az webapp deploy \ - --resource-group roomies-rg \ - --name roomies-api \ - --src-path roomies-deploy.zip \ - --type zip -``` - -Azure's Oryx build system will detect `package.json`, run `npm install --production`, and start the app with -`node src/server.js`. This takes 3–5 minutes. - -### 7.3 Watch the Startup Logs - -```bash -az webapp log tail \ - --resource-group roomies-rg \ - --name roomies-api -``` - -Expected successful output: - -``` -PostgreSQL connected -Redis connected -Media processing worker started -Notification delivery worker started -Email delivery worker started -Verification event worker started -cron:listingExpiry — registered -cron:expiryWarning — registered -cron:hardDeleteCleanup — registered -Server running on port 8080 [production] -``` - -### 7.4 Test the Azure URL - -Before touching DNS, verify everything works on the Azure `.azurewebsites.net` URL: - -```bash -# Health check -curl https://roomies-api.azurewebsites.net/api/v1/health -# Expected: {"status":"ok","services":{"database":"ok","redis":"ok"}} - -# Register a test user (use a throwaway email) -curl -X POST https://roomies-api.azurewebsites.net/api/v1/auth/register \ - -H "Content-Type: application/json" \ - -d '{"email":"azure_test@example.com","password":"TestPass1","role":"student","fullName":"Azure Test"}' -# Expected: 201 Created -``` - -Do not proceed to DNS cutover until the Azure URL is fully working. Your Render service is still serving all real user -traffic during this phase. - ---- - -## Phase 8 — Set Up Custom Domain on Azure App Service - -Azure needs to know about your custom domain before you redirect traffic to it. - -### 8.1 Add Custom Domain to App Service - -Go to Azure Portal → your App Service → **Custom domains** (left sidebar) → **+ Add custom domain**. - -Enter `api.roomies.sumitly.app`. Azure will tell you what TXT and CNAME records to add for verification. It will show -you something like: - -- **TXT record:** Host = `asuid.api.roomies`, Value = a long verification string -- **CNAME record:** Host = `api.roomies`, Value = `roomies-api.azurewebsites.net` - -### 8.2 Add the Verification TXT Record on name.com - -Go to name.com → My Domains → `sumitly.app` → Manage DNS Records. - -Add a **TXT record**: - -- **Type:** TXT -- **Host:** `asuid.api.roomies` -- **Answer:** the long verification string from Azure (something like `3A8B2C...`) -- **TTL:** 300 (default) - -Click **Add Record**. - -### 8.3 Add the App Service Managed Certificate (Free SSL) - -After Azure verifies the TXT record (may take a few minutes), go back to **Custom domains** → **Add custom domain** → -complete the binding → select **App Service Managed Certificate** — this is a free, auto-renewing SSL certificate. - ---- - -## Phase 9 — The DNS Cutover (Zero-Downtime Migration) - -This is the moment where you switch traffic from Render to Azure. The window between removing the old CNAME and DNS -propagating to the new one is when a small amount of traffic might get errors (cached DNS entries taking different -amounts of time to expire). For a low-traffic app, this window is typically 0–60 seconds. - -**To minimise this window, temporarily lower TTL before the cutover.** Do this 24 hours before you plan to cut over: - -Go to name.com → DNS records → find your existing `api.roomies` CNAME (pointing to `roomies-api.onrender.com`) → edit it -to set TTL to **60 seconds** (the minimum name.com allows). - -After 24 hours, most of the world's DNS resolvers have refreshed their cached record with the 60-second TTL. That means -when you change the CNAME target, propagation will happen within 60 seconds instead of up to 24 hours. - -**On the day of the cutover:** - -Step 1 — Pick a low-traffic time. Look at your Render logs and pick a time when you see the fewest requests (typically -2–5 AM India time). - -Step 2 — Take a final Neon backup immediately before the cutover: - -```bash -pg_dump "$NEON_URL" --no-owner --no-acl > ~/roomies-backups/final_pre_cutover_$(date +%Y%m%d_%H%M%S).sql -``` - -Step 3 — Do a second data import to Azure to catch any writes since your first migration (Phase 2.6): - -```bash -# Export only data (no DDL — schema is already in Azure) -pg_dump "$NEON_URL" --data-only --no-owner --no-acl > /tmp/final_data.sql - -# Import into Azure (some rows may conflict with existing data — that's OK) -psql "$AZURE_DB_URL" --single-transaction < /tmp/final_data.sql 2>&1 | grep -v "ERROR.*duplicate" | grep -v "^$" -# Filtering out expected duplicate-key errors from data that was already imported -``` - -Step 4 — Update the CNAME on name.com: - -Go to name.com → DNS records → find the `api.roomies` CNAME → edit it: - -- **Answer:** change from `roomies-api.onrender.com` to `roomies-api.azurewebsites.net` -- **TTL:** set back to 300 (default) - -Click **Save**. - -Step 5 — Verify the change is propagating: - -```bash -# Watch DNS propagation -watch -n 5 'dig CNAME api.roomies.sumitly.app +short' -# Should change from roomies-api.onrender.com to roomies-api.azurewebsites.net within ~60 seconds -``` - -Step 6 — Test the production URL over HTTPS: - -```bash -curl https://api.roomies.sumitly.app/api/v1/health -# Expected: {"status":"ok","services":{"database":"ok","redis":"ok"}} -``` - -Step 7 — Update Google OAuth callback URL in Google Cloud Console to confirm -`https://api.roomies.sumitly.app/api/v1/auth/google/callback` is listed. - -Step 8 — Keep Render running for 48 hours after the cutover. Old DNS entries may still point to Render. After 48 hours, -you can safely suspend or delete the Render service. - ---- - -## Phase 10 — CI/CD Pipeline for Azure - -Set up automatic deployment on push to `main` so you don't have to manually create ZIP files. - -### 10.1 Get the App Service Publish Profile - -Azure Portal → your App Service → **Overview** → click **Get publish profile** → save the downloaded file. - -### 10.2 Add to GitHub Secrets - -Go to your GitHub repository → **Settings** → **Secrets and variables** → **Actions** → **New repository secret**: - -- **Name:** `AZURE_WEBAPP_PUBLISH_PROFILE` -- **Value:** paste the entire contents of the downloaded `.PublishSettings` file - -### 10.3 Create the GitHub Actions Workflow - -Create `.github/workflows/deploy-azure.yml`: - -```yaml -name: Deploy to Azure App Service - -on: - push: - branches: [main] - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js 22 - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: "npm" - - - name: Install production dependencies - run: npm ci --production - - - name: Deploy to Azure Web App - uses: azure/webapps-deploy@v3 - with: - app-name: roomies-api - publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }} - package: . -``` - -When you push to `main`, GitHub Actions runs this workflow. Deploys take 3–5 minutes. You can watch progress in GitHub → -Actions tab. - ---- - -## Phase 11 — Setting Up a Cost Budget Alert - -Your student credits are finite. Set up an alert so you're notified before they run out. - -Azure Portal → search "Cost Management + Billing" → **Budgets** → **+ Add**. - -Fill in: - -- **Scope:** your Azure for Students subscription -- **Budget name:** `roomies-monthly` -- **Reset period:** Monthly -- **Budget amount:** ₹1,500 (slightly above expected ~₹1,323/month for Tier 2 minus Redis) -- **Alert conditions:** - - Alert at 80% (₹1,200) — send an email to your address - - Alert at 100% (₹1,500) — send another email - -This gives you two warnings before you significantly overspend. - ---- - -## Post-Migration Verification Checklist - -Run all of these after the DNS cutover: - -```bash -# Health -curl https://api.roomies.sumitly.app/api/v1/health - -# Authentication -curl -X POST https://api.roomies.sumitly.app/api/v1/auth/login \ - -H "Content-Type: application/json" \ - -d '{"email":"your_real_user@example.com","password":"their_password"}' - -# Photo URL check — make sure existing photos still load -curl -I https://roomiesblob.blob.core.windows.net/roomies-uploads/listings/SOME_LISTING_ID/some_photo.webp -# Should return 200 OK - -# OTP email -# Register a test account and trigger an OTP — confirm it arrives -``` - ---- - -## Tier 2 Monthly Cost Summary - -| Service | Tier | Cost/month | -| ------------------------------ | ---------------- | ----------------- | -| Azure App Service B1 | Basic | ~₹1,092 | -| Azure PostgreSQL Flexible B1ms | Burstable | ~₹1,043 | -| PostgreSQL storage 32 GB | SSD | ~₹280 | -| Upstash Redis Fixed 250MB | Fixed | ~₹840 | -| Azure Blob Storage | Always-free 5 GB | ₹0 | -| Brevo Email API | Free 300/day | ₹0 | -| **Total** | | **~₹3,255/month** | - -With ₹9,480 student credits: approximately **2.9 months** of full Tier 2 operation. By then, either your student credits -will have renewed (Azure for Students renews annually) or the project will have paying users covering costs. - -**Cost-saving tip:** Azure PostgreSQL Flexible Server has a **Stop** feature. When you are not actively developing or -expecting traffic, stop the server — you pay only for storage (~₹280/month), not compute (~₹1,043/month). Restart it -before your next session. The server auto-restarts after 7 days if you forget. - -```bash -# Stop to save credits -az postgres flexible-server stop --resource-group roomies-rg --name roomies-db - -# Start again before developing -az postgres flexible-server start --resource-group roomies-rg --name roomies-db -``` - ---- - -## All Tier 2 Azure Resource Names - -| Resource | Name | URL / Endpoint | -| --------------------- | ----------------- | ------------------------------------------------------------ | -| Resource Group | `roomies-rg` | — | -| PostgreSQL Server | `roomies-db` | `roomies-db.postgres.database.azure.com` | -| PostgreSQL Database | `roomies_db` | — | -| App Service Plan | `roomies-plan` | — | -| App Service (Web App) | `roomies-api` | `roomies-api.azurewebsites.net` | -| 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:6379` | -| Custom Domain | — | `api.roomies.sumitly.app` | diff --git a/docs/roomies_project_plan.md b/docs/roomies_project_plan.md deleted file mode 100644 index a324c40..0000000 --- a/docs/roomies_project_plan.md +++ /dev/null @@ -1,101 +0,0 @@ -# Roomies — Project Plan - -**Student Roommate & PG Discovery Platform — India** - ---- - -## What Roomies Solves - -Roomies is a trust-first platform built for Indian college students seeking paying-guest accommodation and compatible -roommates near their institutions. The platform does not simply list rooms — its core differentiation is a verified -reputation system that cannot be gamed, built on the principle that trust must be earned through confirmed real-world -interactions rather than self-reported claims. - -The platform serves three distinct personas whose interactions are deeply interconnected across every feature. - -| Persona | Registration Path | Primary Actions | -| -------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -| Student | College email → auto-verified via institution domain match | Search listings, send interest requests, confirm connections, earn and give ratings | -| PG Owner | Manual registration → document upload → admin review → verified | Create properties and listings, manage interest requests, confirm stays, earn ratings | -| Admin | Seeded directly into DB with admin role | Review PG owner documents, moderate flagged ratings, manage user accounts | - ---- - -## The Central Design Principle - -Every major architectural decision traces back to a single idea: **trust must be earned, not claimed.** A student cannot -rate a PG owner simply by saying they stayed there. The system requires a two-sided confirmation of the real-world -interaction before any rating is accepted. This constraint lives at the database level, meaning it is physically -impossible to submit a fake review regardless of application bugs or API manipulation. - ---- - -## The WhatsApp Contact Flow - -During the current phase, real-time messaging infrastructure is not yet built. Once a student's interest request is -accepted by a PG owner, the application exposes the poster's WhatsApp number as a `wa.me/91XXXXXXXXXX` deep-link. The -student taps the link and goes directly to WhatsApp. This is intentional — it is how the majority of Indian PG discovery -currently works, it ships with zero external API dependency, and it gets replaced by in-app messaging in a future phase. - ---- - -## Contact Reveal System - -Roomies supports two tiers of contact visibility to balance openness for discovery against protection from scraping. - -Verified users (authenticated with a confirmed email) get unlimited contact reveals — they see both email and WhatsApp -phone number for any user they look up. Guests and unverified users get up to 10 free email-only reveals in a 30-day -rolling window. On the 11th attempt, the API returns a login/signup redirect hint. Counting is backed primarily by Redis -(keyed on a SHA-256 fingerprint of IP + User-Agent) with an HttpOnly cookie as a fallback when Redis is unavailable. - ---- - -## Environment Strategy - -The project runs with two environment files that serve distinct purposes. `.env.local` contains local development -configuration: PostgreSQL on the host machine, local Redis, Ethereal Mail for fake SMTP, and local disk for file -storage. `.env.azure` is used for local connectivity checks against Azure services. Email transport is selected by -`EMAIL_PROVIDER` (`ethereal` for local testing, `brevo` for real delivery), and Brevo credentials are supplied via -`BREVO_SMTP_LOGIN`, `BREVO_SMTP_KEY`, and `BREVO_SMTP_FROM`. - ---- - -## Phase Summary - -| Phase | Focus | Status | What Becomes Possible | -| ------------------------- | ------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------ | -| 1 — Foundation & Identity | Auth, profiles, institutions, Google OAuth, PG owner verification | ✅ Complete | Register, log in, verify email, upload documents, admin approve/reject | -| 2 — Listings & Search | Properties, listings, async photo upload, proximity + compatibility search, saved listings | ✅ Complete | Post listings, search by filters + proximity, compress photos, bookmark listings | -| 3 — Interaction Pipeline | Interest requests, connections, two-sided confirmation, notification feed, ratings baseline | ✅ Complete | Full trust loop: interest → accept → WhatsApp → confirm → rate → notify | -| 4 — Reputation Moderation | Report pipeline, admin moderation, aggregate-safe rating takedowns | ✅ Complete | Report abusive ratings, admin review queue, remove ratings with auto-recalculated averages | -| 5 — Operations & Admin | Cron jobs, email worker, full admin control panel, analytics | 🔄 Partial | Cron maintenance jobs done; admin panel, email worker, and analytics still planned | -| 6 — Real-Time | WebSocket push over Redis pub/sub | ⏳ Deferred | Replace polling with push — requires real user volume to justify infrastructure | - ---- - -## Phase 5 — What Remains - -The three scheduled maintenance cron jobs are written and wired into the server. What still needs to be built is the -email delivery worker (BullMQ-backed, replacing the direct Nodemailer calls), the full admin control panel for user -management and rating visibility management, and the platform analytics endpoint. These will be addressed in -`phase5/admin`. - ---- - -## Phase 6 — Real-Time (Deferred) - -WebSocket infrastructure will be additive — the existing HTTP notification endpoints remain in place. WebSocket push -requires Redis pub/sub for cross-instance fanout, which is necessary on Azure App Service where multiple instances mean -a user's socket may be on a different instance than the one processing the notification job. This phase begins only -after Phase 3 polling is confirmed working with real users. - ---- - -## Future Upgrade Notes - -**View counting on listings:** The current `getListing` implementation increments `views_count` with a detached, -fire-and-forget `pool.query` after the listing read. This is correct for reliability but is a direct database write on -every view. When traffic grows, replace this with a queue/event approach — buffer view events in Redis, batch flush to -Postgres, and optionally deduplicate by user/session/time window. If `views_count` later affects ranking or -monetisation, define whether it should be approximate or strongly consistent before redesigning. Changes would start in -`src/services/listing.service.js` (`getListing`) and extend into a new async analytics component. diff --git a/docs/services/README.md b/docs/services/README.md deleted file mode 100644 index 9e3aa0b..0000000 --- a/docs/services/README.md +++ /dev/null @@ -1,70 +0,0 @@ -# Service Contract Index - -This directory provides one stable place to understand backend behavior service-by-service. - -For each service below, treat source files as canonical in this order: route -> validator -> service -> worker. - -## Auth service - -- Source: `src/services/auth.service.js` -- Scope: Session lifecycle, token rotation, logout semantics, and OTP verification rules. - -## Listing service - -- Source: `src/services/listing.service.js` -- Scope: Search, detail, lifecycle transitions, save/unsave behavior, and pagination contracts. - -## Photo service - -- Source: `src/services/photo.service.js` -- Scope: Listing photo upload lifecycle, placeholder filtering, cover election, and reorder invariants. - -## Interest service - -- Source: `src/services/interest.service.js` -- Scope: Interest request creation, transitions, and accept-flow side effects. - -## Connection service - -- Source: `src/services/connection.service.js` -- Scope: Two-party connection reads and confirmation state transitions. - -## Notification service - -- Source: `src/services/notification.service.js` -- Scope: Notification feed reads, unread count, and mark-read updates. - -## Verification service - -- Source: `src/services/verification.service.js` -- Scope: PG owner verification request submission and admin decision state changes. - -## Preferences service - -- Source: `src/services/preferences.service.js` -- Scope: Student preference CRUD and metadata-backed validation behavior. - -## Property service - -- Source: `src/services/property.service.js` -- Scope: PG owner property CRUD and ownership guards. - -## Ratings & reports service - -- Source: `src/services/rating.service.js` -- Scope: Rating creation/reads and reporting lifecycle constraints. - -## Student service - -- Source: `src/services/student.service.js` -- Scope: Student profile reads, updates, and contact reveal shape controls. - -## PG owner service - -- Source: `src/services/pgOwner.service.js` -- Scope: PG owner profile reads, updates, and contact reveal shape controls. - -## Report service - -- Source: `src/services/report.service.js` -- Scope: Admin report queue and resolution updates. From d20d591d2f8abacf9f0cfc8a8915dc91a3f6ad0b Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Sat, 25 Apr 2026 00:23:00 +0530 Subject: [PATCH 18/54] Unwatned Comments Removed. --- migrations/001_initial_schema.sql | 2 +- migrations/002_verification_event_outbox.sql | 2 +- src/app.js | 62 ++-- src/cache/client.js | 2 +- src/config/constants.js | 2 +- src/config/preferences.js | 50 +--- src/controllers/auth.controller.js | 24 +- src/controllers/connection.controller.js | 34 +-- src/controllers/interest.controller.js | 20 +- src/controllers/listing.controller.js | 6 +- src/controllers/notification.controller.js | 8 +- src/controllers/pgOwner.controller.js | 12 +- src/controllers/photo.controller.js | 12 +- src/controllers/preferences.controller.js | 2 +- src/controllers/property.controller.js | 2 +- src/controllers/rating.controller.js | 56 ++-- src/controllers/report.controller.js | 42 +-- src/controllers/student.controller.js | 22 +- src/controllers/verification.controller.js | 2 +- src/cron/expiryWarning.js | 129 ++------ src/cron/hardDeleteCleanup.js | 298 +++++++++---------- src/cron/listingExpiry.js | 86 +++--- src/db/client.js | 36 +-- src/db/migrate.js | 128 ++++---- src/db/seeds/amenities.js | 132 ++++---- src/db/utils/auth.js | 36 +-- src/db/utils/compatibility.js | 4 +- src/db/utils/institutions.js | 30 +- src/db/utils/pgOwner.js | 32 +- src/db/utils/spatial.js | 12 +- src/logger/index.js | 4 +- src/middleware/authenticate.js | 60 ++-- src/middleware/authorize.js | 22 +- src/middleware/contactRevealGate.js | 224 +++++++------- src/middleware/errorHandler.js | 10 +- src/middleware/guestListingGate.js | 54 ++-- src/middleware/optionalAuthenticate.js | 2 +- src/middleware/rateLimiter.js | 58 ++-- src/middleware/upload.js | 10 +- src/middleware/validate.js | 26 +- src/routes/amenities.js | 14 +- src/routes/auth.js | 32 +- src/routes/connection.js | 74 ++--- src/routes/health.js | 56 ++-- src/routes/index.js | 2 +- src/routes/interest.js | 88 +++--- src/routes/listing.js | 64 ++-- src/routes/notification.js | 28 +- src/routes/pgOwner.js | 32 +- src/routes/preferences.js | 2 +- src/routes/property.js | 42 +-- src/routes/rating.js | 60 ++-- src/routes/student.js | 42 +-- src/routes/testUtils.js | 40 +-- src/server.js | 30 +- src/services/auth.service.js | 86 +++--- src/services/connection.service.js | 44 +-- src/services/email.service.js | 292 +++++++++--------- src/services/interest.service.js | 44 +-- src/services/listing.service.js | 54 ++-- src/services/notification.service.js | 158 +++++----- src/services/pgOwner.service.js | 32 +- src/services/photo.service.js | 256 ++++++++-------- src/services/preferences.service.js | 2 +- src/services/property.service.js | 46 +-- src/services/rating.service.js | 64 ++-- src/services/report.service.js | 56 ++-- src/services/student.service.js | 42 +-- src/services/verification.service.js | 36 +-- src/validators/auth.validators.js | 92 +++--- src/validators/connection.validators.js | 84 +++--- src/validators/interest.validators.js | 94 +++--- src/validators/listing.validators.js | 36 +-- src/validators/notification.validators.js | 24 +- src/validators/pagination.validators.js | 2 +- src/validators/pgOwner.validators.js | 12 +- src/validators/photo.validators.js | 54 ++-- src/validators/preferences.validators.js | 6 +- src/validators/property.validators.js | 20 +- src/validators/rating.validators.js | 18 +- src/validators/report.validators.js | 50 ++-- src/validators/student.validators.js | 10 +- src/validators/verification.validators.js | 32 +- src/workers/emailQueue.js | 126 ++++---- src/workers/emailWorker.js | 100 +++---- src/workers/mediaProcessor.js | 18 +- src/workers/notificationQueue.js | 26 +- src/workers/notificationWorker.js | 122 ++++---- src/workers/queue.js | 22 +- src/workers/verificationEventWorker.js | 188 ++++++------ 90 files changed, 2300 insertions(+), 2409 deletions(-) diff --git a/migrations/001_initial_schema.sql b/migrations/001_initial_schema.sql index bacb506..787e5f4 100644 --- a/migrations/001_initial_schema.sql +++ b/migrations/001_initial_schema.sql @@ -1,4 +1,4 @@ --- Active: 1774765370673@@127.0.0.1@5432@roomies_db + CREATE EXTENSION IF NOT EXISTS postgis; CREATE EXTENSION IF NOT EXISTS pgcrypto; diff --git a/migrations/002_verification_event_outbox.sql b/migrations/002_verification_event_outbox.sql index 3105126..01293fc 100644 --- a/migrations/002_verification_event_outbox.sql +++ b/migrations/002_verification_event_outbox.sql @@ -1,4 +1,4 @@ --- Active: 1774765370673@@127.0.0.1@5432@roomies_db + DO $$ BEGIN IF NOT EXISTS ( diff --git a/src/app.js b/src/app.js index 4c0c14a..6dc5e6b 100644 --- a/src/app.js +++ b/src/app.js @@ -1,4 +1,4 @@ -// src/app.js + import express from "express"; import helmet from "helmet"; @@ -14,30 +14,30 @@ export const app = express(); app.set("trust proxy", config.TRUST_PROXY); -// ─── Security headers ────────────────────────────────────────────────────── -// Relax helmet's default restrictions for cross-origin API access. -// The frontend needs to read response bodies from a different origin. + + + app.use( helmet({ crossOriginResourcePolicy: { policy: "cross-origin" }, }), ); -// ─── CORS ────────────────────────────────────────────────────────────────── -// -// Cross-origin cookie rules (for reference): -// sameSite: "none" + secure: true → browser sends cookies cross-site -// sameSite: "strict" → browser NEVER sends cookies cross-site -// sameSite: "lax" → only sent on top-level GET navigations -// -// Since our frontend is on a different domain from the backend, we need: -// 1. credentials: true → sends Access-Control-Allow-Credentials -// 2. An explicit origin (not "*") → required when credentials: true -// 3. Cookies set with sameSite: "none" + secure (handled in authenticate.js) -// -// In development: origin: true reflects the incoming Origin header — works for -// any localhost port. -// In production: explicit allowlist from ALLOWED_ORIGINS. + + + + + + + + + + + + + + + if (config.NODE_ENV !== "development" && config.ALLOWED_ORIGINS.length === 0) { logger.fatal( @@ -51,46 +51,46 @@ if (config.NODE_ENV !== "development" && config.ALLOWED_ORIGINS.length === 0) { app.use( cors({ origin: (origin, callback) => { - // Allow requests with no origin (Postman, curl, server-to-server) + if (!origin) return callback(null, true); if (config.NODE_ENV === "development") { - // In development, allow any origin (localhost on any port) + return callback(null, true); } - // In production, check against the explicit allowlist + if (config.ALLOWED_ORIGINS.includes(origin)) { return callback(null, true); } callback(new Error(`CORS: origin '${origin}' is not allowed`)); }, - // credentials: true is REQUIRED for cross-origin cookies AND for the - // browser to expose response headers/body on credentialed requests. + + credentials: true, - // Expose headers the frontend may need to read + 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", @@ -98,5 +98,5 @@ app.use((req, res) => { }); }); -// ─── Global error handler ────────────────────────────────────────────────── + app.use(errorHandler); diff --git a/src/cache/client.js b/src/cache/client.js index 104fcd1..33f0fd8 100644 --- a/src/cache/client.js +++ b/src/cache/client.js @@ -1,4 +1,4 @@ -// src/cache/client.js + import { createClient } from "redis"; import { config } from "../config/env.js"; diff --git a/src/config/constants.js b/src/config/constants.js index 5dc6021..0a017d7 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -1,4 +1,4 @@ -// src/config/constants.js + export const MAX_UPLOAD_SIZE_BYTES = 10 * 1024 * 1024; 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 2e37d38..f8e83b9 100644 --- a/src/controllers/auth.controller.js +++ b/src/controllers/auth.controller.js @@ -1,4 +1,4 @@ -// src/controllers/auth.controller.js + import * as authService from "../services/auth.service.js"; import { parseTtlSeconds } from "../services/auth.service.js"; @@ -6,12 +6,12 @@ import { AppError } from "../middleware/errorHandler.js"; import { config } from "../config/env.js"; import { ACCESS_COOKIE_OPTIONS, REFRESH_COOKIE_OPTIONS } from "../middleware/authenticate.js"; -// Determines whether the caller is a non-browser client (e.g. Android) or a -// cross-origin SPA that uses the bearer transport instead of cookies. -// The frontend sends X-Client-Transport: bearer in production. + + + const isBearerTransport = (req) => req.headers["x-client-transport"] === "bearer"; -// Builds the safe body payload for cookie-mode responses. + const buildSafeBody = (tokens) => ({ user: tokens.user, sid: tokens.sid, @@ -23,7 +23,7 @@ const setAuthCookies = (res, accessToken, refreshToken) => { }; const clearAuthCookies = (res) => { - // Must use the same sameSite/secure settings to clear correctly + res.clearCookie("accessToken", { httpOnly: true, secure: ACCESS_COOKIE_OPTIONS.secure, @@ -36,16 +36,16 @@ const clearAuthCookies = (res) => { }); }; -// ─── Controllers ────────────────────────────────────────────────────────────── + export const register = async (req, res, next) => { try { const tokens = await authService.register(req.body); setAuthCookies(res, tokens.accessToken, tokens.refreshToken); - // Always return full token data — the frontend needs it in production - // where cookies can't cross domains. In cookie-mode the frontend ignores - // the tokens in the body and relies on cookies. + + + res.status(201).json({ status: "success", data: tokens }); } catch (err) { next(err); @@ -57,7 +57,7 @@ export const login = async (req, res, next) => { const tokens = await authService.login(req.body); setAuthCookies(res, tokens.accessToken, tokens.refreshToken); - // Return full tokens unconditionally. Same reasoning as register above. + res.json({ status: "success", data: tokens }); } catch (err) { next(err); @@ -104,7 +104,7 @@ export const refresh = async (req, res, next) => { setAuthCookies(res, tokens.accessToken, tokens.refreshToken); - // Always return full tokens (bearer transport + cookie both work) + res.json({ status: "success", data: tokens }); } catch (err) { next(err); diff --git a/src/controllers/connection.controller.js b/src/controllers/connection.controller.js index cb8fc3c..12e04b3 100644 --- a/src/controllers/connection.controller.js +++ b/src/controllers/connection.controller.js @@ -1,14 +1,14 @@ -// 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 +18,11 @@ 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 +32,10 @@ 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..f9e85d6 100644 --- a/src/controllers/interest.controller.js +++ b/src/controllers/interest.controller.js @@ -1,17 +1,17 @@ -// 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 +19,10 @@ 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..833bd10 100644 --- a/src/controllers/listing.controller.js +++ b/src/controllers/listing.controller.js @@ -1,4 +1,4 @@ -// src/controllers/listing.controller.js + import * as listingService from "../services/listing.service.js"; @@ -20,8 +20,8 @@ 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/notification.controller.js b/src/controllers/notification.controller.js index 0b41b7b..01d411f 100644 --- a/src/controllers/notification.controller.js +++ b/src/controllers/notification.controller.js @@ -1,8 +1,8 @@ -// 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 +12,7 @@ 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 +22,7 @@ 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..e68b4e1 100644 --- a/src/controllers/pgOwner.controller.js +++ b/src/controllers/pgOwner.controller.js @@ -1,4 +1,4 @@ -// src/controllers/pgOwner.controller.js + import * as pgOwnerService from "../services/pgOwner.service.js"; import { AppError } from "../middleware/errorHandler.js"; @@ -23,11 +23,11 @@ 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..a4fa0c8 100644 --- a/src/controllers/photo.controller.js +++ b/src/controllers/photo.controller.js @@ -1,4 +1,4 @@ -// src/controllers/photo.controller.js + import * as photoService from "../services/photo.service.js"; import { rm } from "node:fs/promises"; @@ -9,7 +9,7 @@ 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 +26,12 @@ 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); diff --git a/src/controllers/preferences.controller.js b/src/controllers/preferences.controller.js index 112995c..a064b23 100644 --- a/src/controllers/preferences.controller.js +++ b/src/controllers/preferences.controller.js @@ -1,4 +1,4 @@ -// src/controllers/preferences.controller.js + import * as preferencesService from "../services/preferences.service.js"; diff --git a/src/controllers/property.controller.js b/src/controllers/property.controller.js index f40a116..b98a741 100644 --- a/src/controllers/property.controller.js +++ b/src/controllers/property.controller.js @@ -1,4 +1,4 @@ -// src/controllers/property.controller.js + import * as propertyService from "../services/property.service.js"; diff --git a/src/controllers/rating.controller.js b/src/controllers/rating.controller.js index 5fa6f27..16c0d20 100644 --- a/src/controllers/rating.controller.js +++ b/src/controllers/rating.controller.js @@ -1,19 +1,19 @@ -// 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 +23,10 @@ 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 +36,10 @@ 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 +49,9 @@ 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 +61,10 @@ 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/report.controller.js b/src/controllers/report.controller.js index 1cb2c07..96fb778 100644 --- a/src/controllers/report.controller.js +++ b/src/controllers/report.controller.js @@ -1,16 +1,16 @@ -// 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; -// 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,8 +20,8 @@ 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; @@ -37,15 +37,15 @@ export const getReportQueue = async (req, res, next) => { 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 = @@ -62,8 +62,8 @@ export const getReportQueue = async (req, res, next) => { } }; -// PATCH /api/v1/admin/reports/:reportId/resolve -// Admin-only. Closes the report with either resolved_removed or resolved_kept. + + 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/student.controller.js b/src/controllers/student.controller.js index da91f74..7c5e537 100644 --- a/src/controllers/student.controller.js +++ b/src/controllers/student.controller.js @@ -1,4 +1,4 @@ -// src/controllers/student.controller.js + import * as studentService from "../services/student.service.js"; import { AppError } from "../middleware/errorHandler.js"; @@ -45,20 +45,20 @@ 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..3de81ae 100644 --- a/src/controllers/verification.controller.js +++ b/src/controllers/verification.controller.js @@ -1,4 +1,4 @@ -// src/controllers/verification.controller.js + import * as verificationService from "../services/verification.service.js"; diff --git a/src/cron/expiryWarning.js b/src/cron/expiryWarning.js index 2f2edb5..2b2519e 100644 --- a/src/cron/expiryWarning.js +++ b/src/cron/expiryWarning.js @@ -1,67 +1,4 @@ -// 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"; @@ -70,26 +7,26 @@ 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 +42,11 @@ 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 +68,12 @@ 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 +87,18 @@ 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 +120,15 @@ 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 +136,7 @@ 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..c2e1dea 100644 --- a/src/cron/hardDeleteCleanup.js +++ b/src/cron/hardDeleteCleanup.js @@ -1,87 +1,87 @@ -// 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"; -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]+$/; @@ -138,14 +138,14 @@ const runHardDeleteCleanup = async () => { 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. + + const { rowCount: rr } = await client.query( `DELETE FROM rating_reports WHERE deleted_at IS NOT NULL @@ -154,9 +154,9 @@ 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. + + + const { rowCount: ra } = await client.query( `DELETE FROM ratings WHERE deleted_at IS NOT NULL @@ -165,9 +165,9 @@ 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. + + + const { rowCount: no } = await client.query( `DELETE FROM notifications WHERE deleted_at IS NOT NULL @@ -176,16 +176,16 @@ 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. + + + + + + + + + + const { rowCount: co } = await client.query( `DELETE FROM connections WHERE deleted_at IS NOT NULL @@ -199,8 +199,8 @@ 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. + + const { rowCount: ir } = await client.query( `DELETE FROM interest_requests WHERE deleted_at IS NOT NULL @@ -209,7 +209,7 @@ const runHardDeleteCleanup = async () => { ); results.interest_requests = ir; - // ── Step 6: saved_listings (depends on listings and users) ──────────── + const { rowCount: sl } = await client.query( `DELETE FROM saved_listings WHERE deleted_at IS NOT NULL @@ -218,7 +218,7 @@ const runHardDeleteCleanup = async () => { ); results.saved_listings = sl; - // ── Step 7: listing_photos (soft-deleted photos outliving their listing) ─ + const { rowCount: lp } = await client.query( `DELETE FROM listing_photos WHERE deleted_at IS NOT NULL @@ -227,19 +227,19 @@ 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( `DELETE FROM listings WHERE deleted_at IS NOT NULL @@ -262,14 +262,14 @@ const runHardDeleteCleanup = async () => { 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. + + + p, ); results.listings = li; - // ── Step 9: verification_requests (depends on users) ────────────────── + const { rowCount: vr } = await client.query( `DELETE FROM verification_requests WHERE deleted_at IS NOT NULL @@ -278,8 +278,8 @@ 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. + + const { rowCount: pop } = await client.query( `DELETE FROM pg_owner_profiles WHERE deleted_at IS NOT NULL @@ -296,15 +296,15 @@ 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. + + + + + + + + + const { rowCount: pr } = await client.query( `DELETE FROM properties WHERE deleted_at IS NOT NULL @@ -319,9 +319,9 @@ 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. + + + const { rowCount: ins } = await client.query( `DELETE FROM institutions WHERE deleted_at IS NOT NULL @@ -330,23 +330,23 @@ 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. + + + + + + + + + + + + + + + + + const { rowCount: us } = await client.query( `DELETE FROM users WHERE deleted_at IS NOT NULL diff --git a/src/cron/listingExpiry.js b/src/cron/listingExpiry.js index 0b3e92b..2b1dd7a 100644 --- a/src/cron/listingExpiry.js +++ b/src/cron/listingExpiry.js @@ -1,35 +1,35 @@ -// 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 +42,9 @@ 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 +58,9 @@ 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 +81,8 @@ 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 +107,17 @@ 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/db/client.js b/src/db/client.js index 1de4d13..9896968 100644 --- a/src/db/client.js +++ b/src/db/client.js @@ -18,21 +18,21 @@ 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. + + + + + + + + + + + + + + + pool.on("error", (err) => { logger.error({ err }, "pg pool: unexpected error on idle client"); @@ -42,9 +42,9 @@ pool.on("error", (err) => { } }); -// 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..8217968 100644 --- a/src/db/migrate.js +++ b/src/db/migrate.js @@ -1,40 +1,40 @@ -// 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"; @@ -43,9 +43,9 @@ 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 +60,24 @@ 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,7 +86,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations ( ); `; -// ─── Main ───────────────────────────────────────────────────────────────────── + const run = async () => { const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); @@ -95,21 +95,21 @@ 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 +121,12 @@ 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 +144,7 @@ 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 +159,7 @@ const run = async () => { return; } - // ── Apply pending migrations ───────────────────────────────────────── + const pending = files.filter((f) => !applied.has(f)); if (pending.length === 0) { @@ -176,7 +176,7 @@ 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 +185,9 @@ 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,11 +199,11 @@ const run = async () => { console.log("✅"); } catch (err) { - // Roll back the failed migration transaction. + try { await client.query("ROLLBACK"); } catch (_) { - /* ignore */ + } console.log("❌"); diff --git a/src/db/seeds/amenities.js b/src/db/seeds/amenities.js index b54a83b..872586f 100644 --- a/src/db/seeds/amenities.js +++ b/src/db/seeds/amenities.js @@ -1,61 +1,61 @@ -// 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 +63,14 @@ 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 +84,16 @@ 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 +107,11 @@ 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..f3e37b5 100644 --- a/src/db/utils/auth.js +++ b/src/db/utils/auth.js @@ -1,4 +1,4 @@ -// src/db/utils/auth.js + import { pool } from "../client.js"; @@ -36,7 +36,7 @@ export const findUserById = async (id, client = pool) => { } return user; - // return rows[0] ?? null; + }; export const findUserByEmail = async (email, client = pool) => { @@ -56,22 +56,22 @@ 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..a5aa100 100644 --- a/src/db/utils/compatibility.js +++ b/src/db/utils/compatibility.js @@ -1,5 +1,5 @@ -// src/db/utils/compatibility.js -// + + import { pool } from "../client.js"; diff --git a/src/db/utils/institutions.js b/src/db/utils/institutions.js index 2c56aee..f34316a 100644 --- a/src/db/utils/institutions.js +++ b/src/db/utils/institutions.js @@ -1,20 +1,20 @@ 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..cd36126 100644 --- a/src/db/utils/pgOwner.js +++ b/src/db/utils/pgOwner.js @@ -1,23 +1,23 @@ -// 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/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..9585696 100644 --- a/src/logger/index.js +++ b/src/logger/index.js @@ -1,11 +1,11 @@ -// 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 dcf74af..ae38a2a 100644 --- a/src/middleware/authenticate.js +++ b/src/middleware/authenticate.js @@ -10,32 +10,32 @@ 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); -// ─── Cross-origin cookie policy ─────────────────────────────────────────────── -// -// In production the frontend (Vercel) and backend (Render) are on different -// domains. Browsers apply the following rules: -// -// sameSite: "strict" → cookie is NEVER sent cross-site, even with -// credentials: "include". Auth breaks completely. -// sameSite: "lax" → cookie is sent on top-level navigations (GET) but -// NOT on cross-origin fetch with credentials. Still -// broken for API calls. -// sameSite: "none" → sent on all cross-site requests, but REQUIRES -// secure: true (HTTPS only). -// -// In practice, for a cross-domain SPA + API architecture the correct approach -// is to use sameSite: "none" + secure: true in production so the browser -// actually sends the cookie on every credentialed fetch. -// -// We also support the X-Client-Transport: bearer header (sent by the frontend -// in production). When present, the auth controller returns tokens in the JSON -// body AND sets cookies. The frontend stores tokens in memory/sessionStorage -// and sends them as Authorization: Bearer headers — completely bypassing the -// cross-domain cookie problem. -// -// Cookie options for each environment: -// development → sameSite: "lax", secure: false (localhost HTTP) -// production → sameSite: "none", secure: true (cross-domain HTTPS) + + + + + + + + + + + + + + + + + + + + + + + + + + const IS_PROD = config.NODE_ENV === "production"; @@ -56,8 +56,8 @@ export const REFRESH_COOKIE_OPTIONS = { const refreshTokenKey = (userId, sid) => `refreshToken:${userId}:${sid}`; const extractToken = (req) => { - // Authorization: Bearer takes priority over cookies for - // cross-origin clients that use the bearer transport. + + const authHeader = req.headers.authorization; if (authHeader && authHeader.startsWith("Bearer ")) { return { token: authHeader.slice(7), source: "header" }; @@ -148,8 +148,8 @@ export const authenticate = async (req, res, next) => { try { payload = jwt.verify(token, config.JWT_SECRET); } catch (err) { - // Only attempt silent cookie-based refresh for cookie-sourced tokens. - // Header-sourced tokens (bearer transport) handle refresh client-side. + + if (err.name === "TokenExpiredError" && source === "cookie") { const session = await attemptSilentRefresh(req, res); if (!session?.userId) { diff --git a/src/middleware/authorize.js b/src/middleware/authorize.js index 766cd33..b404452 100644 --- a/src/middleware/authorize.js +++ b/src/middleware/authorize.js @@ -1,13 +1,13 @@ import { AppError } from "./errorHandler.js"; -// Role gate — must run after authenticate (req.user must exist). -// 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( @@ -21,9 +21,9 @@ 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..cd6f247 100644 --- a/src/middleware/contactRevealGate.js +++ b/src/middleware/contactRevealGate.js @@ -1,52 +1,52 @@ -// 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"; @@ -54,14 +54,14 @@ 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 +102,40 @@ 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 +143,10 @@ 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 +173,9 @@ 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 +186,9 @@ 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 +196,8 @@ 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 +205,8 @@ 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 +217,16 @@ 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 +235,11 @@ 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 +256,7 @@ 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 +264,16 @@ 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 d519e62..36f5b2a 100644 --- a/src/middleware/errorHandler.js +++ b/src/middleware/errorHandler.js @@ -20,13 +20,13 @@ 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( { @@ -49,8 +49,8 @@ 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( @@ -87,7 +87,7 @@ 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 }, diff --git a/src/middleware/guestListingGate.js b/src/middleware/guestListingGate.js index 8b98d9d..68d1772 100644 --- a/src/middleware/guestListingGate.js +++ b/src/middleware/guestListingGate.js @@ -1,30 +1,30 @@ -// 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. -// -// 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. -// -// - 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; diff --git a/src/middleware/optionalAuthenticate.js b/src/middleware/optionalAuthenticate.js index 1cba223..b8dde7d 100644 --- a/src/middleware/optionalAuthenticate.js +++ b/src/middleware/optionalAuthenticate.js @@ -1,4 +1,4 @@ -// src/middleware/optionalAuthenticate.js + import jwt from "jsonwebtoken"; import { config } from "../config/env.js"; diff --git a/src/middleware/rateLimiter.js b/src/middleware/rateLimiter.js index b927d5d..7785181 100644 --- a/src/middleware/rateLimiter.js +++ b/src/middleware/rateLimiter.js @@ -1,19 +1,19 @@ -// 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"; @@ -21,7 +21,7 @@ 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,11 +54,11 @@ 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) { @@ -70,14 +70,14 @@ export const closeRateLimitRedisClient = async () => { } }; -// ─── 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 +91,7 @@ export const otpLimiter = rateLimit({ }, }); -// ─── Auth endpoints — register / login / refresh ───────────────────────────── + export const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10, @@ -105,10 +105,10 @@ 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/upload.js b/src/middleware/upload.js index 28daf07..fb2932b 100644 --- a/src/middleware/upload.js +++ b/src/middleware/upload.js @@ -5,8 +5,8 @@ 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"], @@ -36,9 +36,9 @@ 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 557160e..5ee6718 100644 --- a/src/middleware/validate.js +++ b/src/middleware/validate.js @@ -1,16 +1,16 @@ -// 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({ diff --git a/src/routes/amenities.js b/src/routes/amenities.js index a1bb1a3..005db26 100644 --- a/src/routes/amenities.js +++ b/src/routes/amenities.js @@ -1,16 +1,16 @@ -// src/routes/amenities.js -// Public read-only endpoint to list all amenities. -// Used by the frontend AmenityPicker component when creating/editing properties and listings. -// No auth required — amenity catalog is not sensitive. + + + + import { Router } from "express"; import { pool } from "../db/client.js"; export const amenitiesRouter = Router(); -// GET /api/v1/amenities -// Returns all amenities grouped by category, ordered by category then name. -// Response: { status: "success", data: { items: Amenity[] } } + + + amenitiesRouter.get("/", async (req, res, next) => { try { const { rows } = await pool.query( diff --git a/src/routes/auth.js b/src/routes/auth.js index 32ecb57..291692c 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -16,22 +16,22 @@ 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); @@ -44,13 +44,13 @@ 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); diff --git a/src/routes/connection.js b/src/routes/connection.js index c965393..22eb2df 100644 --- a/src/routes/connection.js +++ b/src/routes/connection.js @@ -1,26 +1,26 @@ -// 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"; @@ -30,20 +30,20 @@ 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 +51,10 @@ 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..7c86675 100644 --- a/src/routes/health.js +++ b/src/routes/health.js @@ -1,4 +1,4 @@ -// src/routes/health.js + import { Router } from "express"; import { pool } from "../db/client.js"; @@ -9,34 +9,34 @@ 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 +44,10 @@ 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 +63,12 @@ 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 7fd4f34..fd64ffb 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -1,4 +1,4 @@ -// src/routes/index.js + import { Router } from "express"; import { healthRouter } from "./health.js"; diff --git a/src/routes/interest.js b/src/routes/interest.js index 42a76db..6db1e65 100644 --- a/src/routes/interest.js +++ b/src/routes/interest.js @@ -1,31 +1,31 @@ -// 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"; @@ -40,11 +40,11 @@ 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 +53,21 @@ 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..cb0024e 100644 --- a/src/routes/listing.js +++ b/src/routes/listing.js @@ -1,22 +1,22 @@ -// 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"; @@ -47,9 +47,9 @@ import { UPLOAD_FIELD_NAME } from "../config/constants.js"; export const listingRouter = Router(); -// ─── Static routes first — must precede /:listingId ────────────────────────── -// Saved listings: auth required (student only) + + listingRouter.get( "/me/saved", authenticate, @@ -58,10 +58,10 @@ 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 +70,12 @@ 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 +84,7 @@ 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 +96,7 @@ listingRouter.patch( listingController.updateListingStatus, ); -// ─── Child resource routes ──────────────────────────────────────────────────── + listingRouter.get( "/:listingId/preferences", @@ -128,7 +128,7 @@ listingRouter.delete( listingController.unsaveListing, ); -// ─── Photo routes (child resource of /:listingId) ──────────────────────────── + listingRouter.get("/:listingId/photos", authenticate, validate(uploadPhotoSchema), photoController.getPhotos); @@ -161,7 +161,7 @@ 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"; diff --git a/src/routes/notification.js b/src/routes/notification.js index 2347d8e..5f67775 100644 --- a/src/routes/notification.js +++ b/src/routes/notification.js @@ -1,11 +1,11 @@ -// 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"; @@ -15,14 +15,14 @@ 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 eb74cb2..c7aa928 100644 --- a/src/routes/pgOwner.js +++ b/src/routes/pgOwner.js @@ -1,4 +1,4 @@ -// src/routes/pgOwner.js + import { Router } from "express"; import { authenticate } from "../middleware/authenticate.js"; @@ -15,26 +15,26 @@ export const pgOwnerRouter = Router(); 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.post( "/:userId/contact/reveal", optionalAuthenticate, validate(getPgOwnerParamsSchema), (req, res, next) => { - // Prevent any caching of the PII response by browsers, CDNs, or proxies. + res.setHeader("Cache-Control", "no-store"); next(); }, diff --git a/src/routes/preferences.js b/src/routes/preferences.js index 9c43fd7..f679036 100644 --- a/src/routes/preferences.js +++ b/src/routes/preferences.js @@ -1,4 +1,4 @@ -// src/routes/preferences.js + import { Router } from "express"; import { authenticate } from "../middleware/authenticate.js"; diff --git a/src/routes/property.js b/src/routes/property.js index 4cadef2..4c3dc57 100644 --- a/src/routes/property.js +++ b/src/routes/property.js @@ -1,4 +1,4 @@ -// src/routes/property.js + import { Router } from "express"; import { authenticate } from "../middleware/authenticate.js"; @@ -14,28 +14,28 @@ 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..f7fe583 100644 --- a/src/routes/rating.js +++ b/src/routes/rating.js @@ -1,19 +1,19 @@ -// 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"; @@ -32,13 +32,13 @@ 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 +62,15 @@ 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/student.js b/src/routes/student.js index 5a48a45..c59189e 100644 --- a/src/routes/student.js +++ b/src/routes/student.js @@ -1,4 +1,4 @@ -// src/routes/student.js + import { Router } from "express"; import { authenticate } from "../middleware/authenticate.js"; @@ -18,30 +18,30 @@ export const studentRouter = Router(); 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 + + + + + + + + + + + + + + + + + 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(); diff --git a/src/routes/testUtils.js b/src/routes/testUtils.js index 32592d8..4d0e382 100644 --- a/src/routes/testUtils.js +++ b/src/routes/testUtils.js @@ -1,8 +1,8 @@ -// 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"; @@ -10,20 +10,20 @@ 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 +34,8 @@ 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 +44,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/server.js b/src/server.js index 5777767..8c4fd68 100644 --- a/src/server.js +++ b/src/server.js @@ -37,21 +37,21 @@ const start = async () => { 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`); diff --git a/src/services/auth.service.js b/src/services/auth.service.js index e614f08..66024b0 100644 --- a/src/services/auth.service.js +++ b/src/services/auth.service.js @@ -1,4 +1,4 @@ -// src/services/auth.service.js + import bcrypt from "bcryptjs"; import jwt from "jsonwebtoken"; @@ -102,29 +102,29 @@ 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 +137,12 @@ 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 +198,8 @@ 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 +503,18 @@ 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"); @@ -605,7 +605,7 @@ 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 +632,9 @@ 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 +687,7 @@ 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..fa0bbef 100644 --- a/src/services/connection.service.js +++ b/src/services/connection.service.js @@ -1,14 +1,14 @@ -// 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 +18,9 @@ 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 +38,8 @@ 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 +74,9 @@ 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); @@ -89,7 +89,7 @@ export const confirmConnection = async (callerId, connectionId) => { try { await client.query("ROLLBACK"); } catch (_) { - // Ignore — connection may already be in an error state. + } } throw err; @@ -136,10 +136,10 @@ 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 +239,12 @@ 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 33ce3c1..606bfbb 100644 --- a/src/services/email.service.js +++ b/src/services/email.service.js @@ -1,65 +1,65 @@ -// 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"; @@ -68,14 +68,14 @@ 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 +83,13 @@ 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 +104,26 @@ 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 +136,8 @@ 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 +146,39 @@ 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); } @@ -206,12 +206,12 @@ export const sendOtpEmail = async (to, otp) => { 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 + + + + +
+
Two orthogonal features. The roommate matcher surfaces user-to-user preference compatibility so students find people to share with. The proper rent system adds a verified rent layer — crowdsourced fair-market rent data for a city/locality so users know if a listing is priced fairly.
+ +

Roommate matching — what we're building

+

Currently scoreListingsForUser counts how many listing preferences overlap a student's own preferences — user-vs-listing matching. We now need user-vs-user matching: "which other students have preferences close to mine?" This powers a "Find a Roommate" feed separate from the listing search.

+ +
+
+
New
+

User-vs-user scoring

+

Jaccard similarity on user_preferences. Both the preference key and value must match. Score = shared / total union preferences.

+
+
+
New
+

Roommate feed

+

Paginated list of students sorted by compatibility. Only shows users who have opted in and have a profile set to "looking". Cursor-based, same pattern as listing search.

+
+
+
Extend existing
+

Listing search enrichment

+

Existing compatibilityScore stays unchanged. Listing cards can also expose a "co-tenants" field showing compatibility with accepted co-tenants on shared rooms.

+
+
+
New
+

Proper rent layer

+

Crowdsourced fair-market rent for city+locality+room_type. Listings get a rentDeviation field: how far the posted rent is from the locality median.

+
+
+ +

Design principles — unchanged from existing codebase

+
+ Keyset pagination everywhere + Zod validators at the boundary + Service layer owns business logic + No raw process.env — config/env.js + BullMQ for async work + Soft-delete, never hard-delete from app code + pg pool, transactions via pool.connect() +
+
+ + +
+

New tables

+ +

005_roommate_matching.sql

+
-- Opt-in flag on student profiles (add column, no new table)
+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,            -- "I'm a 2nd-year CSE student..."
+  ADD COLUMN IF NOT EXISTS looking_updated_at TIMESTAMPTZ;
+
+CREATE INDEX IF NOT EXISTS idx_student_roommate_lookup
+  ON student_profiles (user_id)
+  WHERE looking_for_roommate = TRUE AND deleted_at IS NULL;
+
+-- Blocked users (student can hide from feed)
+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)
+);
+ +

006_rent_index.sql

+
-- Crowdsourced rent observations — one row per listing per snapshot
+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),
+  room_type      room_type_enum NOT NULL,
+  rent_per_month INTEGER NOT NULL,          -- paise, same as listings
+  observed_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  -- source: 'listing_created' | 'listing_renewed' | 'manual'
+  source         VARCHAR(30) NOT NULL DEFAULT 'listing_created'
+);
+
+CREATE INDEX IF NOT EXISTS idx_rent_obs_lookup
+  ON rent_observations (city, locality, room_type, observed_at DESC)
+  WHERE observed_at > NOW() - INTERVAL '180 days';
+
+-- Materialised summary refreshed by cron (city+locality+room_type median)
+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)
+);
+
+CREATE INDEX IF NOT EXISTS idx_rent_index_lookup
+  ON rent_index (city, locality, room_type);
+ +
Rent is stored in paise throughout (same as listings.rent_per_month). Division by 100 happens only at the service layer when building the JSON response. Never store rupees in the DB.
+ +

Existing tables — no structural changes needed

+ + + + + + + +
TableUsed byNotes
user_preferencesRoommate scoringAlready has (user_id, preference_key, preference_value). The matcher JOINs two copies of this table.
student_profilesRoommate feedGets two new columns via migration 005.
listingsRent observationsTrigger on INSERT + status change populates rent_observations automatically.
+
+ + +
+

User-vs-user similarity

+

We use Jaccard similarity: |A ∩ B| / |A ∪ B| where A and B are sets of (preference_key, preference_value) pairs. Both key and value must match — having smoking=smoker vs smoking=non_smoker is a mismatch, not a half-match.

+ +
Jaccard is cheap at this scale (7 preference keys max per user). No vector embeddings needed. The SQL self-join runs in milliseconds for a page of 20 candidates.
+ +

Core query — src/db/utils/roommateCompatibility.js

+
export const scoreUsersForUser = async (requestingUserId, candidateIds, client = pool) => {
+  if (!candidateIds.length) return {};
+
+  // Count matching (key, value) pairs — the intersection |A ∩ B|
+  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]
+  );
+
+  // Count total unique keys per user — the union |A ∪ B|
+  // union = |A| + |B| - |A ∩ B|
+  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`,
+    [[requestingUserId, ...candidateIds]]
+  );
+
+  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;
+    // Score 0 when both users have no preferences (union = 0)
+    acc[id] = union === 0 ? 0 : Math.round((shared / union) * 100);
+    return acc;
+  }, {});
+};
+ +

Compatibility label mapping

+
+
80–100
Excellent match
+
50–79
Good match
+
20–49
Partial match
+
0–19
Low compatibility
+
+

Label computed in the service layer. Score is an integer 0–100. compatibilityAvailable is false when either user has zero preferences set — same pattern as existing listing compatibility field.

+ +

Rent deviation score

+
// In listing.service.js — enrichListing() helper
+const rentDeviationPct = (rentPaise, p50Paise) => {
+  if (!p50Paise) return null;
+  return Math.round(((rentPaise - p50Paise) / p50Paise) * 100);
+};
+// Result: -15 means 15% below median. +30 means 30% above.
+// null means no rent index data for this city/locality/room_type yet.
+ +

The front-end can render this as "15% below typical rent for singles in Koramangala" without exposing raw paise values from the index.

+
+ + +
+

New endpoints

+ + + + + + + + + + +
MethodPathAuthDescription
GET/api/v1/students/roommatesstudentPaginated feed of students looking for roommates, sorted by compatibility with caller.
PUT/api/v1/students/:userId/roommate-profilestudent (own)Toggle looking_for_roommate and update roommate_bio.
POST/api/v1/students/:userId/blockstudent (own)Block a user from appearing in caller's roommate feed.
DELETE/api/v1/students/:userId/blockstudent (own)Unblock.
GET/api/v1/rent-indexoptionalQuery rent index for a city+locality+room_type. Returns p25/p50/p75.
+ +

Existing endpoints — enriched responses only

+ + + + + + +
EndpointNew field(s)
GET /listings/:listingIdrentDeviation, rentIndex (p25/p50/p75 for context)
GET /listings (search)rentDeviation on each item (JOIN rent_index, single extra column)
+ +

GET /api/v1/students/roommates — full spec

+
+

Query params

+ + + + + + + + +
ParamTypeDefaultNotes
citystringFilter to students whose last-active listing city matches. Optional.
cursorTimeISO datetimePair with cursorId for pagination.
cursorIdUUID
limitint 1–5020Max 50 for roommate feed (tighter than listing search).
+

Response item shape

+
{
+  "userId": "uuid",
+  "fullName": "Arjun Mehta",
+  "profilePhotoUrl": "https://...",
+  "bio": "2nd year CSE at BITS Pilani, Goa...",
+  "roomateBio": "Looking to split a 2BHK near campus...",
+  "course": "B.Tech CSE",
+  "yearOfStudy": 2,
+  "institution": { "name": "BITS Pilani Goa", "city": "Goa" },
+  "averageRating": 4.7,
+  "compatibilityScore": 83,         // Jaccard × 100, integer
+  "compatibilityAvailable": true,   // false if either party has no preferences
+  "preferences": [                  // their preferences (full list so UI can highlight matches)
+    { "preferenceKey": "smoking",   "preferenceValue": "non_smoker" },
+    { "preferenceKey": "food_habit","preferenceValue": "vegetarian" }
+  ]
+}
+
+ +

PUT /api/v1/students/:userId/roommate-profile — full spec

+
+
// Zod body schema — src/validators/student.validators.js (add export)
+export const updateRoommateProfileSchema = z.object({
+  params: z.object({ userId: z.uuid() }),
+  body: z.object({
+    lookingForRoommate: z.boolean(),
+    roommateBio: z.string().max(500).optional(),
+  }),
+});
+

Service checks requestingUserId === targetUserId (same pattern as updateStudentProfile). Updates looking_for_roommate, roommate_bio, and looking_updated_at = NOW().

+
+
+ + +
+

Proper rent system — how data flows

+ +
1
Observation capture (DB trigger). When a listing is created or renewed (status → active), a trigger inserts a row into rent_observations. This is zero-latency and happens inside the same transaction — no async risk. Source = 'listing_created' or 'listing_renewed'.
+
2
Index refresh (cron, nightly at 03:00). A new cron job reads observations from the last 180 days, computes percentile_cont(0.25/0.5/0.75) in PostgreSQL, and upserts into rent_index. Uses advisory lock (same pattern as expiryWarning.js).
+
3
Listing response enrichment. getListing and searchListings LEFT JOIN rent_index on (city, LOWER(locality), room_type). Falls back to city-wide (NULL locality) if no locality data exists. Deviation computed at service layer.
+
4
Public query endpoint. GET /api/v1/rent-index?city=Pune&locality=Kothrud&roomType=single returns the raw percentiles for the front-end to display a distribution bar.
+ +
+ +

DB trigger — migration 006 (append to the file)

+
CREATE OR REPLACE FUNCTION capture_rent_observation()
+RETURNS TRIGGER AS $$
+BEGIN
+  -- Fire on INSERT (new listing) or UPDATE where status flips to active
+  IF (TG_OP = 'INSERT' OR (TG_OP = 'UPDATE' AND NEW.status = 'active' AND OLD.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,
+      LOWER(TRIM(COALESCE(NEW.locality, ''))),  -- normalise
+      NEW.room_type,
+      NEW.rent_per_month,
+      CASE WHEN TG_OP = 'INSERT' THEN 'listing_created' ELSE 'listing_renewed' END
+    );
+  END IF;
+  RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE OR REPLACE TRIGGER trg_capture_rent_observation
+  AFTER INSERT OR UPDATE OF status ON listings
+  FOR EACH ROW EXECUTE FUNCTION capture_rent_observation();
+ +

Cron job — src/cron/rentIndexRefresh.js (new file)

+
const SCHEDULE = process.env.CRON_RENT_INDEX ?? '0 3 * * *';
+const ADVISORY_LOCK_KEY = 7002;
+const WINDOW_DAYS = 180;
+
+const runRentIndexRefresh = async () => {
+  const client = await pool.connect();
+  try {
+    await client.query('BEGIN');
+    const { rows: [{ acquired }] } = await client.query(
+      'SELECT pg_try_advisory_xact_lock($1) AS acquired', [ADVISORY_LOCK_KEY]
+    );
+    if (!acquired) { await client.query('ROLLBACK'); return; }
+
+    // Upsert locality-level rows
+    await client.query(`
+      INSERT INTO rent_index (city, locality, room_type, p25, p50, p75, sample_count)
+      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
+      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(*) >= 3          -- need at least 3 data points
+      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 = NOW()
+    `, [WINDOW_DAYS]);
+
+    // Upsert city-wide fallback rows (locality = NULL)
+    await client.query(`
+      INSERT INTO rent_index (city, locality, room_type, p25, p50, p75, sample_count)
+      SELECT city, NULL, 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
+      FROM rent_observations
+      WHERE observed_at > NOW() - ($1::int * INTERVAL '1 day')
+      GROUP BY city, room_type
+      HAVING COUNT(*) >= 5
+      ON CONFLICT (city, NULL, room_type)
+      DO UPDATE SET
+        p25 = EXCLUDED.p25, p50 = EXCLUDED.p50,
+        p75 = EXCLUDED.p75, sample_count = EXCLUDED.sample_count,
+        computed_at = NOW()
+    `, [WINDOW_DAYS]);
+
+    await client.query('COMMIT');
+  } finally { client.release(); }
+};
+ +
The HAVING COUNT(*) >= 3 guard prevents the index from reporting misleading medians when a locality has only 1–2 listings. Below the threshold the city-wide fallback is used instead, and rentDeviation will be null if neither has data.
+
+ + +
+

Background job changes

+ +

New cron jobs

+ + + + + +
JobScheduleAdvisory lockWhat it does
rentIndexRefresh03:00 daily7002Recomputes p25/p50/p75 in rent_index from last 180 days of observations.
+ +

Existing jobs — no change required

+ + + + + + +
JobNotes
listingExpiryAlready handles status transitions — the rent observation trigger fires on those too.
hardDeleteCleanupAdd rent_observations to the cleanup batch: delete rows where listing_id has deleted_at < cutoff AND observation is older than retention window.
+ +

BullMQ — no new queues needed

+

The roommate feed query is fast enough (keyset-paginated, indexed) to run synchronously per request. The rent index refresh is infrequent enough for a cron. Neither needs a BullMQ worker.

+ +
+

server.js additions

+
import { registerRentIndexRefreshCron } from './cron/rentIndexRefresh.js';
+
+// Inside start():
+const cronTasks = [
+  registerListingExpiryCron(),
+  registerExpiryWarningCron(),
+  registerHardDeleteCleanupCron(),
+  registerRentIndexRefreshCron(),   // ← add
+];
+
+ + +
+

New files

+ + + + + + + + + + + + + + + +
PathWhat goes in it
migrations/005_roommate_matching.sqlALTER student_profiles (2 columns), CREATE roommate_blocks
migrations/006_rent_index.sqlCREATE rent_observations, rent_index, trigger
src/db/utils/roommateCompatibility.jsscoreUsersForUser(requestingUserId, candidateIds) — Jaccard query
src/services/roommate.service.jsgetRoommateFeed, updateRoommateProfile, blockUser, unblockUser
src/services/rentIndex.service.jsgetRentIndex(city, locality, roomType)
src/controllers/roommate.controller.jsThin controllers delegating to roommate.service
src/controllers/rentIndex.controller.jsSingle handler for GET /rent-index
src/validators/roommate.validators.jsgetRoommateFeedSchema, updateRoommateProfileSchema, blockParamsSchema
src/routes/roommate.jsGET /students/roommates, PUT /:userId/roommate-profile, POST/DELETE /:userId/block
src/routes/rentIndex.jsGET /rent-index
src/cron/rentIndexRefresh.jsNightly rent index recomputation
+ +

Modified files

+ + + + + + + + + + +
PathChange
src/routes/index.jsMount roommateRouter on /students (or as sub-router), rentIndexRouter on /rent-index
src/routes/student.jsAdd GET /roommates, PUT /:userId/roommate-profile, POST/DELETE /:userId/block — or delegate to roommate.js sub-router
src/services/listing.service.jsgetListing: add LEFT JOIN rent_index + compute rentDeviation. searchListings: add rentDeviation column to SELECT.
src/validators/student.validators.jsAdd updateRoommateProfileSchema export
src/server.jsRegister registerRentIndexRefreshCron
src/cron/hardDeleteCleanup.jsAdd DELETE from rent_observations for aged listing cascades
+ +
Mount order matters. In student.js, the new GET /roommates route must be registered before GET /:userId/profile — otherwise Express matches "roommates" as a userId param.
+
+ + diff --git a/src/controllers/rentIndex.controller.js b/src/controllers/rentIndex.controller.js new file mode 100644 index 0000000..08e489e --- /dev/null +++ b/src/controllers/rentIndex.controller.js @@ -0,0 +1,13 @@ +// 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; + const result = await rentIndexService.getRentIndex({ city, locality, roomType }); + res.json({ status: "success", data: result }); + } catch (err) { + next(err); + } +}; diff --git a/src/controllers/roommate.controller.js b/src/controllers/roommate.controller.js new file mode 100644 index 0000000..6626e47 --- /dev/null +++ b/src/controllers/roommate.controller.js @@ -0,0 +1,39 @@ +// src/controllers/roommate.controller.js + +import * as roommateService from "../services/roommate.service.js"; + +export const getFeed = async (req, res, next) => { + try { + const result = await roommateService.getRoommateFeed(req.user.userId, req.query); + res.json({ status: "success", data: result }); + } catch (err) { + next(err); + } +}; + +export const updateRoommateProfile = async (req, res, next) => { + try { + const result = await roommateService.updateRoommateProfile(req.user.userId, req.params.userId, req.body); + res.json({ status: "success", data: result }); + } catch (err) { + next(err); + } +}; + +export const blockUser = async (req, res, next) => { + try { + const result = await roommateService.blockUser(req.user.userId, req.params.targetUserId); + res.json({ status: "success", data: result }); + } catch (err) { + next(err); + } +}; + +export const unblockUser = async (req, res, next) => { + try { + const result = await roommateService.unblockUser(req.user.userId, req.params.targetUserId); + res.json({ status: "success", data: result }); + } catch (err) { + next(err); + } +}; 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/db/utils/roommateCompatibility.js b/src/db/utils/roommateCompatibility.js new file mode 100644 index 0000000..7522cd2 --- /dev/null +++ b/src/db/utils/roommateCompatibility.js @@ -0,0 +1,80 @@ +// src/db/utils/roommateCompatibility.js +// +// Jaccard similarity between two students' preference sets. +// +// A preference is the pair (preference_key, preference_value). +// Both fields must match for it to count as shared — having +// smoking=smoker vs smoking=non_smoker is a mismatch, not a partial hit. +// +// Score formula: +// jaccard = |A ∩ B| / |A ∪ B| +// where |A ∪ B| = |A| + |B| - |A ∩ B| +// +// Returned as an integer 0–100. +// Returns 0 when either user has no preferences (union = 0) — the caller +// should set compatibilityAvailable = false in that case. + +import { pool } from "../client.js"; + +// scoreUsersForUser +// +// requestingUserId: UUID of the student whose feed is being built. +// candidateIds: Array of UUIDs of the candidate students to score. +// Already filtered for opt-in, blocks, and city before this call. +// +// Returns: { [userId]: score 0–100 } +export const scoreUsersForUser = async (requestingUserId, candidateIds, client = pool) => { + if (!candidateIds.length) return {}; + + // 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. + // We fetch the requesting user alongside the candidates in one query. + 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`, + [[requestingUserId, ...candidateIds]], + ); + + 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; + // When union is 0 both users have no preferences — score 0, caller sets + // compatibilityAvailable = false so the UI can hide the score badge. + acc[id] = union === 0 ? 0 : Math.round((shared / union) * 100); + return acc; + }, {}); +}; + +// hasPreferences +// +// Quick check: does the given user have at least one preference row? +// Used to set compatibilityAvailable on the requesting user's own side. +export const hasPreferences = async (userId, client = pool) => { + 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; +}; diff --git a/src/routes/index.js b/src/routes/index.js index fd64ffb..17e8d4c 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -1,4 +1,4 @@ - +// src/routes/index.js import { Router } from "express"; import { healthRouter } from "./health.js"; @@ -13,6 +13,7 @@ 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 { testUtilsRouter } from "./testUtils.js"; import { config } from "../config/env.js"; @@ -30,6 +31,7 @@ rootRouter.use("/notifications", notificationRouter); rootRouter.use("/ratings", ratingRouter); rootRouter.use("/preferences", preferencesRouter); rootRouter.use("/amenities", amenitiesRouter); +rootRouter.use("/rent-index", rentIndexRouter); if (config.NODE_ENV !== "production") { rootRouter.use("/test-utils", testUtilsRouter); 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/roommate.js b/src/routes/roommate.js new file mode 100644 index 0000000..a039026 --- /dev/null +++ b/src/routes/roommate.js @@ -0,0 +1,58 @@ +// src/routes/roommate.js +// +// Mounted inside student.js BEFORE the /:userId param routes to prevent +// "roommates" being captured as a userId. +// +// Route tree (relative to /api/v1/students): +// GET /roommates — paginated roommate feed +// PUT /:userId/roommate-profile — toggle opt-in + update bio +// POST /:userId/block/:targetUserId — block a user from your feed +// DELETE /:userId/block/:targetUserId — unblock + +import { Router } from "express"; +import { authenticate } from "../middleware/authenticate.js"; +import { authorize } from "../middleware/authorize.js"; +import { validate } from "../middleware/validate.js"; +import { + getRoommateFeedSchema, + updateRoommateProfileSchema, + blockTargetParamsSchema, +} from "../validators/roommate.validators.js"; +import * as roommateController from "../controllers/roommate.controller.js"; + +export const roommateRouter = Router(); + +// Feed — any authenticated student can browse +roommateRouter.get( + "/roommates", + authenticate, + authorize("student"), + validate(getRoommateFeedSchema), + roommateController.getFeed, +); + +// Opt-in toggle — own profile only (service layer enforces userId match) +roommateRouter.put( + "/:userId/roommate-profile", + authenticate, + authorize("student"), + validate(updateRoommateProfileSchema), + roommateController.updateRoommateProfile, +); + +// Block / unblock +roommateRouter.post( + "/:userId/block/:targetUserId", + authenticate, + authorize("student"), + validate(blockTargetParamsSchema), + roommateController.blockUser, +); + +roommateRouter.delete( + "/:userId/block/:targetUserId", + authenticate, + authorize("student"), + validate(blockTargetParamsSchema), + roommateController.unblockUser, +); diff --git a/src/routes/student.js b/src/routes/student.js index cb43bc7..0853e96 100644 --- a/src/routes/student.js +++ b/src/routes/student.js @@ -1,3 +1,9 @@ +// 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"; @@ -13,12 +19,19 @@ import { } 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); +// ── Photo ───────────────────────────────────────────────────────────────────── studentRouter.put( "/:userId/photo", authenticate, @@ -34,6 +47,7 @@ studentRouter.delete( profilePhotoController.deleteStudentPhoto, ); +// ── Contact reveal ──────────────────────────────────────────────────────────── studentRouter.get( "/:userId/contact/reveal", optionalAuthenticate, @@ -46,6 +60,7 @@ studentRouter.get( studentController.revealContact, ); +// ── Preferences ─────────────────────────────────────────────────────────────── studentRouter.get( "/:userId/preferences", authenticate, diff --git a/src/services/listing.service.js b/src/services/listing.service.js index 378347f..1b133d2 100644 --- a/src/services/listing.service.js +++ b/src/services/listing.service.js @@ -30,6 +30,22 @@ const toRupees = (listing) => { }; }; +const rentDeviationPct = (rentPaise, p50Paise) => { + if (p50Paise == null || p50Paise === 0) return null; + return Math.round(((rentPaise - p50Paise) / p50Paise) * 100); +}; +// Helper that converts paise rent-index fields to rupees for the JSON response. +const formatRentIndex = (row) => { + if (row.ri_p50 == null) return null; + return { + p25: Math.round(row.ri_p25 / 100), + p50: Math.round(row.ri_p50 / 100), + p75: Math.round(row.ri_p75 / 100), + sampleCount: row.ri_sample_count, + resolution: row.ri_resolution, // 'locality' | 'city' | null + }; +}; + const bulkInsertListingAmenities = async (client, listingId, amenityIds) => { if (!amenityIds.length) return; const placeholders = amenityIds.map((_, i) => `($1, $${i + 2})`).join(", "); @@ -300,13 +316,47 @@ export const getListing = async (listingId) => { const listing = await fetchListingDetail(listingId); if (!listing) throw new AppError("Listing not found", 404); + // Increment view count fire-and-forget — unchanged. void pool .query(`UPDATE listings SET views_count = views_count + 1 WHERE listing_id = $1`, [listingId]) .catch((err) => { logger.warn({ err, listingId }, "Failed to increment listing view count"); }); - return toRupees(listing); + // Fetch rent index for this listing's city / locality / room_type. + // Two LEFT JOINs: locality-specific first, city-wide fallback second. + const { rows: riRows } = await pool.query( + `SELECT + COALESCE(ri_loc.p25, ri_city.p25) AS ri_p25, + COALESCE(ri_loc.p50, ri_city.p50) AS ri_p50, + COALESCE(ri_loc.p75, ri_city.p75) AS ri_p75, + COALESCE(ri_loc.sample_count, ri_city.sample_count) AS ri_sample_count, + CASE + WHEN ri_loc.rent_index_id IS NOT NULL THEN 'locality' + WHEN ri_city.rent_index_id IS NOT NULL THEN 'city' + ELSE NULL + END AS ri_resolution + FROM listings l + LEFT JOIN rent_index ri_loc + ON ri_loc.city = l.city + AND ri_loc.locality = NULLIF(LOWER(TRIM(COALESCE(l.locality, ''))), '') + AND ri_loc.room_type = l.room_type + LEFT JOIN rent_index ri_city + ON ri_city.city = l.city + AND ri_city.locality IS NULL + AND ri_city.room_type = l.room_type + WHERE l.listing_id = $1`, + [listingId], + ); + + const ri = riRows[0] ?? {}; + const converted = toRupees(listing); + + return { + ...converted, + rentDeviation: rentDeviationPct(listing.rent_per_month, ri.ri_p50), + rentIndex: formatRentIndex(ri), + }; }; export const searchListings = async (userId, filters) => { @@ -420,6 +470,7 @@ export const searchListings = async (userId, filters) => { params.push(limit + 1); const limitParam = p; + // ── CHANGED: added rent_index LEFT JOINs and ri_p50 / ri_resolution columns ── const { rows } = await pool.query( `SELECT l.listing_id, @@ -446,15 +497,31 @@ export const searchListings = async (userId, filters) => { AND ph.deleted_at IS NULL AND ph.photo_url NOT LIKE 'processing:%' LIMIT 1 - ) AS cover_photo_url + ) AS cover_photo_url, + -- Rent index (locality-level preferred, city-wide fallback) + COALESCE(ri_loc.p50, ri_city.p50) AS ri_p50, + CASE + WHEN ri_loc.rent_index_id IS NOT NULL THEN 'locality' + WHEN ri_city.rent_index_id IS NOT NULL THEN 'city' + ELSE NULL + END AS ri_resolution FROM listings l JOIN users u ON u.user_id = l.posted_by LEFT JOIN properties p ON p.property_id = l.property_id AND p.deleted_at IS NULL + LEFT JOIN rent_index ri_loc + ON ri_loc.city = l.city + AND ri_loc.locality = NULLIF(LOWER(TRIM(COALESCE(l.locality, ''))), '') + AND ri_loc.room_type = l.room_type + LEFT JOIN rent_index ri_city + ON ri_city.city = l.city + AND ri_city.locality IS NULL + AND ri_city.room_type = l.room_type WHERE ${clauses.join(" AND ")} ORDER BY l.created_at DESC, l.listing_id ASC LIMIT $${limitParam}`, params, ); + // ── END CHANGED section ─────────────────────────────────────────────────── const hasNextPage = rows.length > limit; const items = hasNextPage ? rows.slice(0, limit) : rows; @@ -490,6 +557,7 @@ export const searchListings = async (userId, filters) => { } } + // ── CHANGED: added rentDeviation to each enriched item ─────────────────── const enrichedItems = items.map((row) => ({ ...row, rentPerMonth: row.rent_per_month / 100, @@ -499,7 +567,12 @@ export const searchListings = async (userId, filters) => { compatibilityScore: userId !== null ? (scoreMap[row.listing_id] ?? 0) : 0, compatibilityAvailable: userId !== null && userHasPreferences && (listingPreferenceCounts.get(row.listing_id) ?? 0) > 0, + // New: rent deviation as a percentage relative to local median + rentDeviation: rentDeviationPct(row.rent_per_month, row.ri_p50), + ri_p50: undefined, + ri_resolution: undefined, })); + // ── END CHANGED section ─────────────────────────────────────────────────── if (sortBy === "compatibility") { enrichedItems.sort((a, b) => b.compatibilityScore - a.compatibilityScore); diff --git a/src/services/rentIndex.service.js b/src/services/rentIndex.service.js new file mode 100644 index 0000000..f88c7ea --- /dev/null +++ b/src/services/rentIndex.service.js @@ -0,0 +1,61 @@ +// src/services/rentIndex.service.js +// +// Public query interface for the rent_index table. +// +// getRentIndex: fetches the precomputed percentiles for a given +// city + optional locality + room_type combination. +// Falls back to city-wide data (locality IS NULL) when no +// locality-specific row exists, matching the same fallback logic +// used when enriching listing responses. + +import { pool } from "../db/client.js"; +import { AppError } from "../middleware/errorHandler.js"; + +// Converts paise to rupees for the JSON response. +const paiseToRupees = (paise) => (paise != null ? Math.round(paise / 100) : null); + +export const getRentIndex = async ({ city, locality, roomType }) => { + // Try locality-specific row first, then city-wide fallback. + // Both lookups in one query using COALESCE across two LEFT JOINs. + const normLocality = locality ? locality.toLowerCase().trim() : null; + + const { rows } = await pool.query( + `SELECT + COALESCE(ri_loc.p25, ri_city.p25) AS p25, + COALESCE(ri_loc.p50, ri_city.p50) AS p50, + COALESCE(ri_loc.p75, ri_city.p75) AS p75, + COALESCE(ri_loc.sample_count, ri_city.sample_count) AS sample_count, + COALESCE(ri_loc.computed_at, ri_city.computed_at) AS computed_at, + CASE WHEN ri_loc.rent_index_id IS NOT NULL THEN 'locality' ELSE 'city' END AS resolution + FROM (SELECT 1) AS _dual + LEFT JOIN rent_index ri_loc + ON ri_loc.city = $1 + AND ri_loc.locality = $2 + AND ri_loc.room_type = $3::room_type_enum + LEFT JOIN rent_index ri_city + ON ri_city.city = $1 + AND ri_city.locality IS NULL + AND ri_city.room_type = $3::room_type_enum`, + [city, normLocality, roomType], + ); + + const row = rows[0]; + + if (!row || row.p50 == null) { + // No data for this combination yet — 404 is the right response so the + // caller knows not to display a deviation badge. + throw new AppError("No rent index data available for this city / room type combination yet", 404); + } + + return { + city, + locality: locality ?? null, + roomType, + resolution: row.resolution, // 'locality' | 'city' + p25: paiseToRupees(row.p25), + p50: paiseToRupees(row.p50), + p75: paiseToRupees(row.p75), + sampleCount: row.sample_count, + computedAt: row.computed_at, + }; +}; diff --git a/src/services/roommate.service.js b/src/services/roommate.service.js new file mode 100644 index 0000000..54395a4 --- /dev/null +++ b/src/services/roommate.service.js @@ -0,0 +1,267 @@ +// src/services/roommate.service.js +// +// Business logic for the roommate-matching feed. +// +// Feed strategy: +// 1. Fetch a page of candidate student IDs (opt-in, not blocked, optional city +// filter) ordered by looking_updated_at DESC with keyset cursor. +// 2. Score those IDs against the requesting user via Jaccard similarity. +// 3. Merge scores into the candidate rows and return. +// +// We do NOT sort by score at the DB level — that would require loading all +// candidates before paginating, which kills performance. Instead we surface +// recency-ordered candidates with score as a display field. A future +// "sortBy=compatibility" mode can fetch a larger batch (e.g. 200), sort in +// memory, and slice — same tradeoff as the existing listing search. + +import { pool } from "../db/client.js"; +import { logger } from "../logger/index.js"; +import { AppError } from "../middleware/errorHandler.js"; +import { scoreUsersForUser, hasPreferences } from "../db/utils/roommateCompatibility.js"; + +const MAX_BLOCKS_PER_USER = 200; + +// ─── getRoommateFeed ────────────────────────────────────────────────────────── + +export const getRoommateFeed = async (requestingUserId, filters) => { + const { city, cursorTime, cursorId, limit = 20 } = filters; + const safeLimit = Math.min(Math.max(1, Number(limit) || 20), 50); + + // Whether the requesting user has any preferences set — needed to decide + // whether to expose compatibility scores at all. + const callerHasPrefs = await hasPreferences(requestingUserId); + + // Build the candidate query dynamically. + // We exclude: + // • the requesting user themselves + // • users blocked BY the caller (blocker_id = caller) + // • users who have blocked the caller (blocked_id = caller) + const clauses = [ + `sp.looking_for_roommate = TRUE`, + `sp.deleted_at IS NULL`, + `u.deleted_at IS NULL`, + `u.account_status = 'active'`, + `sp.user_id <> $1`, + // Bidirectional block exclusion in a single NOT EXISTS + `NOT EXISTS ( + SELECT 1 FROM roommate_blocks rb + WHERE (rb.blocker_id = $1 AND rb.blocked_id = sp.user_id) + OR (rb.blocker_id = sp.user_id AND rb.blocked_id = $1) + )`, + ]; + const params = [requestingUserId]; + let p = 2; + + if (city) { + const escaped = city.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_"); + // Match against the city stored on their most-recent active listing, falling + // back to a simple city column on student_profiles if we add one later. + // For now we check listings — a student has at least one active listing in that city. + clauses.push( + `EXISTS ( + SELECT 1 FROM listings l + WHERE l.posted_by = sp.user_id + AND l.deleted_at IS NULL + AND l.status = 'active' + AND LOWER(l.city) LIKE LOWER($${p}) ESCAPE '\\' + )`, + ); + params.push(`${escaped}%`); + p++; + } + + const hasCursor = cursorTime !== undefined && cursorId !== undefined; + if (hasCursor) { + // looking_updated_at DESC, user_id ASC tie-break (UUID ordering) + clauses.push( + `(sp.looking_updated_at < $${p} OR (sp.looking_updated_at = $${p} AND sp.user_id > $${p + 1}::uuid))`, + ); + params.push(cursorTime, cursorId); + p += 2; + } + + params.push(safeLimit + 1); + const limitParam = p; + + const { rows: candidates } = await pool.query( + `SELECT + sp.user_id, + sp.full_name, + sp.profile_photo_url, + sp.bio, + sp.roommate_bio, + sp.course, + sp.year_of_study, + sp.looking_updated_at, + u.average_rating, + u.rating_count, + i.name AS institution_name, + i.city AS institution_city + FROM student_profiles sp + JOIN users u + ON u.user_id = sp.user_id + LEFT JOIN institutions i + ON i.institution_id = sp.institution_id + AND i.deleted_at IS NULL + WHERE ${clauses.join(" AND ")} + ORDER BY sp.looking_updated_at DESC, sp.user_id ASC + LIMIT $${limitParam}`, + params, + ); + + const hasNextPage = candidates.length > safeLimit; + const items = hasNextPage ? candidates.slice(0, safeLimit) : candidates; + + // Fetch all preferences for candidates in one query (avoids N+1). + let preferenceMap = {}; + let candidateHasPrefSet = {}; + if (items.length) { + const candidateIds = items.map((r) => r.user_id); + + const { rows: prefRows } = await pool.query( + `SELECT user_id, preference_key AS "preferenceKey", preference_value AS "preferenceValue" + FROM user_preferences + WHERE user_id = ANY($1::uuid[]) + ORDER BY user_id, preference_key`, + [candidateIds], + ); + + for (const row of prefRows) { + if (!preferenceMap[row.user_id]) preferenceMap[row.user_id] = []; + preferenceMap[row.user_id].push({ + preferenceKey: row.preferenceKey, + preferenceValue: row.preferenceValue, + }); + candidateHasPrefSet[row.user_id] = true; + } + + // Score only if the caller has preferences — otherwise every score is 0 + // and we just mark compatibilityAvailable = false. + if (callerHasPrefs && candidateIds.length) { + const scores = await scoreUsersForUser(requestingUserId, candidateIds); + for (const item of items) { + item._score = scores[item.user_id] ?? 0; + } + } + } + + const nextCursor = + hasNextPage ? + { + cursorTime: items[items.length - 1].looking_updated_at.toISOString(), + cursorId: items[items.length - 1].user_id, + } + : null; + + return { + items: items.map((row) => ({ + userId: row.user_id, + fullName: row.full_name, + profilePhotoUrl: row.profile_photo_url, + bio: row.bio, + roommateBio: row.roommate_bio, + course: row.course, + yearOfStudy: row.year_of_study, + averageRating: row.average_rating, + ratingCount: row.rating_count, + institution: row.institution_name ? { name: row.institution_name, city: row.institution_city } : null, + compatibilityScore: callerHasPrefs ? (row._score ?? 0) : 0, + compatibilityAvailable: callerHasPrefs && (candidateHasPrefSet[row.user_id] ?? false), + preferences: preferenceMap[row.user_id] ?? [], + })), + nextCursor, + }; +}; + +// ─── updateRoommateProfile ──────────────────────────────────────────────────── + +export const updateRoommateProfile = async (requestingUserId, targetUserId, { lookingForRoommate, roommateBio }) => { + if (requestingUserId !== targetUserId) { + throw new AppError("Forbidden", 403); + } + + const { rows } = await pool.query( + `UPDATE student_profiles + SET looking_for_roommate = $1, + roommate_bio = $2, + looking_updated_at = CASE WHEN $1 = TRUE THEN NOW() ELSE looking_updated_at END, + updated_at = NOW() + WHERE user_id = $3 + AND deleted_at IS NULL + RETURNING user_id, looking_for_roommate, roommate_bio, looking_updated_at`, + [lookingForRoommate, roommateBio ?? null, targetUserId], + ); + + if (!rows.length) { + throw new AppError("Student profile not found", 404); + } + + logger.info({ userId: targetUserId, lookingForRoommate }, "Roommate profile updated"); + + return { + userId: rows[0].user_id, + lookingForRoommate: rows[0].looking_for_roommate, + roommateBio: rows[0].roommate_bio, + lookingUpdatedAt: rows[0].looking_updated_at, + }; +}; + +// ─── blockUser ──────────────────────────────────────────────────────────────── + +export const blockUser = async (requestingUserId, targetUserId) => { + if (requestingUserId === targetUserId) { + throw new AppError("You cannot block yourself", 422); + } + + // Verify the target user exists and is a student + const { rows: targetRows } = await pool.query( + `SELECT u.user_id FROM users u + WHERE u.user_id = $1 + AND u.deleted_at IS NULL + AND u.account_status = 'active'`, + [targetUserId], + ); + if (!targetRows.length) { + throw new AppError("User not found", 404); + } + + // Soft cap — prevent bloat + const { rows: countRows } = await pool.query( + `SELECT COUNT(*)::int AS cnt FROM roommate_blocks WHERE blocker_id = $1`, + [requestingUserId], + ); + if (countRows[0].cnt >= MAX_BLOCKS_PER_USER) { + throw new AppError(`You can block at most ${MAX_BLOCKS_PER_USER} users`, 422); + } + + await pool.query( + `INSERT INTO roommate_blocks (blocker_id, blocked_id) + VALUES ($1, $2) + ON CONFLICT (blocker_id, blocked_id) DO NOTHING`, + [requestingUserId, targetUserId], + ); + + logger.info({ blockerId: requestingUserId, blockedId: targetUserId }, "Roommate block added"); + return { blockedUserId: targetUserId, blocked: true }; +}; + +// ─── unblockUser ───────────────────────────────────────────────────────────── + +export const unblockUser = async (requestingUserId, targetUserId) => { + const { rowCount } = await pool.query(`DELETE FROM roommate_blocks WHERE blocker_id = $1 AND blocked_id = $2`, [ + requestingUserId, + targetUserId, + ]); + + if (rowCount === 0) { + // Idempotent — if the block didn't exist, that's fine. + logger.debug( + { blockerId: requestingUserId, blockedId: targetUserId }, + "Roommate unblock: no-op (block not found)", + ); + } else { + logger.info({ blockerId: requestingUserId, blockedId: targetUserId }, "Roommate block removed"); + } + + return { blockedUserId: targetUserId, blocked: false }; +}; diff --git a/src/validators/rentIndex.validators.js b/src/validators/rentIndex.validators.js new file mode 100644 index 0000000..e69de29 diff --git a/src/validators/roommate.validators.js b/src/validators/roommate.validators.js new file mode 100644 index 0000000..4ad1aa3 --- /dev/null +++ b/src/validators/roommate.validators.js @@ -0,0 +1,41 @@ +// src/validators/roommate.validators.js + +import { z } from "zod"; +import { buildKeysetPaginationQuerySchema } from "./pagination.validators.js"; + +export const getRoommateFeedSchema = z.object({ + query: buildKeysetPaginationQuerySchema({ + city: z.string().min(1).max(100).optional(), + }).transform((data) => ({ + ...data, + // Clamp limit to 50 for the roommate feed (tighter than listing search) + limit: Math.min(data.limit, 50), + })), +}); + +export const updateRoommateProfileSchema = z.object({ + params: z.object({ + userId: z.uuid({ error: "Invalid user ID" }), + }), + body: z.object({ + lookingForRoommate: z.boolean({ + error: "lookingForRoommate must be a boolean", + }), + roommateBio: z.string().max(500).optional(), + }), +}); + +export const roommateBlockParamsSchema = z.object({ + params: z.object({ + userId: z.uuid({ error: "Invalid user ID" }), // requesting user + targetUserId: z.uuid({ error: "Invalid target user ID" }), + }), +}); + +// Used for block/unblock endpoints where only the target user ID matters +export const blockTargetParamsSchema = z.object({ + params: z.object({ + userId: z.uuid({ error: "Invalid user ID" }), + targetUserId: z.uuid({ error: "Invalid target user ID" }), + }), +}); From ae016170a4931966149c1eb125e6581a8fca9cb9 Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Sun, 26 Apr 2026 22:59:34 +0530 Subject: [PATCH 31/54] feat: add rent index validation schema and notification for saved search alerts --- src/cron/hardDeleteCleanup.js | 12 ++++++++++++ src/server.js | 26 ++++++++++---------------- src/validators/rentIndex.validators.js | 13 +++++++++++++ src/workers/notificationWorker.js | 2 ++ 4 files changed, 37 insertions(+), 16 deletions(-) diff --git a/src/cron/hardDeleteCleanup.js b/src/cron/hardDeleteCleanup.js index 280b1f3..bba6876 100644 --- a/src/cron/hardDeleteCleanup.js +++ b/src/cron/hardDeleteCleanup.js @@ -132,6 +132,18 @@ const runHardDeleteCleanup = async () => { ); results.listing_photos = lp; + + const { rowCount: ro } = await client.query( + `DELETE FROM rent_observations + WHERE listing_id IN ( + SELECT listing_id FROM listings + WHERE deleted_at IS NOT NULL + AND deleted_at < ${cutoffExpr} + )`, + p, + ); + results.rent_observations = ro; + const { rowCount: li } = await client.query( `DELETE FROM listings WHERE deleted_at IS NOT NULL diff --git a/src/server.js b/src/server.js index 8c4fd68..fa1a811 100644 --- a/src/server.js +++ b/src/server.js @@ -14,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,7 +31,14 @@ const start = async () => { const verificationEventWorker = startVerificationEventWorker(); - 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}]`); @@ -37,21 +46,6 @@ const start = async () => { let isShuttingDown = false; - - - - - - - - - - - - - - - const shutdown = (signal) => async () => { if (isShuttingDown) { logger.warn(`${signal} received again during shutdown — ignoring duplicate signal`); diff --git a/src/validators/rentIndex.validators.js b/src/validators/rentIndex.validators.js index e69de29..f3ea6a8 100644 --- a/src/validators/rentIndex.validators.js +++ b/src/validators/rentIndex.validators.js @@ -0,0 +1,13 @@ +// src/validators/rentIndex.validators.js + +import { z } from "zod"; + +export const getRentIndexSchema = z.object({ + query: z.object({ + city: z.string({ error: "city is required" }).min(1, { error: "city is required" }).max(100), + locality: z.string().max(100).optional(), + roomType: z.enum(["single", "double", "triple", "entire_flat"], { + error: "roomType must be one of: single, double, triple, entire_flat", + }), + }), +}); diff --git a/src/workers/notificationWorker.js b/src/workers/notificationWorker.js index 1ff7d71..eb47db5 100644 --- a/src/workers/notificationWorker.js +++ b/src/workers/notificationWorker.js @@ -29,6 +29,8 @@ const NOTIFICATION_MESSAGES = { new_message: "You have a new message", connection_requested: "You have a new connection request", + + saved_search_alert: "A new listing matches your saved search", }; export const startNotificationWorker = () => { From ce6d142bf68a1784111b8200ce140928422738e0 Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Sun, 26 Apr 2026 23:00:14 +0530 Subject: [PATCH 32/54] feat: remove outdated roommate matching and rent system design documents --- feat.md | 20 -- roommate_matching_system_design.html | 491 --------------------------- 2 files changed, 511 deletions(-) delete mode 100644 feat.md delete mode 100644 roommate_matching_system_design.html diff --git a/feat.md b/feat.md deleted file mode 100644 index b65b9ab..0000000 --- a/feat.md +++ /dev/null @@ -1,20 +0,0 @@ -I'll analyze the codebase first to understand the existing patterns before designing the system.I have a thorough -picture of the codebase. Let me now produce the full system design document.Now I have everything I need to produce a -thorough, well-structured design document. Let me build it as an interactive HTML artifact.Here's the full system design -across six tabs. A few things worth calling out explicitly: - -**On the roommate feed query pattern** — the service will fetch a page of candidate IDs first (students with -`looking_for_roommate = TRUE`, excluding blocks, with keyset cursor on `looking_updated_at`), then call -`scoreUsersForUser` with just those IDs. This is the same two-phase pattern as `scoreListingsForUser` in the existing -codebase. Sorting by score alone would require computing scores for every user in the DB, which doesn't scale — so the -feed shows candidates in reverse-recency order, with score surfaced as a secondary display field, unless you add a -`sortBy=compatibility` mode (which fetches a larger batch, scores all of it, sorts, then paginates in memory — same -tradeoff as the existing listing search). - -**On the rent index** — the DB trigger approach means you never need to backfill. Every listing that goes active from -now on feeds the index automatically. The `HAVING COUNT(*) >= 3` floor is conservative; you can tune it via the -`CRON_RENT_INDEX_MIN_SAMPLE` env var once you have real data. - -**On mount order** — the `GET /students/roommates` route must go above `GET /students/:userId/profile` in `student.js`. -Express will greedily match `roommates` as a `:userId` param otherwise. The easiest fix is to put all the new roommate -routes in a dedicated `roommate.js` router mounted at `/students` before the parameterised routes. diff --git a/roommate_matching_system_design.html b/roommate_matching_system_design.html deleted file mode 100644 index 097e7ba..0000000 --- a/roommate_matching_system_design.html +++ /dev/null @@ -1,491 +0,0 @@ - -

Roommate matching and proper rent system design for Roomies backend

- - - - - - -
-
Two orthogonal features. The roommate matcher surfaces user-to-user preference compatibility so students find people to share with. The proper rent system adds a verified rent layer — crowdsourced fair-market rent data for a city/locality so users know if a listing is priced fairly.
- -

Roommate matching — what we're building

-

Currently scoreListingsForUser counts how many listing preferences overlap a student's own preferences — user-vs-listing matching. We now need user-vs-user matching: "which other students have preferences close to mine?" This powers a "Find a Roommate" feed separate from the listing search.

- -
-
-
New
-

User-vs-user scoring

-

Jaccard similarity on user_preferences. Both the preference key and value must match. Score = shared / total union preferences.

-
-
-
New
-

Roommate feed

-

Paginated list of students sorted by compatibility. Only shows users who have opted in and have a profile set to "looking". Cursor-based, same pattern as listing search.

-
-
-
Extend existing
-

Listing search enrichment

-

Existing compatibilityScore stays unchanged. Listing cards can also expose a "co-tenants" field showing compatibility with accepted co-tenants on shared rooms.

-
-
-
New
-

Proper rent layer

-

Crowdsourced fair-market rent for city+locality+room_type. Listings get a rentDeviation field: how far the posted rent is from the locality median.

-
-
- -

Design principles — unchanged from existing codebase

-
- Keyset pagination everywhere - Zod validators at the boundary - Service layer owns business logic - No raw process.env — config/env.js - BullMQ for async work - Soft-delete, never hard-delete from app code - pg pool, transactions via pool.connect() -
-
- - -
-

New tables

- -

005_roommate_matching.sql

-
-- Opt-in flag on student profiles (add column, no new table)
-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,            -- "I'm a 2nd-year CSE student..."
-  ADD COLUMN IF NOT EXISTS looking_updated_at TIMESTAMPTZ;
-
-CREATE INDEX IF NOT EXISTS idx_student_roommate_lookup
-  ON student_profiles (user_id)
-  WHERE looking_for_roommate = TRUE AND deleted_at IS NULL;
-
--- Blocked users (student can hide from feed)
-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)
-);
- -

006_rent_index.sql

-
-- Crowdsourced rent observations — one row per listing per snapshot
-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),
-  room_type      room_type_enum NOT NULL,
-  rent_per_month INTEGER NOT NULL,          -- paise, same as listings
-  observed_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-  -- source: 'listing_created' | 'listing_renewed' | 'manual'
-  source         VARCHAR(30) NOT NULL DEFAULT 'listing_created'
-);
-
-CREATE INDEX IF NOT EXISTS idx_rent_obs_lookup
-  ON rent_observations (city, locality, room_type, observed_at DESC)
-  WHERE observed_at > NOW() - INTERVAL '180 days';
-
--- Materialised summary refreshed by cron (city+locality+room_type median)
-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)
-);
-
-CREATE INDEX IF NOT EXISTS idx_rent_index_lookup
-  ON rent_index (city, locality, room_type);
- -
Rent is stored in paise throughout (same as listings.rent_per_month). Division by 100 happens only at the service layer when building the JSON response. Never store rupees in the DB.
- -

Existing tables — no structural changes needed

- - - - - - - -
TableUsed byNotes
user_preferencesRoommate scoringAlready has (user_id, preference_key, preference_value). The matcher JOINs two copies of this table.
student_profilesRoommate feedGets two new columns via migration 005.
listingsRent observationsTrigger on INSERT + status change populates rent_observations automatically.
-
- - -
-

User-vs-user similarity

-

We use Jaccard similarity: |A ∩ B| / |A ∪ B| where A and B are sets of (preference_key, preference_value) pairs. Both key and value must match — having smoking=smoker vs smoking=non_smoker is a mismatch, not a half-match.

- -
Jaccard is cheap at this scale (7 preference keys max per user). No vector embeddings needed. The SQL self-join runs in milliseconds for a page of 20 candidates.
- -

Core query — src/db/utils/roommateCompatibility.js

-
export const scoreUsersForUser = async (requestingUserId, candidateIds, client = pool) => {
-  if (!candidateIds.length) return {};
-
-  // Count matching (key, value) pairs — the intersection |A ∩ B|
-  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]
-  );
-
-  // Count total unique keys per user — the union |A ∪ B|
-  // union = |A| + |B| - |A ∩ B|
-  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`,
-    [[requestingUserId, ...candidateIds]]
-  );
-
-  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;
-    // Score 0 when both users have no preferences (union = 0)
-    acc[id] = union === 0 ? 0 : Math.round((shared / union) * 100);
-    return acc;
-  }, {});
-};
- -

Compatibility label mapping

-
-
80–100
Excellent match
-
50–79
Good match
-
20–49
Partial match
-
0–19
Low compatibility
-
-

Label computed in the service layer. Score is an integer 0–100. compatibilityAvailable is false when either user has zero preferences set — same pattern as existing listing compatibility field.

- -

Rent deviation score

-
// In listing.service.js — enrichListing() helper
-const rentDeviationPct = (rentPaise, p50Paise) => {
-  if (!p50Paise) return null;
-  return Math.round(((rentPaise - p50Paise) / p50Paise) * 100);
-};
-// Result: -15 means 15% below median. +30 means 30% above.
-// null means no rent index data for this city/locality/room_type yet.
- -

The front-end can render this as "15% below typical rent for singles in Koramangala" without exposing raw paise values from the index.

-
- - -
-

New endpoints

- - - - - - - - - - -
MethodPathAuthDescription
GET/api/v1/students/roommatesstudentPaginated feed of students looking for roommates, sorted by compatibility with caller.
PUT/api/v1/students/:userId/roommate-profilestudent (own)Toggle looking_for_roommate and update roommate_bio.
POST/api/v1/students/:userId/blockstudent (own)Block a user from appearing in caller's roommate feed.
DELETE/api/v1/students/:userId/blockstudent (own)Unblock.
GET/api/v1/rent-indexoptionalQuery rent index for a city+locality+room_type. Returns p25/p50/p75.
- -

Existing endpoints — enriched responses only

- - - - - - -
EndpointNew field(s)
GET /listings/:listingIdrentDeviation, rentIndex (p25/p50/p75 for context)
GET /listings (search)rentDeviation on each item (JOIN rent_index, single extra column)
- -

GET /api/v1/students/roommates — full spec

-
-

Query params

- - - - - - - - -
ParamTypeDefaultNotes
citystringFilter to students whose last-active listing city matches. Optional.
cursorTimeISO datetimePair with cursorId for pagination.
cursorIdUUID
limitint 1–5020Max 50 for roommate feed (tighter than listing search).
-

Response item shape

-
{
-  "userId": "uuid",
-  "fullName": "Arjun Mehta",
-  "profilePhotoUrl": "https://...",
-  "bio": "2nd year CSE at BITS Pilani, Goa...",
-  "roomateBio": "Looking to split a 2BHK near campus...",
-  "course": "B.Tech CSE",
-  "yearOfStudy": 2,
-  "institution": { "name": "BITS Pilani Goa", "city": "Goa" },
-  "averageRating": 4.7,
-  "compatibilityScore": 83,         // Jaccard × 100, integer
-  "compatibilityAvailable": true,   // false if either party has no preferences
-  "preferences": [                  // their preferences (full list so UI can highlight matches)
-    { "preferenceKey": "smoking",   "preferenceValue": "non_smoker" },
-    { "preferenceKey": "food_habit","preferenceValue": "vegetarian" }
-  ]
-}
-
- -

PUT /api/v1/students/:userId/roommate-profile — full spec

-
-
// Zod body schema — src/validators/student.validators.js (add export)
-export const updateRoommateProfileSchema = z.object({
-  params: z.object({ userId: z.uuid() }),
-  body: z.object({
-    lookingForRoommate: z.boolean(),
-    roommateBio: z.string().max(500).optional(),
-  }),
-});
-

Service checks requestingUserId === targetUserId (same pattern as updateStudentProfile). Updates looking_for_roommate, roommate_bio, and looking_updated_at = NOW().

-
-
- - -
-

Proper rent system — how data flows

- -
1
Observation capture (DB trigger). When a listing is created or renewed (status → active), a trigger inserts a row into rent_observations. This is zero-latency and happens inside the same transaction — no async risk. Source = 'listing_created' or 'listing_renewed'.
-
2
Index refresh (cron, nightly at 03:00). A new cron job reads observations from the last 180 days, computes percentile_cont(0.25/0.5/0.75) in PostgreSQL, and upserts into rent_index. Uses advisory lock (same pattern as expiryWarning.js).
-
3
Listing response enrichment. getListing and searchListings LEFT JOIN rent_index on (city, LOWER(locality), room_type). Falls back to city-wide (NULL locality) if no locality data exists. Deviation computed at service layer.
-
4
Public query endpoint. GET /api/v1/rent-index?city=Pune&locality=Kothrud&roomType=single returns the raw percentiles for the front-end to display a distribution bar.
- -
- -

DB trigger — migration 006 (append to the file)

-
CREATE OR REPLACE FUNCTION capture_rent_observation()
-RETURNS TRIGGER AS $$
-BEGIN
-  -- Fire on INSERT (new listing) or UPDATE where status flips to active
-  IF (TG_OP = 'INSERT' OR (TG_OP = 'UPDATE' AND NEW.status = 'active' AND OLD.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,
-      LOWER(TRIM(COALESCE(NEW.locality, ''))),  -- normalise
-      NEW.room_type,
-      NEW.rent_per_month,
-      CASE WHEN TG_OP = 'INSERT' THEN 'listing_created' ELSE 'listing_renewed' END
-    );
-  END IF;
-  RETURN NEW;
-END;
-$$ LANGUAGE plpgsql;
-
-CREATE OR REPLACE TRIGGER trg_capture_rent_observation
-  AFTER INSERT OR UPDATE OF status ON listings
-  FOR EACH ROW EXECUTE FUNCTION capture_rent_observation();
- -

Cron job — src/cron/rentIndexRefresh.js (new file)

-
const SCHEDULE = process.env.CRON_RENT_INDEX ?? '0 3 * * *';
-const ADVISORY_LOCK_KEY = 7002;
-const WINDOW_DAYS = 180;
-
-const runRentIndexRefresh = async () => {
-  const client = await pool.connect();
-  try {
-    await client.query('BEGIN');
-    const { rows: [{ acquired }] } = await client.query(
-      'SELECT pg_try_advisory_xact_lock($1) AS acquired', [ADVISORY_LOCK_KEY]
-    );
-    if (!acquired) { await client.query('ROLLBACK'); return; }
-
-    // Upsert locality-level rows
-    await client.query(`
-      INSERT INTO rent_index (city, locality, room_type, p25, p50, p75, sample_count)
-      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
-      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(*) >= 3          -- need at least 3 data points
-      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 = NOW()
-    `, [WINDOW_DAYS]);
-
-    // Upsert city-wide fallback rows (locality = NULL)
-    await client.query(`
-      INSERT INTO rent_index (city, locality, room_type, p25, p50, p75, sample_count)
-      SELECT city, NULL, 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
-      FROM rent_observations
-      WHERE observed_at > NOW() - ($1::int * INTERVAL '1 day')
-      GROUP BY city, room_type
-      HAVING COUNT(*) >= 5
-      ON CONFLICT (city, NULL, room_type)
-      DO UPDATE SET
-        p25 = EXCLUDED.p25, p50 = EXCLUDED.p50,
-        p75 = EXCLUDED.p75, sample_count = EXCLUDED.sample_count,
-        computed_at = NOW()
-    `, [WINDOW_DAYS]);
-
-    await client.query('COMMIT');
-  } finally { client.release(); }
-};
- -
The HAVING COUNT(*) >= 3 guard prevents the index from reporting misleading medians when a locality has only 1–2 listings. Below the threshold the city-wide fallback is used instead, and rentDeviation will be null if neither has data.
-
- - -
-

Background job changes

- -

New cron jobs

- - - - - -
JobScheduleAdvisory lockWhat it does
rentIndexRefresh03:00 daily7002Recomputes p25/p50/p75 in rent_index from last 180 days of observations.
- -

Existing jobs — no change required

- - - - - - -
JobNotes
listingExpiryAlready handles status transitions — the rent observation trigger fires on those too.
hardDeleteCleanupAdd rent_observations to the cleanup batch: delete rows where listing_id has deleted_at < cutoff AND observation is older than retention window.
- -

BullMQ — no new queues needed

-

The roommate feed query is fast enough (keyset-paginated, indexed) to run synchronously per request. The rent index refresh is infrequent enough for a cron. Neither needs a BullMQ worker.

- -
-

server.js additions

-
import { registerRentIndexRefreshCron } from './cron/rentIndexRefresh.js';
-
-// Inside start():
-const cronTasks = [
-  registerListingExpiryCron(),
-  registerExpiryWarningCron(),
-  registerHardDeleteCleanupCron(),
-  registerRentIndexRefreshCron(),   // ← add
-];
-
- - -
-

New files

- - - - - - - - - - - - - - - -
PathWhat goes in it
migrations/005_roommate_matching.sqlALTER student_profiles (2 columns), CREATE roommate_blocks
migrations/006_rent_index.sqlCREATE rent_observations, rent_index, trigger
src/db/utils/roommateCompatibility.jsscoreUsersForUser(requestingUserId, candidateIds) — Jaccard query
src/services/roommate.service.jsgetRoommateFeed, updateRoommateProfile, blockUser, unblockUser
src/services/rentIndex.service.jsgetRentIndex(city, locality, roomType)
src/controllers/roommate.controller.jsThin controllers delegating to roommate.service
src/controllers/rentIndex.controller.jsSingle handler for GET /rent-index
src/validators/roommate.validators.jsgetRoommateFeedSchema, updateRoommateProfileSchema, blockParamsSchema
src/routes/roommate.jsGET /students/roommates, PUT /:userId/roommate-profile, POST/DELETE /:userId/block
src/routes/rentIndex.jsGET /rent-index
src/cron/rentIndexRefresh.jsNightly rent index recomputation
- -

Modified files

- - - - - - - - - - -
PathChange
src/routes/index.jsMount roommateRouter on /students (or as sub-router), rentIndexRouter on /rent-index
src/routes/student.jsAdd GET /roommates, PUT /:userId/roommate-profile, POST/DELETE /:userId/block — or delegate to roommate.js sub-router
src/services/listing.service.jsgetListing: add LEFT JOIN rent_index + compute rentDeviation. searchListings: add rentDeviation column to SELECT.
src/validators/student.validators.jsAdd updateRoommateProfileSchema export
src/server.jsRegister registerRentIndexRefreshCron
src/cron/hardDeleteCleanup.jsAdd DELETE from rent_observations for aged listing cascades
- -
Mount order matters. In student.js, the new GET /roommates route must be registered before GET /:userId/profile — otherwise Express matches "roommates" as a userId param.
-
- - From e57a71aa6f67234312445a2d65d63cff286cf4b6 Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Sun, 26 Apr 2026 23:56:08 +0530 Subject: [PATCH 33/54] feat: enhance roommate and rent index functionalities with improved constraints, validations, and error handling --- migrations/007_fix_roommate_constraints.sql | 41 +++++++ .../008_fix_rent_index_redundant_index.sql | 8 ++ ...009_idx_listings_posted_by_status_city.sql | 13 ++ src/controllers/rentIndex.controller.js | 8 ++ src/controllers/roommate.controller.js | 50 +++----- src/cron/hardDeleteCleanup.js | 61 +++++++--- src/db/utils/roommateCompatibility.js | 106 ++++++++++------- src/routes/roommate.js | 25 ++-- src/services/listing.service.js | 58 ++++----- src/services/rentIndex.service.js | 34 +++--- src/services/roommate.service.js | 111 +++++++++--------- src/validators/roommate.validators.js | 8 -- 12 files changed, 304 insertions(+), 219 deletions(-) create mode 100644 migrations/007_fix_roommate_constraints.sql create mode 100644 migrations/008_fix_rent_index_redundant_index.sql create mode 100644 migrations/009_idx_listings_posted_by_status_city.sql diff --git a/migrations/007_fix_roommate_constraints.sql b/migrations/007_fix_roommate_constraints.sql new file mode 100644 index 0000000..a7485e7 --- /dev/null +++ b/migrations/007_fix_roommate_constraints.sql @@ -0,0 +1,41 @@ +-- Active: 1777192065763@@127.0.0.1@5432@roomies_db +-- Migration 007: Fix roommate_blocks FK cascade + redundant index + add looking timestamp check +-- +-- 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) + +-- Fix 1: Add CHECK constraint on student_profiles +-- Only safe to add if it won't violate existing data. +-- First backfill: any row already set to TRUE with NULL timestamp gets NOW(). +UPDATE student_profiles +SET + looking_updated_at = NOW() +WHERE + looking_for_roommate = TRUE + AND looking_updated_at IS NULL; + +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: Recreate roommate_blocks with CASCADE and without redundant index +-- We must drop and recreate because ALTER CONSTRAINT is not supported for FK ON DELETE in PG. + +-- Drop old table (no data worth preserving in prod at this stage — blocks are +-- user-generated UX state, not business-critical records). +DROP TABLE IF EXISTS roommate_blocks; + +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) +); + +-- Only keep the blocked_id index (blocker_id is covered by the PK leftmost prefix) +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..eae8418 --- /dev/null +++ b/migrations/009_idx_listings_posted_by_status_city.sql @@ -0,0 +1,13 @@ +-- 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. + +CREATE INDEX IF NOT EXISTS idx_listings_posted_by_status_city ON listings (posted_by, status, city) +WHERE + deleted_at IS NULL; \ No newline at end of file diff --git a/src/controllers/rentIndex.controller.js b/src/controllers/rentIndex.controller.js index 08e489e..851e863 100644 --- a/src/controllers/rentIndex.controller.js +++ b/src/controllers/rentIndex.controller.js @@ -5,6 +5,14 @@ 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) { diff --git a/src/controllers/roommate.controller.js b/src/controllers/roommate.controller.js index 6626e47..72a30c5 100644 --- a/src/controllers/roommate.controller.js +++ b/src/controllers/roommate.controller.js @@ -2,38 +2,24 @@ import * as roommateService from "../services/roommate.service.js"; -export const getFeed = async (req, res, next) => { - try { - const result = await roommateService.getRoommateFeed(req.user.userId, req.query); - res.json({ status: "success", data: result }); - } catch (err) { - next(err); - } -}; +const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); -export const updateRoommateProfile = async (req, res, next) => { - try { - const result = await roommateService.updateRoommateProfile(req.user.userId, req.params.userId, req.body); - res.json({ status: "success", data: result }); - } catch (err) { - next(err); - } -}; +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 blockUser = async (req, res, next) => { - try { - const result = await roommateService.blockUser(req.user.userId, req.params.targetUserId); - res.json({ status: "success", data: result }); - } catch (err) { - next(err); - } -}; +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 unblockUser = async (req, res, next) => { - try { - const result = await roommateService.unblockUser(req.user.userId, req.params.targetUserId); - res.json({ status: "success", data: result }); - } catch (err) { - next(err); - } -}; +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/cron/hardDeleteCleanup.js b/src/cron/hardDeleteCleanup.js index bba6876..9975684 100644 --- a/src/cron/hardDeleteCleanup.js +++ b/src/cron/hardDeleteCleanup.js @@ -1,3 +1,5 @@ +// src/cron/hardDeleteCleanup.js + import cron from "node-cron"; import { pool } from "../db/client.js"; import { logger } from "../logger/index.js"; @@ -59,6 +61,7 @@ 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. @@ -71,6 +74,7 @@ const runHardDeleteCleanup = async () => { const cutoffExpr = `NOW() - ($1::int * INTERVAL '1 day')`; const p = [RETENTION_DAYS]; + // ── rating_reports ──────────────────────────────────────────────────────── const { rowCount: rr } = await client.query( `DELETE FROM rating_reports WHERE deleted_at IS NOT NULL @@ -79,6 +83,7 @@ const runHardDeleteCleanup = async () => { ); results.rating_reports = rr; + // ── ratings ─────────────────────────────────────────────────────────────── const { rowCount: ra } = await client.query( `DELETE FROM ratings WHERE deleted_at IS NOT NULL @@ -87,6 +92,7 @@ const runHardDeleteCleanup = async () => { ); results.ratings = ra; + // ── notifications ───────────────────────────────────────────────────────── const { rowCount: no } = await client.query( `DELETE FROM notifications WHERE deleted_at IS NOT NULL @@ -95,6 +101,7 @@ const runHardDeleteCleanup = async () => { ); results.notifications = no; + // ── connections ─────────────────────────────────────────────────────────── const { rowCount: co } = await client.query( `DELETE FROM connections WHERE deleted_at IS NOT NULL @@ -108,6 +115,7 @@ const runHardDeleteCleanup = async () => { ); results.connections = co; + // ── interest_requests ───────────────────────────────────────────────────── const { rowCount: ir } = await client.query( `DELETE FROM interest_requests WHERE deleted_at IS NOT NULL @@ -116,6 +124,7 @@ const runHardDeleteCleanup = async () => { ); results.interest_requests = ir; + // ── saved_listings ──────────────────────────────────────────────────────── const { rowCount: sl } = await client.query( `DELETE FROM saved_listings WHERE deleted_at IS NOT NULL @@ -124,6 +133,7 @@ const runHardDeleteCleanup = async () => { ); results.saved_listings = sl; + // ── listing_photos ──────────────────────────────────────────────────────── const { rowCount: lp } = await client.query( `DELETE FROM listing_photos WHERE deleted_at IS NOT NULL @@ -132,19 +142,11 @@ const runHardDeleteCleanup = async () => { ); results.listing_photos = lp; - - const { rowCount: ro } = await client.query( - `DELETE FROM rent_observations - WHERE listing_id IN ( - SELECT listing_id FROM listings - WHERE deleted_at IS NOT NULL - AND deleted_at < ${cutoffExpr} - )`, - p, - ); - results.rent_observations = ro; - - 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} @@ -165,12 +167,28 @@ const runHardDeleteCleanup = async () => { WHERE sl.listing_id = listings.listing_id AND sl.deleted_at IS NOT NULL AND sl.deleted_at >= ${cutoffExpr} - )`, - + ) + 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; + // ── verification_requests ───────────────────────────────────────────────── const { rowCount: vr } = await client.query( `DELETE FROM verification_requests WHERE deleted_at IS NOT NULL @@ -179,6 +197,7 @@ const runHardDeleteCleanup = async () => { ); results.verification_requests = vr; + // ── pg_owner_profiles ───────────────────────────────────────────────────── const { rowCount: pop } = await client.query( `DELETE FROM pg_owner_profiles WHERE deleted_at IS NOT NULL @@ -187,8 +206,8 @@ const runHardDeleteCleanup = async () => { ); results.pg_owner_profiles = pop; - // Collect blob URLs before deleting rows so we know what to clean up - // from storage. The actual storageService.delete calls happen after + // 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 @@ -199,6 +218,7 @@ const runHardDeleteCleanup = async () => { ); 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 @@ -207,6 +227,7 @@ const runHardDeleteCleanup = async () => { ); results.student_profiles = sp; + // ── properties ──────────────────────────────────────────────────────────── const { rowCount: pr } = await client.query( `DELETE FROM properties WHERE deleted_at IS NOT NULL @@ -221,6 +242,7 @@ const runHardDeleteCleanup = async () => { ); results.properties = pr; + // ── institutions ────────────────────────────────────────────────────────── const { rowCount: ins } = await client.query( `DELETE FROM institutions WHERE deleted_at IS NOT NULL @@ -229,6 +251,7 @@ const runHardDeleteCleanup = async () => { ); results.institutions = ins; + // ── users ───────────────────────────────────────────────────────────────── const { rowCount: us } = await client.query( `DELETE FROM users WHERE deleted_at IS NOT NULL @@ -271,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} )`, diff --git a/src/db/utils/roommateCompatibility.js b/src/db/utils/roommateCompatibility.js index 7522cd2..cfc8bc0 100644 --- a/src/db/utils/roommateCompatibility.js +++ b/src/db/utils/roommateCompatibility.js @@ -15,6 +15,7 @@ // should set compatibilityAvailable = false in that case. import { pool } from "../client.js"; +import { logger } from "../../logger/index.js"; // scoreUsersForUser // @@ -23,58 +24,79 @@ import { pool } from "../client.js"; // Already filtered for opt-in, blocks, and city before this call. // // Returns: { [userId]: score 0–100 } +// On DB failure: logs the error and returns {} so the feed still renders +// without compatibility scores rather than crashing the request. export const scoreUsersForUser = async (requestingUserId, candidateIds, client = pool) => { if (!candidateIds.length) return {}; - // 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], - ); + 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. - // We fetch the requesting user alongside the candidates in one query. - 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`, - [[requestingUserId, ...candidateIds]], - ); + // Step 2 — individual preference counts for union computation. + // We fetch the requesting user alongside the candidates in one query. + 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`, + [[requestingUserId, ...candidateIds]], + ); - 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])); + 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; - // When union is 0 both users have no preferences — score 0, caller sets - // compatibilityAvailable = false so the UI can hide the score badge. - acc[id] = union === 0 ? 0 : Math.round((shared / union) * 100); - return acc; - }, {}); + return candidateIds.reduce((acc, id) => { + const shared = sharedMap[id] ?? 0; + const theirCount = countMap[id] ?? 0; + const union = myCount + theirCount - shared; + // When union is 0 both users have no preferences — score 0, caller sets + // compatibilityAvailable = false so the UI can hide the score badge. + 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 safe default so the feed still renders without scores + return {}; + } }; // hasPreferences // // Quick check: does the given user have at least one preference row? // Used to set compatibilityAvailable on the requesting user's own side. +// On DB failure: logs the error and returns false so the feed degrades +// gracefully (no compatibility scores shown) rather than crashing. export const hasPreferences = async (userId, client = pool) => { - 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; -}; + 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; + } +}; \ No newline at end of file diff --git a/src/routes/roommate.js b/src/routes/roommate.js index a039026..6717fc6 100644 --- a/src/routes/roommate.js +++ b/src/routes/roommate.js @@ -1,18 +1,10 @@ // src/routes/roommate.js -// -// Mounted inside student.js BEFORE the /:userId param routes to prevent -// "roommates" being captured as a userId. -// -// Route tree (relative to /api/v1/students): -// GET /roommates — paginated roommate feed -// PUT /:userId/roommate-profile — toggle opt-in + update bio -// POST /:userId/block/:targetUserId — block a user from your feed -// DELETE /:userId/block/:targetUserId — unblock 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, @@ -22,6 +14,14 @@ 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", @@ -31,20 +31,22 @@ roommateRouter.get( roommateController.getFeed, ); -// Opt-in toggle — own profile only (service layer enforces userId match) +// Opt-in toggle — own profile only roommateRouter.put( "/:userId/roommate-profile", authenticate, authorize("student"), + requireSelf, validate(updateRoommateProfileSchema), roommateController.updateRoommateProfile, ); -// Block / unblock +// Block / unblock — :userId must be the authenticated user roommateRouter.post( "/:userId/block/:targetUserId", authenticate, authorize("student"), + requireSelf, validate(blockTargetParamsSchema), roommateController.blockUser, ); @@ -53,6 +55,7 @@ roommateRouter.delete( "/:userId/block/:targetUserId", authenticate, authorize("student"), + requireSelf, validate(blockTargetParamsSchema), roommateController.unblockUser, ); diff --git a/src/services/listing.service.js b/src/services/listing.service.js index 1b133d2..481ff48 100644 --- a/src/services/listing.service.js +++ b/src/services/listing.service.js @@ -163,7 +163,18 @@ const fetchListingDetail = async (listingId, client = pool) => { 'averageRating', p.average_rating, 'ratingCount', p.rating_count ) - ELSE NULL END AS property + ELSE NULL END AS property, + + -- Rent index (locality-level preferred, city-wide fallback) + COALESCE(ri_loc.p25, ri_city.p25) AS ri_p25, + COALESCE(ri_loc.p50, ri_city.p50) AS ri_p50, + COALESCE(ri_loc.p75, ri_city.p75) AS ri_p75, + COALESCE(ri_loc.sample_count, ri_city.sample_count) AS ri_sample_count, + CASE + WHEN ri_loc.rent_index_id IS NOT NULL THEN 'locality' + WHEN ri_city.rent_index_id IS NOT NULL THEN 'city' + ELSE NULL + END AS ri_resolution FROM listings l JOIN users u ON u.user_id = l.posted_by @@ -173,6 +184,14 @@ const fetchListingDetail = async (listingId, client = pool) => { LEFT JOIN amenities a ON a.amenity_id = la.amenity_id LEFT JOIN listing_preferences lp ON lp.listing_id = l.listing_id LEFT JOIN properties p ON p.property_id = l.property_id AND p.deleted_at IS NULL + LEFT JOIN rent_index ri_loc + ON ri_loc.city = l.city + AND ri_loc.locality = NULLIF(LOWER(TRIM(COALESCE(l.locality, ''))), '') + AND ri_loc.room_type = l.room_type + LEFT JOIN rent_index ri_city + ON ri_city.city = l.city + AND ri_city.locality IS NULL + AND ri_city.room_type = l.room_type WHERE l.listing_id = $1 AND l.deleted_at IS NULL GROUP BY @@ -180,7 +199,9 @@ const fetchListingDetail = async (listingId, client = pool) => { sp.full_name, pop.owner_full_name, p.property_id, p.property_name, p.property_type, p.address_line, p.city, p.locality, p.latitude, p.longitude, p.house_rules, - p.average_rating, p.rating_count`, + p.average_rating, p.rating_count, + ri_loc.p25, ri_loc.p50, ri_loc.p75, ri_loc.sample_count, ri_loc.rent_index_id, + ri_city.p25, ri_city.p50, ri_city.p75, ri_city.sample_count, ri_city.rent_index_id`, [listingId], ); @@ -316,46 +337,19 @@ export const getListing = async (listingId) => { const listing = await fetchListingDetail(listingId); if (!listing) throw new AppError("Listing not found", 404); - // Increment view count fire-and-forget — unchanged. + // Increment view count fire-and-forget void pool .query(`UPDATE listings SET views_count = views_count + 1 WHERE listing_id = $1`, [listingId]) .catch((err) => { logger.warn({ err, listingId }, "Failed to increment listing view count"); }); - // Fetch rent index for this listing's city / locality / room_type. - // Two LEFT JOINs: locality-specific first, city-wide fallback second. - const { rows: riRows } = await pool.query( - `SELECT - COALESCE(ri_loc.p25, ri_city.p25) AS ri_p25, - COALESCE(ri_loc.p50, ri_city.p50) AS ri_p50, - COALESCE(ri_loc.p75, ri_city.p75) AS ri_p75, - COALESCE(ri_loc.sample_count, ri_city.sample_count) AS ri_sample_count, - CASE - WHEN ri_loc.rent_index_id IS NOT NULL THEN 'locality' - WHEN ri_city.rent_index_id IS NOT NULL THEN 'city' - ELSE NULL - END AS ri_resolution - FROM listings l - LEFT JOIN rent_index ri_loc - ON ri_loc.city = l.city - AND ri_loc.locality = NULLIF(LOWER(TRIM(COALESCE(l.locality, ''))), '') - AND ri_loc.room_type = l.room_type - LEFT JOIN rent_index ri_city - ON ri_city.city = l.city - AND ri_city.locality IS NULL - AND ri_city.room_type = l.room_type - WHERE l.listing_id = $1`, - [listingId], - ); - - const ri = riRows[0] ?? {}; const converted = toRupees(listing); return { ...converted, - rentDeviation: rentDeviationPct(listing.rent_per_month, ri.ri_p50), - rentIndex: formatRentIndex(ri), + rentDeviation: rentDeviationPct(listing.rent_per_month, listing.ri_p50), + rentIndex: formatRentIndex(listing), }; }; diff --git a/src/services/rentIndex.service.js b/src/services/rentIndex.service.js index f88c7ea..4055444 100644 --- a/src/services/rentIndex.service.js +++ b/src/services/rentIndex.service.js @@ -1,32 +1,28 @@ // src/services/rentIndex.service.js // -// Public query interface for the rent_index table. -// -// getRentIndex: fetches the precomputed percentiles for a given -// city + optional locality + room_type combination. -// Falls back to city-wide data (locality IS NULL) when no -// locality-specific row exists, matching the same fallback logic -// used when enriching listing responses. + import { pool } from "../db/client.js"; import { AppError } from "../middleware/errorHandler.js"; -// Converts paise to rupees for the JSON response. const paiseToRupees = (paise) => (paise != null ? Math.round(paise / 100) : null); export const getRentIndex = async ({ city, locality, roomType }) => { - // Try locality-specific row first, then city-wide fallback. - // Both lookups in one query using COALESCE across two LEFT JOINs. + const normCity = city ? city.toLowerCase().trim() : city; const normLocality = locality ? locality.toLowerCase().trim() : null; const { rows } = await pool.query( `SELECT - COALESCE(ri_loc.p25, ri_city.p25) AS p25, - COALESCE(ri_loc.p50, ri_city.p50) AS p50, - COALESCE(ri_loc.p75, ri_city.p75) AS p75, + COALESCE(ri_loc.p25, ri_city.p25) AS p25, + COALESCE(ri_loc.p50, ri_city.p50) AS p50, + COALESCE(ri_loc.p75, ri_city.p75) AS p75, COALESCE(ri_loc.sample_count, ri_city.sample_count) AS sample_count, COALESCE(ri_loc.computed_at, ri_city.computed_at) AS computed_at, - CASE WHEN ri_loc.rent_index_id IS NOT NULL THEN 'locality' ELSE 'city' END AS resolution + CASE + WHEN ri_loc.rent_index_id IS NOT NULL THEN 'locality' + WHEN ri_city.rent_index_id IS NOT NULL THEN 'city' + ELSE NULL + END AS resolution FROM (SELECT 1) AS _dual LEFT JOIN rent_index ri_loc ON ri_loc.city = $1 @@ -36,20 +32,18 @@ export const getRentIndex = async ({ city, locality, roomType }) => { ON ri_city.city = $1 AND ri_city.locality IS NULL AND ri_city.room_type = $3::room_type_enum`, - [city, normLocality, roomType], + [normCity, normLocality, roomType], ); const row = rows[0]; - if (!row || row.p50 == null) { - // No data for this combination yet — 404 is the right response so the - // caller knows not to display a deviation badge. + if (row.p50 == null) { throw new AppError("No rent index data available for this city / room type combination yet", 404); } return { - city, - locality: locality ?? null, + city: normCity, + locality: normLocality, // normalized value (null when blank/absent) roomType, resolution: row.resolution, // 'locality' | 'city' p25: paiseToRupees(row.p25), diff --git a/src/services/roommate.service.js b/src/services/roommate.service.js index 54395a4..aa6ef55 100644 --- a/src/services/roommate.service.js +++ b/src/services/roommate.service.js @@ -1,18 +1,5 @@ // src/services/roommate.service.js // -// Business logic for the roommate-matching feed. -// -// Feed strategy: -// 1. Fetch a page of candidate student IDs (opt-in, not blocked, optional city -// filter) ordered by looking_updated_at DESC with keyset cursor. -// 2. Score those IDs against the requesting user via Jaccard similarity. -// 3. Merge scores into the candidate rows and return. -// -// We do NOT sort by score at the DB level — that would require loading all -// candidates before paginating, which kills performance. Instead we surface -// recency-ordered candidates with score as a display field. A future -// "sortBy=compatibility" mode can fetch a larger batch (e.g. 200), sort in -// memory, and slice — same tradeoff as the existing listing search. import { pool } from "../db/client.js"; import { logger } from "../logger/index.js"; @@ -27,15 +14,8 @@ export const getRoommateFeed = async (requestingUserId, filters) => { const { city, cursorTime, cursorId, limit = 20 } = filters; const safeLimit = Math.min(Math.max(1, Number(limit) || 20), 50); - // Whether the requesting user has any preferences set — needed to decide - // whether to expose compatibility scores at all. const callerHasPrefs = await hasPreferences(requestingUserId); - // Build the candidate query dynamically. - // We exclude: - // • the requesting user themselves - // • users blocked BY the caller (blocker_id = caller) - // • users who have blocked the caller (blocked_id = caller) const clauses = [ `sp.looking_for_roommate = TRUE`, `sp.deleted_at IS NULL`, @@ -54,9 +34,7 @@ export const getRoommateFeed = async (requestingUserId, filters) => { if (city) { const escaped = city.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_"); - // Match against the city stored on their most-recent active listing, falling - // back to a simple city column on student_profiles if we add one later. - // For now we check listings — a student has at least one active listing in that city. + clauses.push( `EXISTS ( SELECT 1 FROM listings l @@ -115,6 +93,7 @@ export const getRoommateFeed = async (requestingUserId, filters) => { // Fetch all preferences for candidates in one query (avoids N+1). let preferenceMap = {}; let candidateHasPrefSet = {}; + if (items.length) { const candidateIds = items.map((r) => r.user_id); @@ -135,8 +114,6 @@ export const getRoommateFeed = async (requestingUserId, filters) => { candidateHasPrefSet[row.user_id] = true; } - // Score only if the caller has preferences — otherwise every score is 0 - // and we just mark compatibilityAvailable = false. if (callerHasPrefs && candidateIds.length) { const scores = await scoreUsersForUser(requestingUserId, candidateIds); for (const item of items) { @@ -145,11 +122,12 @@ export const getRoommateFeed = async (requestingUserId, filters) => { } } + const lastItem = hasNextPage ? items[items.length - 1] : null; const nextCursor = - hasNextPage ? + hasNextPage && lastItem?.looking_updated_at != null ? { - cursorTime: items[items.length - 1].looking_updated_at.toISOString(), - cursorId: items[items.length - 1].user_id, + cursorTime: lastItem.looking_updated_at.toISOString(), + cursorId: lastItem.user_id, } : null; @@ -206,40 +184,63 @@ export const updateRoommateProfile = async (requestingUserId, targetUserId, { lo }; }; -// ─── blockUser ──────────────────────────────────────────────────────────────── - export const blockUser = async (requestingUserId, targetUserId) => { if (requestingUserId === targetUserId) { throw new AppError("You cannot block yourself", 422); } - // Verify the target user exists and is a student - const { rows: targetRows } = await pool.query( - `SELECT u.user_id FROM users u - WHERE u.user_id = $1 - AND u.deleted_at IS NULL - AND u.account_status = 'active'`, - [targetUserId], - ); - if (!targetRows.length) { - throw new AppError("User not found", 404); - } + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // Serialize concurrent block operations for the same blocker. + await client.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [requestingUserId]); + + // Verify target is an active student (join student_profiles). + const { rows: targetRows } = await client.query( + `SELECT u.user_id + FROM users u + JOIN student_profiles sp + ON sp.user_id = u.user_id + AND sp.deleted_at IS NULL + WHERE u.user_id = $1 + AND u.deleted_at IS NULL + AND u.account_status = 'active'`, + [targetUserId], + ); + if (!targetRows.length) { + await client.query("ROLLBACK"); + throw new AppError("User not found", 404); + } - // Soft cap — prevent bloat - const { rows: countRows } = await pool.query( - `SELECT COUNT(*)::int AS cnt FROM roommate_blocks WHERE blocker_id = $1`, - [requestingUserId], - ); - if (countRows[0].cnt >= MAX_BLOCKS_PER_USER) { - throw new AppError(`You can block at most ${MAX_BLOCKS_PER_USER} users`, 422); - } + // Soft cap — re-checked inside the transaction to prevent TOCTOU race. + const { rows: countRows } = await client.query( + `SELECT COUNT(*)::int AS cnt FROM roommate_blocks WHERE blocker_id = $1`, + [requestingUserId], + ); + if (countRows[0].cnt >= MAX_BLOCKS_PER_USER) { + await client.query("ROLLBACK"); + throw new AppError(`You can block at most ${MAX_BLOCKS_PER_USER} users`, 422); + } - await pool.query( - `INSERT INTO roommate_blocks (blocker_id, blocked_id) - VALUES ($1, $2) - ON CONFLICT (blocker_id, blocked_id) DO NOTHING`, - [requestingUserId, targetUserId], - ); + await client.query( + `INSERT INTO roommate_blocks (blocker_id, blocked_id) + VALUES ($1, $2) + ON CONFLICT (blocker_id, blocked_id) DO NOTHING`, + [requestingUserId, targetUserId], + ); + + await client.query("COMMIT"); + } catch (err) { + try { + await client.query("ROLLBACK"); + } catch (rollbackErr) { + logger.error({ rollbackErr, err }, "blockUser: rollback failed"); + } + throw err; + } finally { + client.release(); + } logger.info({ blockerId: requestingUserId, blockedId: targetUserId }, "Roommate block added"); return { blockedUserId: targetUserId, blocked: true }; diff --git a/src/validators/roommate.validators.js b/src/validators/roommate.validators.js index 4ad1aa3..f43c64c 100644 --- a/src/validators/roommate.validators.js +++ b/src/validators/roommate.validators.js @@ -25,14 +25,6 @@ export const updateRoommateProfileSchema = z.object({ }), }); -export const roommateBlockParamsSchema = z.object({ - params: z.object({ - userId: z.uuid({ error: "Invalid user ID" }), // requesting user - targetUserId: z.uuid({ error: "Invalid target user ID" }), - }), -}); - -// Used for block/unblock endpoints where only the target user ID matters export const blockTargetParamsSchema = z.object({ params: z.object({ userId: z.uuid({ error: "Invalid user ID" }), From efce0df415a953863237582b3747e2af39ac547a Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Thu, 30 Apr 2026 10:29:49 +0530 Subject: [PATCH 34/54] postman --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 17f978e..9fb72b4 100644 --- a/.gitignore +++ b/.gitignore @@ -25,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/ From 12172df027ffa0e7c24a4d6a10c900157cda0d1c Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Thu, 30 Apr 2026 13:25:28 +0530 Subject: [PATCH 35/54] fixNeeded --- implementation_plan.md | 324 +++++++++++++++++++++++++++++++++++++++++ task.md | 16 ++ 2 files changed, 340 insertions(+) create mode 100644 implementation_plan.md create mode 100644 task.md diff --git a/implementation_plan.md b/implementation_plan.md new file mode 100644 index 0000000..4f0d480 --- /dev/null +++ b/implementation_plan.md @@ -0,0 +1,324 @@ +# TanStack Query Caching Layer — Implementation Plan + +## Overview + +Add a full three-layer caching system to the Roomies frontend using **TanStack Query v5**. The app currently has zero caching: every route mount and every navigation triggers fresh network requests. This plan adds in-memory caching with stale-while-revalidate semantics, route-level preloading via loaders, and optional session-storage persistence — all without breaking any existing functionality. + +--- + +## Key Findings + +| Issue | Location | Impact | +|---|---|---| +| 6 parallel API calls on every dashboard mount | `dashboard.tsx` | High | +| `setInterval` polling every 60s in all tabs | `NotificationBell.tsx` | Medium | +| Saved listings re-fetched on every browse visit | `browse.tsx` | Medium | +| Notifications re-fetched on every mount | `notifications.tsx` | Medium | +| `getStudentProfile` + `getSessions` fetched in both `profile.tsx` and `sessions.tsx` | Both pages | Medium | +| `getMyConnections` called twice on connections page | `connections.tsx` | Low | +| `defaultPreloadStaleTime: 0` in router | `router.tsx` | High (disables router preload) | + +--- + +## User Review Required + +> [!IMPORTANT] +> This is a **full caching overhaul**. All pages will be converted from raw `useEffect` to `useQuery`/`useMutation`. The routing layer will be updated to use route loaders that prefetch data. The changes are backward compatible but touch almost every route file. + +> [!WARNING] +> The `browse.tsx` listing search uses cursor-based infinite pagination (`Load More`). TanStack Query's `useInfiniteQuery` is ideal here, but it changes the data shape. The plan converts it to `useInfiniteQuery` — the UI stays identical, just backed by the cache. + +--- + +## Proposed Changes + +### Step 1 — Install Dependencies + +``` +pnpm add @tanstack/react-query @tanstack/react-query-persist-client @tanstack/query-sync-storage-persister +``` + +No new devDependencies needed (types are included). + +--- + +### Step 2 — New Infrastructure Files + +--- + +#### [NEW] `src/lib/queryKeys.ts` + +Centralized, typed query key factory. All `queryKey` arrays live here — prevents typos and makes `invalidateQueries` calls predictable. + +```ts +export const queryKeys = { + // Auth / sessions + sessions: () => ['sessions'] as const, + + // Notifications + notifications: { + all: (isRead?: boolean) => ['notifications', { isRead }] as const, + unreadCount: () => ['notifications', 'unread-count'] as const, + }, + + // Profiles + studentProfile: (userId: string) => ['profile', 'student', userId] as const, + pgOwnerProfile: (userId: string) => ['profile', 'pg-owner', userId] as const, + + // Interests + interests: (status?: string) => ['interests', { status }] as const, + + // Connections + connections: (status?: string) => ['connections', { status }] as const, + connection: (id: string) => ['connections', id] as const, + + // Listings + listings: (filters: object) => ['listings', filters] as const, + listing: (id: string) => ['listing', id] as const, + savedListings: () => ['saved-listings'] as const, + + // Properties + properties: () => ['properties'] as const, + + // Amenities (near-static) + amenities: () => ['amenities'] as const, +}; +``` + +--- + +#### [NEW] `src/lib/queryClient.ts` + +Singleton `QueryClient` with data-classified `staleTime` values and optional `sessionStorage` persistence. The persister is only active in production (`IS_PROD`), and uses `shouldDehydrateQuery` to whitelist only safe-to-persist queries (no contact reveals, no session tokens). + +```ts +import { QueryClient } from '@tanstack/react-query'; +import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'; +import { persistQueryClient } from '@tanstack/react-query-persist-client'; + +// Classified stale times (ms) +export const STALE = { + STATIC: 60 * 60 * 1000, // 1 hour — amenities, enums + PROFILE: 5 * 60 * 1000, // 5 min — user profile + SESSIONS: 2 * 60 * 1000, // 2 min — active sessions + FEED: 2 * 60 * 1000, // 2 min — listings browse + TRANSACTIONAL: 30 * 1000, // 30 sec — interests, connections + NOTIFICATION: 30 * 1000, // 30 sec — unread count +}; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: STALE.FEED, // sensible default + gcTime: 10 * 60 * 1000, // 10 min in-memory gc + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}); + +// Safe-to-persist query keys (no PII, no contact reveals) +const PERSIST_WHITELIST = ['amenities', 'profile']; + +if (typeof window !== 'undefined' && import.meta.env.PROD) { + const persister = createSyncStoragePersister({ storage: window.sessionStorage }); + persistQueryClient({ + queryClient, + persister, + maxAge: 30 * 60 * 1000, // 30 min persistence window + dehydrateOptions: { + shouldDehydrateQuery: (query) => + PERSIST_WHITELIST.some((key) => String(query.queryKey[0]).startsWith(key)), + }, + }); +} +``` + +--- + +### Step 3 — Plumbing: Router & Root + +--- + +#### [MODIFY] [router.tsx](file:///home/sumit/Desktop/Roomies/roomies_frontend/src/router.tsx) + +- Pass `queryClient` into the router context so route `loader` functions can call `queryClient.ensureQueryData(...)`. +- Set `defaultPreloadStaleTime` to something sensible (e.g., `30_000` ms) so the router's intent-preload is actually useful. + +```ts +import { queryClient } from '#/lib/queryClient'; + +export function getRouter() { + const router = createTanStackRouter({ + routeTree, + context: { queryClient }, + scrollRestoration: true, + defaultPreload: 'intent', + defaultPreloadStaleTime: 30_000, + }); + return router; +} + +declare module '@tanstack/react-router' { + interface Register { router: ReturnType; } + interface RouterContext { queryClient: typeof queryClient; } +} +``` + +--- + +#### [MODIFY] [__root.tsx](file:///home/sumit/Desktop/Roomies/roomies_frontend/src/routes/__root.tsx) + +Wrap the entire app with `QueryClientProvider`. Add `ReactQueryDevtools` (dev only) — this integrates with the existing `TanStackDevtools` panel or can be added separately. + +```tsx +import { QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { queryClient } from '#/lib/queryClient'; + +function RootComponent() { + return ( + + + + + + + + + ); +} +``` + +--- + +### Step 4 — Component Conversions + +--- + +#### [MODIFY] [NotificationBell.tsx](file:///home/sumit/Desktop/Roomies/roomies_frontend/src/components/NotificationBell.tsx) + +Replace the manual `setInterval` with `useQuery` + `refetchInterval`. Key improvement: `refetchIntervalInBackground: false` stops server polling when the browser tab is not focused. + +**Before:** `setInterval(fetchUnreadCount, 60000)` — always running +**After:** `useQuery({ refetchInterval: 60_000, refetchIntervalInBackground: false })` — stops in background tabs + +--- + +#### [MODIFY] [dashboard.tsx](file:///home/sumit/Desktop/Roomies/roomies_frontend/src/routes/_auth/dashboard.tsx) + +Replace the large `Promise.all` inside `useEffect` with individual `useQuery` calls. Add a route `loader` that prefetches everything before the route renders. + +**Queries to convert:** +- `getStudentProfile(userId)` → `useQuery({ queryKey: queryKeys.studentProfile(userId), staleTime: STALE.PROFILE })` +- `getMyInterests('pending')` → `useQuery({ queryKey: queryKeys.interests('pending'), staleTime: STALE.TRANSACTIONAL })` +- `getMyInterests('accepted')` → same pattern +- `getMyConnections('confirmed')` → `useQuery({ queryKey: queryKeys.connections('confirmed'), staleTime: STALE.TRANSACTIONAL })` +- `getSavedListings()` → `useQuery({ queryKey: queryKeys.savedListings(), staleTime: STALE.FEED })` +- `getNotifications(false)` → `useQuery({ queryKey: queryKeys.notifications.all(false), staleTime: STALE.NOTIFICATION })` + +Same pattern for `PgOwnerDashboard`. + +**Route loader:** +```ts +loader: ({ context: { queryClient }, params }) => { + // fire-and-forget prefetch; route will still render if slow + queryClient.ensureQueryData({ queryKey: queryKeys.studentProfile(userId), queryFn: ... }); + // ...other queries +} +``` + +> [!NOTE] +> Since `userId` comes from `AuthContext` (not route params), the loader will prefetch what it can (amenities, notifications) and the rest will be prefetched optimistically. + +--- + +#### [MODIFY] [notifications.tsx](file:///home/sumit/Desktop/Roomies/roomies_frontend/src/routes/_auth/notifications.tsx) + +- `getNotifications()` → `useInfiniteQuery` (for Load More cursor support) +- `markAsRead([id])` → `useMutation` with `onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.unreadCount() })` +- `markAsRead()` (all) → same pattern + +**This means when a notification is marked read on the Notifications page, the bell count in the header updates immediately without an extra network call.** + +--- + +#### [MODIFY] [browse.tsx](file:///home/sumit/Desktop/Roomies/roomies_frontend/src/routes/_auth/browse.tsx) + +- `searchListings(filters)` → `useInfiniteQuery` keyed on `queryKeys.listings(filters)`. Each unique filter combination gets its own cache entry with 2-minute stale time. +- `getSavedListings()` → `useQuery({ queryKey: queryKeys.savedListings() })` — shared with dashboard, cached once +- `saveListing(id)` → `useMutation` with `onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.savedListings() })` +- `unsaveListing(id)` → same + +--- + +#### [MODIFY] [connections.tsx](file:///home/sumit/Desktop/Roomies/roomies_frontend/src/routes/_auth/connections.tsx) + +- `getMyConnections()` → `useQuery({ queryKey: queryKeys.connections(), staleTime: STALE.TRANSACTIONAL })` +- `confirmConnection(id)` → `useMutation` → `onSuccess: invalidateQueries(connections)` +- `getConnection(id)` (detail fetch) → `useQuery({ queryKey: queryKeys.connection(id) })` + +--- + +#### [MODIFY] [profile.tsx](file:///home/sumit/Desktop/Roomies/roomies_frontend/src/routes/_auth/profile.tsx) + +- `getStudentProfile(userId)` → `useQuery({ queryKey: queryKeys.studentProfile(userId), staleTime: STALE.PROFILE })` +- `getSessions()` → `useQuery({ queryKey: queryKeys.sessions(), staleTime: STALE.SESSIONS })` +- `updateStudentProfile(...)` → `useMutation` → `onSuccess: invalidateQueries(studentProfile)` +- `submitVerificationDocument(...)` → `useMutation` → `onSuccess: invalidateQueries(pgOwnerProfile)` + +--- + +#### [MODIFY] [sessions.tsx](file:///home/sumit/Desktop/Roomies/roomies_frontend/src/routes/_auth/sessions.tsx) + +- `getSessions()` → `useQuery({ queryKey: queryKeys.sessions(), staleTime: STALE.SESSIONS })` +- `revokeSession(sid)` → `useMutation` → `onSuccess: () => queryClient.setQueryData(queryKeys.sessions(), filter out sid)` (optimistic update, no refetch needed) + +--- + +### Step 5 — Listing Detail Page + +#### [MODIFY] [listing.$id.tsx](file:///home/sumit/Desktop/Roomies/roomies_frontend/src/routes/_auth/listing.$id.tsx) + +Add a route `loader` that prefetches the listing detail. This is the highest-value place for a loader since users hover listing cards before clicking them (intent preload fires here). + +```ts +loader: ({ context: { queryClient }, params }) => + queryClient.ensureQueryData({ + queryKey: queryKeys.listing(params.id), + queryFn: () => getListing(params.id), + staleTime: STALE.FEED, + }) +``` + +--- + +## Verification Plan + +### Automated Tests +- Existing vitest suite: `pnpm test` — ensure no regressions +- No new test files in scope for this PR + +### Manual Verification + +1. **Cache hit test**: Navigate to Dashboard → Notifications → back to Dashboard. Open browser DevTools Network tab. The second Dashboard visit should show zero new API calls (within `staleTime` window). +2. **NotificationBell**: Open two tabs. Switch away from tab. Confirm no network activity for 60 seconds on the background tab (previously it would still poll). +3. **Mark-read cascade**: Mark a notification as read on `/notifications`. Without navigating away, verify the bell count in the header decrements (proves `invalidateQueries` cross-component sync works). +4. **Save listing cascade**: Save a listing in Browse. Navigate to Dashboard. The "Saved Listings" count should immediately reflect the new save without a spinner. +5. **DevTools**: Open TanStack Query devtools (bottom-right panel) and verify queries are appearing with correct `staleTime` and `status`. +6. **Build check**: `pnpm build` — TypeScript must compile clean. + +--- + +## Execution Order (Safest Path) + +1. Install packages + create `queryKeys.ts` + `queryClient.ts` +2. Wire `QueryClientProvider` into `__root.tsx` +3. Wire `queryClient` context into `router.tsx` +4. Convert `NotificationBell.tsx` (smallest, highest impact) +5. Convert `dashboard.tsx` (most queries, highest traffic) +6. Convert `notifications.tsx` + `sessions.tsx` (straightforward) +7. Convert `browse.tsx` (infinite query, needs care) +8. Convert `connections.tsx` + `profile.tsx` +9. Add route loaders (`listing.$id.tsx`, `dashboard`) +10. Run `pnpm build` to verify TypeScript diff --git a/task.md b/task.md new file mode 100644 index 0000000..e2beacb --- /dev/null +++ b/task.md @@ -0,0 +1,16 @@ +# TanStack Query Caching Layer — Task Tracker + +- [x] Step 1: Install @tanstack/react-query + persist packages +- [/] Step 2: Create src/lib/queryKeys.ts +- [ ] Step 3: Create src/lib/queryClient.ts +- [ ] Step 4: Wire QueryClientProvider into __root.tsx +- [ ] Step 5: Wire queryClient context into router.tsx +- [ ] Step 6: Convert NotificationBell.tsx +- [ ] Step 7: Convert dashboard.tsx +- [ ] Step 8: Convert notifications.tsx +- [ ] Step 9: Convert sessions.tsx +- [ ] Step 10: Convert browse.tsx (useInfiniteQuery) +- [ ] Step 11: Convert connections.tsx +- [ ] Step 12: Convert profile.tsx +- [ ] Step 13: Add route loader for listing.$id.tsx +- [ ] Step 14: Run pnpm build to verify TypeScript From 033602efc5a8ef60ca4a7128de8ee8b11661299b Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Thu, 30 Apr 2026 13:29:54 +0530 Subject: [PATCH 36/54] feat: remove implementation plan and task tracker for TanStack Query caching layer --- implementation_plan.md | 324 ----------------------------------------- task.md | 16 -- 2 files changed, 340 deletions(-) delete mode 100644 implementation_plan.md delete mode 100644 task.md diff --git a/implementation_plan.md b/implementation_plan.md deleted file mode 100644 index 4f0d480..0000000 --- a/implementation_plan.md +++ /dev/null @@ -1,324 +0,0 @@ -# TanStack Query Caching Layer — Implementation Plan - -## Overview - -Add a full three-layer caching system to the Roomies frontend using **TanStack Query v5**. The app currently has zero caching: every route mount and every navigation triggers fresh network requests. This plan adds in-memory caching with stale-while-revalidate semantics, route-level preloading via loaders, and optional session-storage persistence — all without breaking any existing functionality. - ---- - -## Key Findings - -| Issue | Location | Impact | -|---|---|---| -| 6 parallel API calls on every dashboard mount | `dashboard.tsx` | High | -| `setInterval` polling every 60s in all tabs | `NotificationBell.tsx` | Medium | -| Saved listings re-fetched on every browse visit | `browse.tsx` | Medium | -| Notifications re-fetched on every mount | `notifications.tsx` | Medium | -| `getStudentProfile` + `getSessions` fetched in both `profile.tsx` and `sessions.tsx` | Both pages | Medium | -| `getMyConnections` called twice on connections page | `connections.tsx` | Low | -| `defaultPreloadStaleTime: 0` in router | `router.tsx` | High (disables router preload) | - ---- - -## User Review Required - -> [!IMPORTANT] -> This is a **full caching overhaul**. All pages will be converted from raw `useEffect` to `useQuery`/`useMutation`. The routing layer will be updated to use route loaders that prefetch data. The changes are backward compatible but touch almost every route file. - -> [!WARNING] -> The `browse.tsx` listing search uses cursor-based infinite pagination (`Load More`). TanStack Query's `useInfiniteQuery` is ideal here, but it changes the data shape. The plan converts it to `useInfiniteQuery` — the UI stays identical, just backed by the cache. - ---- - -## Proposed Changes - -### Step 1 — Install Dependencies - -``` -pnpm add @tanstack/react-query @tanstack/react-query-persist-client @tanstack/query-sync-storage-persister -``` - -No new devDependencies needed (types are included). - ---- - -### Step 2 — New Infrastructure Files - ---- - -#### [NEW] `src/lib/queryKeys.ts` - -Centralized, typed query key factory. All `queryKey` arrays live here — prevents typos and makes `invalidateQueries` calls predictable. - -```ts -export const queryKeys = { - // Auth / sessions - sessions: () => ['sessions'] as const, - - // Notifications - notifications: { - all: (isRead?: boolean) => ['notifications', { isRead }] as const, - unreadCount: () => ['notifications', 'unread-count'] as const, - }, - - // Profiles - studentProfile: (userId: string) => ['profile', 'student', userId] as const, - pgOwnerProfile: (userId: string) => ['profile', 'pg-owner', userId] as const, - - // Interests - interests: (status?: string) => ['interests', { status }] as const, - - // Connections - connections: (status?: string) => ['connections', { status }] as const, - connection: (id: string) => ['connections', id] as const, - - // Listings - listings: (filters: object) => ['listings', filters] as const, - listing: (id: string) => ['listing', id] as const, - savedListings: () => ['saved-listings'] as const, - - // Properties - properties: () => ['properties'] as const, - - // Amenities (near-static) - amenities: () => ['amenities'] as const, -}; -``` - ---- - -#### [NEW] `src/lib/queryClient.ts` - -Singleton `QueryClient` with data-classified `staleTime` values and optional `sessionStorage` persistence. The persister is only active in production (`IS_PROD`), and uses `shouldDehydrateQuery` to whitelist only safe-to-persist queries (no contact reveals, no session tokens). - -```ts -import { QueryClient } from '@tanstack/react-query'; -import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'; -import { persistQueryClient } from '@tanstack/react-query-persist-client'; - -// Classified stale times (ms) -export const STALE = { - STATIC: 60 * 60 * 1000, // 1 hour — amenities, enums - PROFILE: 5 * 60 * 1000, // 5 min — user profile - SESSIONS: 2 * 60 * 1000, // 2 min — active sessions - FEED: 2 * 60 * 1000, // 2 min — listings browse - TRANSACTIONAL: 30 * 1000, // 30 sec — interests, connections - NOTIFICATION: 30 * 1000, // 30 sec — unread count -}; - -export const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: STALE.FEED, // sensible default - gcTime: 10 * 60 * 1000, // 10 min in-memory gc - retry: 1, - refetchOnWindowFocus: false, - }, - }, -}); - -// Safe-to-persist query keys (no PII, no contact reveals) -const PERSIST_WHITELIST = ['amenities', 'profile']; - -if (typeof window !== 'undefined' && import.meta.env.PROD) { - const persister = createSyncStoragePersister({ storage: window.sessionStorage }); - persistQueryClient({ - queryClient, - persister, - maxAge: 30 * 60 * 1000, // 30 min persistence window - dehydrateOptions: { - shouldDehydrateQuery: (query) => - PERSIST_WHITELIST.some((key) => String(query.queryKey[0]).startsWith(key)), - }, - }); -} -``` - ---- - -### Step 3 — Plumbing: Router & Root - ---- - -#### [MODIFY] [router.tsx](file:///home/sumit/Desktop/Roomies/roomies_frontend/src/router.tsx) - -- Pass `queryClient` into the router context so route `loader` functions can call `queryClient.ensureQueryData(...)`. -- Set `defaultPreloadStaleTime` to something sensible (e.g., `30_000` ms) so the router's intent-preload is actually useful. - -```ts -import { queryClient } from '#/lib/queryClient'; - -export function getRouter() { - const router = createTanStackRouter({ - routeTree, - context: { queryClient }, - scrollRestoration: true, - defaultPreload: 'intent', - defaultPreloadStaleTime: 30_000, - }); - return router; -} - -declare module '@tanstack/react-router' { - interface Register { router: ReturnType; } - interface RouterContext { queryClient: typeof queryClient; } -} -``` - ---- - -#### [MODIFY] [__root.tsx](file:///home/sumit/Desktop/Roomies/roomies_frontend/src/routes/__root.tsx) - -Wrap the entire app with `QueryClientProvider`. Add `ReactQueryDevtools` (dev only) — this integrates with the existing `TanStackDevtools` panel or can be added separately. - -```tsx -import { QueryClientProvider } from '@tanstack/react-query'; -import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; -import { queryClient } from '#/lib/queryClient'; - -function RootComponent() { - return ( - - - - - - - - - ); -} -``` - ---- - -### Step 4 — Component Conversions - ---- - -#### [MODIFY] [NotificationBell.tsx](file:///home/sumit/Desktop/Roomies/roomies_frontend/src/components/NotificationBell.tsx) - -Replace the manual `setInterval` with `useQuery` + `refetchInterval`. Key improvement: `refetchIntervalInBackground: false` stops server polling when the browser tab is not focused. - -**Before:** `setInterval(fetchUnreadCount, 60000)` — always running -**After:** `useQuery({ refetchInterval: 60_000, refetchIntervalInBackground: false })` — stops in background tabs - ---- - -#### [MODIFY] [dashboard.tsx](file:///home/sumit/Desktop/Roomies/roomies_frontend/src/routes/_auth/dashboard.tsx) - -Replace the large `Promise.all` inside `useEffect` with individual `useQuery` calls. Add a route `loader` that prefetches everything before the route renders. - -**Queries to convert:** -- `getStudentProfile(userId)` → `useQuery({ queryKey: queryKeys.studentProfile(userId), staleTime: STALE.PROFILE })` -- `getMyInterests('pending')` → `useQuery({ queryKey: queryKeys.interests('pending'), staleTime: STALE.TRANSACTIONAL })` -- `getMyInterests('accepted')` → same pattern -- `getMyConnections('confirmed')` → `useQuery({ queryKey: queryKeys.connections('confirmed'), staleTime: STALE.TRANSACTIONAL })` -- `getSavedListings()` → `useQuery({ queryKey: queryKeys.savedListings(), staleTime: STALE.FEED })` -- `getNotifications(false)` → `useQuery({ queryKey: queryKeys.notifications.all(false), staleTime: STALE.NOTIFICATION })` - -Same pattern for `PgOwnerDashboard`. - -**Route loader:** -```ts -loader: ({ context: { queryClient }, params }) => { - // fire-and-forget prefetch; route will still render if slow - queryClient.ensureQueryData({ queryKey: queryKeys.studentProfile(userId), queryFn: ... }); - // ...other queries -} -``` - -> [!NOTE] -> Since `userId` comes from `AuthContext` (not route params), the loader will prefetch what it can (amenities, notifications) and the rest will be prefetched optimistically. - ---- - -#### [MODIFY] [notifications.tsx](file:///home/sumit/Desktop/Roomies/roomies_frontend/src/routes/_auth/notifications.tsx) - -- `getNotifications()` → `useInfiniteQuery` (for Load More cursor support) -- `markAsRead([id])` → `useMutation` with `onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.unreadCount() })` -- `markAsRead()` (all) → same pattern - -**This means when a notification is marked read on the Notifications page, the bell count in the header updates immediately without an extra network call.** - ---- - -#### [MODIFY] [browse.tsx](file:///home/sumit/Desktop/Roomies/roomies_frontend/src/routes/_auth/browse.tsx) - -- `searchListings(filters)` → `useInfiniteQuery` keyed on `queryKeys.listings(filters)`. Each unique filter combination gets its own cache entry with 2-minute stale time. -- `getSavedListings()` → `useQuery({ queryKey: queryKeys.savedListings() })` — shared with dashboard, cached once -- `saveListing(id)` → `useMutation` with `onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.savedListings() })` -- `unsaveListing(id)` → same - ---- - -#### [MODIFY] [connections.tsx](file:///home/sumit/Desktop/Roomies/roomies_frontend/src/routes/_auth/connections.tsx) - -- `getMyConnections()` → `useQuery({ queryKey: queryKeys.connections(), staleTime: STALE.TRANSACTIONAL })` -- `confirmConnection(id)` → `useMutation` → `onSuccess: invalidateQueries(connections)` -- `getConnection(id)` (detail fetch) → `useQuery({ queryKey: queryKeys.connection(id) })` - ---- - -#### [MODIFY] [profile.tsx](file:///home/sumit/Desktop/Roomies/roomies_frontend/src/routes/_auth/profile.tsx) - -- `getStudentProfile(userId)` → `useQuery({ queryKey: queryKeys.studentProfile(userId), staleTime: STALE.PROFILE })` -- `getSessions()` → `useQuery({ queryKey: queryKeys.sessions(), staleTime: STALE.SESSIONS })` -- `updateStudentProfile(...)` → `useMutation` → `onSuccess: invalidateQueries(studentProfile)` -- `submitVerificationDocument(...)` → `useMutation` → `onSuccess: invalidateQueries(pgOwnerProfile)` - ---- - -#### [MODIFY] [sessions.tsx](file:///home/sumit/Desktop/Roomies/roomies_frontend/src/routes/_auth/sessions.tsx) - -- `getSessions()` → `useQuery({ queryKey: queryKeys.sessions(), staleTime: STALE.SESSIONS })` -- `revokeSession(sid)` → `useMutation` → `onSuccess: () => queryClient.setQueryData(queryKeys.sessions(), filter out sid)` (optimistic update, no refetch needed) - ---- - -### Step 5 — Listing Detail Page - -#### [MODIFY] [listing.$id.tsx](file:///home/sumit/Desktop/Roomies/roomies_frontend/src/routes/_auth/listing.$id.tsx) - -Add a route `loader` that prefetches the listing detail. This is the highest-value place for a loader since users hover listing cards before clicking them (intent preload fires here). - -```ts -loader: ({ context: { queryClient }, params }) => - queryClient.ensureQueryData({ - queryKey: queryKeys.listing(params.id), - queryFn: () => getListing(params.id), - staleTime: STALE.FEED, - }) -``` - ---- - -## Verification Plan - -### Automated Tests -- Existing vitest suite: `pnpm test` — ensure no regressions -- No new test files in scope for this PR - -### Manual Verification - -1. **Cache hit test**: Navigate to Dashboard → Notifications → back to Dashboard. Open browser DevTools Network tab. The second Dashboard visit should show zero new API calls (within `staleTime` window). -2. **NotificationBell**: Open two tabs. Switch away from tab. Confirm no network activity for 60 seconds on the background tab (previously it would still poll). -3. **Mark-read cascade**: Mark a notification as read on `/notifications`. Without navigating away, verify the bell count in the header decrements (proves `invalidateQueries` cross-component sync works). -4. **Save listing cascade**: Save a listing in Browse. Navigate to Dashboard. The "Saved Listings" count should immediately reflect the new save without a spinner. -5. **DevTools**: Open TanStack Query devtools (bottom-right panel) and verify queries are appearing with correct `staleTime` and `status`. -6. **Build check**: `pnpm build` — TypeScript must compile clean. - ---- - -## Execution Order (Safest Path) - -1. Install packages + create `queryKeys.ts` + `queryClient.ts` -2. Wire `QueryClientProvider` into `__root.tsx` -3. Wire `queryClient` context into `router.tsx` -4. Convert `NotificationBell.tsx` (smallest, highest impact) -5. Convert `dashboard.tsx` (most queries, highest traffic) -6. Convert `notifications.tsx` + `sessions.tsx` (straightforward) -7. Convert `browse.tsx` (infinite query, needs care) -8. Convert `connections.tsx` + `profile.tsx` -9. Add route loaders (`listing.$id.tsx`, `dashboard`) -10. Run `pnpm build` to verify TypeScript diff --git a/task.md b/task.md deleted file mode 100644 index e2beacb..0000000 --- a/task.md +++ /dev/null @@ -1,16 +0,0 @@ -# TanStack Query Caching Layer — Task Tracker - -- [x] Step 1: Install @tanstack/react-query + persist packages -- [/] Step 2: Create src/lib/queryKeys.ts -- [ ] Step 3: Create src/lib/queryClient.ts -- [ ] Step 4: Wire QueryClientProvider into __root.tsx -- [ ] Step 5: Wire queryClient context into router.tsx -- [ ] Step 6: Convert NotificationBell.tsx -- [ ] Step 7: Convert dashboard.tsx -- [ ] Step 8: Convert notifications.tsx -- [ ] Step 9: Convert sessions.tsx -- [ ] Step 10: Convert browse.tsx (useInfiniteQuery) -- [ ] Step 11: Convert connections.tsx -- [ ] Step 12: Convert profile.tsx -- [ ] Step 13: Add route loader for listing.$id.tsx -- [ ] Step 14: Run pnpm build to verify TypeScript From 191be5c56055fb457ca34ecdd18501191ced1c23 Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Sat, 16 May 2026 20:20:23 +0530 Subject: [PATCH 37/54] fix: reduce max connections in pg pool from 20 to 5 for better resource management --- src/db/client.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/db/client.js b/src/db/client.js index 1748397..8a2e222 100644 --- a/src/db/client.js +++ b/src/db/client.js @@ -6,11 +6,8 @@ const { Pool } = pg; export const pool = new Pool({ connectionString: config.DATABASE_URL, - - max: 20, - + max: 5, idleTimeoutMillis: 30_000, - connectionTimeoutMillis: 5_000, }); @@ -22,10 +19,10 @@ 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); } }); + export const query = (text, params) => pool.query(text, params); From 62792009d8c51774e74a614ffcb439c502486513 Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Sat, 16 May 2026 20:20:34 +0530 Subject: [PATCH 38/54] feat: add saved search routes to the root router --- src/routes/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/index.js b/src/routes/index.js index 17e8d4c..314b469 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"; @@ -14,6 +12,7 @@ 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 { config } from "../config/env.js"; @@ -32,6 +31,7 @@ rootRouter.use("/ratings", ratingRouter); rootRouter.use("/preferences", preferencesRouter); rootRouter.use("/amenities", amenitiesRouter); rootRouter.use("/rent-index", rentIndexRouter); +rootRouter.use("/saved-searches", savedSearchRouter); if (config.NODE_ENV !== "production") { rootRouter.use("/test-utils", testUtilsRouter); From a36b115ad411bb494325e771ea05604329c46452 Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Sat, 16 May 2026 20:20:39 +0530 Subject: [PATCH 39/54] feat: enhance searchListings with improved geolocation handling and rent deviation calculation --- src/services/listing.service.js | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/services/listing.service.js b/src/services/listing.service.js index 481ff48..0338ca0 100644 --- a/src/services/listing.service.js +++ b/src/services/listing.service.js @@ -378,13 +378,12 @@ export const searchListings = async (userId, filters) => { let p = 1; if (lat !== undefined && lng !== undefined) { - clauses.push( - `ST_DWithin( - COALESCE(l.location, p.location)::geography, - ST_SetSRID(ST_MakePoint($${p + 1}, $${p}), 4326)::geography, - $${p + 2} - )`, - ); + const pointExpr = `ST_SetSRID(ST_MakePoint($${p + 1}, $${p}), 4326)::geography`; + clauses.push(`( + (l.location IS NOT NULL AND ST_DWithin(l.location::geography, ${pointExpr}, $${p + 2})) + OR + (l.location IS NULL AND p.location IS NOT NULL AND ST_DWithin(p.location::geography, ${pointExpr}, $${p + 2})) + )`); params.push(lat, lng, radius); p += 3; } @@ -464,7 +463,6 @@ export const searchListings = async (userId, filters) => { params.push(limit + 1); const limitParam = p; - // ── CHANGED: added rent_index LEFT JOINs and ri_p50 / ri_resolution columns ── const { rows } = await pool.query( `SELECT l.listing_id, @@ -481,6 +479,8 @@ export const searchListings = async (userId, filters) => { l.available_from, l.status, l.created_at, + COALESCE(l.latitude, p.latitude) AS latitude, + COALESCE(l.longitude, p.longitude) AS longitude, COALESCE(p.property_name, NULL) AS property_name, COALESCE(p.average_rating, u.average_rating) AS average_rating, ( @@ -492,7 +492,6 @@ export const searchListings = async (userId, filters) => { AND ph.photo_url NOT LIKE 'processing:%' LIMIT 1 ) AS cover_photo_url, - -- Rent index (locality-level preferred, city-wide fallback) COALESCE(ri_loc.p50, ri_city.p50) AS ri_p50, CASE WHEN ri_loc.rent_index_id IS NOT NULL THEN 'locality' @@ -515,7 +514,6 @@ export const searchListings = async (userId, filters) => { LIMIT $${limitParam}`, params, ); - // ── END CHANGED section ─────────────────────────────────────────────────── const hasNextPage = rows.length > limit; const items = hasNextPage ? rows.slice(0, limit) : rows; @@ -544,14 +542,12 @@ export const searchListings = async (userId, filters) => { GROUP BY listing_id`, [listingIds], ); - listingPreferenceCounts = new Map( listingPreferenceRows.map((row) => [row.listing_id, Number(row.preference_count)]), ); } } - // ── CHANGED: added rentDeviation to each enriched item ─────────────────── const enrichedItems = items.map((row) => ({ ...row, rentPerMonth: row.rent_per_month / 100, @@ -561,12 +557,10 @@ export const searchListings = async (userId, filters) => { compatibilityScore: userId !== null ? (scoreMap[row.listing_id] ?? 0) : 0, compatibilityAvailable: userId !== null && userHasPreferences && (listingPreferenceCounts.get(row.listing_id) ?? 0) > 0, - // New: rent deviation as a percentage relative to local median rentDeviation: rentDeviationPct(row.rent_per_month, row.ri_p50), ri_p50: undefined, ri_resolution: undefined, })); - // ── END CHANGED section ─────────────────────────────────────────────────── if (sortBy === "compatibility") { enrichedItems.sort((a, b) => b.compatibilityScore - a.compatibilityScore); From 36d02916867dad98368d2ff5c7ebb91c86dd40f1 Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Sun, 17 May 2026 15:25:44 +0530 Subject: [PATCH 40/54] feat: add verification and report routes with admin access control --- src/middleware/requireAdmin.js | 19 +++++++++++++++++++ src/routes/index.js | 4 ++++ src/routes/report.js | 21 +++++++++++++++++++++ src/routes/verification.js | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+) create mode 100644 src/middleware/requireAdmin.js create mode 100644 src/routes/report.js create mode 100644 src/routes/verification.js diff --git a/src/middleware/requireAdmin.js b/src/middleware/requireAdmin.js new file mode 100644 index 0000000..2d631fe --- /dev/null +++ b/src/middleware/requireAdmin.js @@ -0,0 +1,19 @@ +// 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) { + return next(new AppError("authenticate middleware must run before requireAdmin", 500)); + } + 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/routes/index.js b/src/routes/index.js index 314b469..e446e33 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -14,6 +14,8 @@ 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"; export const rootRouter = Router(); @@ -32,6 +34,8 @@ 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); diff --git a/src/routes/report.js b/src/routes/report.js new file mode 100644 index 0000000..0873e87 --- /dev/null +++ b/src/routes/report.js @@ -0,0 +1,21 @@ +// 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 * 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, 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, rc.resolveReport); diff --git a/src/routes/verification.js b/src/routes/verification.js new file mode 100644 index 0000000..40b9376 --- /dev/null +++ b/src/routes/verification.js @@ -0,0 +1,33 @@ +// src/routes/verification.js +// Verification request routes: +// POST /verification/:userId/submit — PG owner submits a document +// GET /verification/queue — Admin: paginated pending queue +// PATCH /verification/:requestId/approve — Admin: approve a request +// PATCH /verification/:requestId/reject — Admin: reject a request + +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(); + +// ── PG owner routes ──────────────────────────────────────────────────────────── +// A PG owner submits verification documents for their own profile +verificationRouter.post( + "/:userId/submit", + authenticate, + authorize("pg_owner"), + vc.submitDocument, +); + +// ── Admin routes ─────────────────────────────────────────────────────────────── +// GET /verification/queue — paginated list of pending verification requests +verificationRouter.get("/queue", authenticate, ...requireAdmin, vc.getVerificationQueue); + +// PATCH /verification/:requestId/approve +verificationRouter.patch("/:requestId/approve", authenticate, ...requireAdmin, vc.approveRequest); + +// PATCH /verification/:requestId/reject +verificationRouter.patch("/:requestId/reject", authenticate, ...requireAdmin, vc.rejectRequest); From e4e207d7b1aa7c1716bbffb0256526ebd5df500c Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Sun, 17 May 2026 15:25:45 +0530 Subject: [PATCH 41/54] Implemented Admin Routes --- location-geospatial-audit.md | 828 ++++++++++++++++++++++++++++++++ unimplemented-features-audit.md | 261 ++++++++++ 2 files changed, 1089 insertions(+) create mode 100644 location-geospatial-audit.md create mode 100644 unimplemented-features-audit.md diff --git a/location-geospatial-audit.md b/location-geospatial-audit.md new file mode 100644 index 0000000..a1ac93d --- /dev/null +++ b/location-geospatial-audit.md @@ -0,0 +1,828 @@ +# Roomies — Location / Geospatial System Audit + +_Continuation of `unimplemented-features-audit.md`_ + +--- + +## Part A — How Location Is Implemented in the Backend + +### A1. Database Schema — Two Column Approach + +Every row in `listings` and `properties` carries **three** location representations simultaneously: + +```sql +-- From migration 001_initial_schema.sql +latitude NUMERIC(10, 7), -- raw decimal degrees, human-readable +longitude NUMERIC(10, 7), -- raw decimal degrees +location GEOMETRY(POINT, 4326) -- PostGIS geometry, SRID 4326 (WGS84) +``` + +The `location` geometry column is kept automatically in sync with `latitude`/`longitude` via a +**`BEFORE INSERT OR UPDATE` trigger**: + +```sql +CREATE OR REPLACE FUNCTION sync_location_geometry() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.latitude IS NOT NULL AND NEW.longitude IS NOT NULL THEN + NEW.location = ST_SetSRID(ST_MakePoint(NEW.longitude, NEW.latitude), 4326); + ELSE + NEW.location = NULL; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +``` + +**Critical detail:** `ST_MakePoint` takes `(longitude, latitude)` — not `(lat, lng)`. This matches the GeoJSON/WGS84 +convention where X = longitude, Y = latitude. The trigger fires on updates to `latitude` or `longitude` columns only: + +```sql +CREATE TRIGGER trg_listings_sync_location +BEFORE INSERT OR UPDATE OF latitude, longitude ON listings +FOR EACH ROW EXECUTE FUNCTION sync_location_geometry(); +``` + +GiST spatial indexes are created on the `location` column: + +```sql +CREATE INDEX IF NOT EXISTS idx_listings_location ON listings USING GIST (location) +WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_properties_location ON properties USING GIST (location) +WHERE deleted_at IS NULL; +``` + +Both are **partial indexes** (`WHERE deleted_at IS NULL`) — this is correct and efficient since soft-deleted rows are +excluded from all spatial queries. + +--- + +### A2. Proximity Search — `searchListings` + +In `listing.service.js`, when `lat` and `lng` are provided: + +```js +if (lat !== undefined && lng !== undefined) { + const pointExpr = `ST_SetSRID(ST_MakePoint($${p + 1}, $${p}), 4326)::geography`; + clauses.push(`( + (l.location IS NOT NULL AND ST_DWithin(l.location::geography, ${pointExpr}, $${p + 2})) + OR + (l.location IS NULL AND p.location IS NOT NULL AND ST_DWithin(p.location::geography, ${pointExpr}, $${p + 2})) + )`); + params.push(lat, lng, radius); + p += 3; +} +``` + +**What happens here, step by step:** + +1. `ST_MakePoint($lng, $lat)` — creates a point. Note parameter order: `$${p+1}` = `lng`, `$${p}` = `lat`. The + `params.push(lat, lng, radius)` pushes them as `[lat, lng, radius]` so `$p` = `lat`, `$p+1` = `lng`. This means + `ST_MakePoint($p+1, $p)` = `ST_MakePoint(lng, lat)`. **This is correct.** + +2. `ST_SetSRID(..., 4326)` — assigns WGS84 coordinate system. + +3. `::geography` cast on **both** sides — this switches from planar geometry to spherical geography. Distance is now in + **metres**, not degrees. This is the correct approach for real-world distance queries in India where distortion from + flat projections is non-trivial at scale. + +4. Listings without their own coordinates (pg_room / hostel_bed) fall back to the property's geometry via the separate + `OR` branch. + +5. The radius parameter accepts metres, default 5,000 (5 km), max 50,000 (50 km) — validated in `searchListingsSchema`. + +**Index usage verification:** + +The `GIST` index on `location GEOMETRY(POINT, 4326)` **will be used** for the `::geography` cast in `ST_DWithin` because +PostGIS's geography index is backed by the same GiST structure and the planner knows to use it. This is confirmed by the +PostGIS documentation: `ST_DWithin(geography, geography, distance_meters)` uses the geography index. + +--- + +### A3. Rent Index — Location-Aware Aggregation + +`rent_observations` stores `city`, `locality` (normalised to `LOWER(TRIM(...))`) and `room_type`. The `rent_index` +materialised table is refreshed nightly by a cron job with a 180-day rolling window. There is **no PostGIS geometry in +rent_index** — it's purely text-based city/locality matching. + +The listing search joins rent_index by: + +```sql +LEFT JOIN rent_index ri_loc + ON ri_loc.city = l.city + AND ri_loc.locality = NULLIF(LOWER(TRIM(COALESCE(l.locality, ''))), '') + AND ri_loc.room_type = l.room_type +``` + +This is case-sensitive at the `=` level but both sides are already lowercased at insert time via the +`capture_rent_observation` trigger. Works correctly. + +--- + +### A4. Property Location Cascade + +When a property's address is updated (`updateProperty`), the backend **cascades location fields to all linked +listings**: + +```js +const changedLocationColumns = setClauses + .map((clause) => clause.split(" = ")[0].trim()) + .filter((col) => col in LOCATION_CASCADE_MAP); +``` + +If any of `city`, `address_line`, `locality`, `landmark`, `pincode`, `latitude`, `longitude` change on the property, the +same values are pushed to all `pg_room` and `hostel_bed` listings linked to that property. The geometry re-sync is +handled by the trigger on the listings table when `latitude`/`longitude` are updated. + +--- + +### A5. Roommate Feed — City Text Filter (No Geometry) + +The roommate feed in `roommate.service.js` uses a **text LIKE filter** on listings, not PostGIS: + +```sql +EXISTS ( + SELECT 1 FROM listings l + WHERE l.posted_by = sp.user_id + AND l.deleted_at IS NULL + AND l.status = 'active' + AND LOWER(l.city) LIKE LOWER($p) ESCAPE '\' +) +``` + +There is no geometry/geography involved in roommate matching — it is purely city-name substring matching. This means a +search for "Pune" also matches "New Pune" or "Pune City". + +--- + +### A6. Coordinate Validation + +Input coordinates are validated via Zod in `listing.validators.js` and `property.validators.js`: + +```js +const latitudeSchema = coordinateSchema(-90, 90, "Latitude"); +const longitudeSchema = coordinateSchema(-180, 180, "Longitude"); +``` + +Cross-field refinement ensures both or neither are provided: + +```js +.refine(data => !(data.latitude !== undefined && data.longitude === undefined), { + error: 'longitude is required when latitude is provided', +}) +``` + +For pg_room / hostel_bed listings, coordinates are **explicitly blocked** at the API layer — the listing validator +rejects them entirely and relies on property coordinates instead: + +```js +.refine(data => + data.listingType === 'student_room' || + (data.latitude === undefined && data.longitude === undefined), + { error: 'Coordinates are not accepted for pg_room or hostel_bed listings' } +) +``` + +--- + +## Part B — Verification Against PostGIS Best Practices + +| Practice | Backend Status | Assessment | +| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Use `geography` type (not raw `geometry`) for distance in metres | ✅ `::geography` cast in `ST_DWithin` | Correct. `GEOMETRY(POINT, 4326)` stored, cast to `::geography` at query time. Saves storage vs native geography column while still getting metre-accurate distances. | +| GiST index on spatial column | ✅ `USING GIST (location)` | Correct. Partial index (`WHERE deleted_at IS NULL`) is optimal. | +| `ST_MakePoint(lng, lat)` order | ✅ `ST_MakePoint($lng, $lat)` | Correct. X=longitude, Y=latitude per WGS84/GeoJSON convention. | +| Assign SRID before spatial operations | ✅ `ST_SetSRID(..., 4326)` | Correct. | +| Trigger for geometry sync | ✅ `sync_location_geometry()` fires `BEFORE INSERT OR UPDATE OF latitude, longitude` | Correct and efficient (only fires when coord columns change). | +| Radius in metres for geography `ST_DWithin` | ✅ `radius` param validated 100–50,000 m | Correct. With `::geography`, the third arg is metres. | +| Avoid `ST_Distance` in WHERE clause (not index-safe) | ✅ Not used in WHERE | Correct. `ST_DWithin` uses the index; `ST_Distance` in WHERE would cause full scan. | + +### B1. Missing `ORDER BY ST_Distance` for "Nearest First" Results + +The current search sorts by `l.created_at DESC` always. When a user searches "within 5 km", results are sorted by +recency, not proximity. A listing 200 m away posted two weeks ago appears below one 4.9 km away posted today. + +The `ST_Distance(..., $point)` expression could be added as a secondary sort but would require a consistent distance +expression for both listing and property locations. + +This would also break cursor-based pagination (which currently uses `cursorTime` + `cursorId`) — a distance-based cursor +requires a different pagination strategy (e.g., `cursor_distance` + `cursor_id`). + +### B2. Neon Serverless + PostGIS + +Neon's serverless PostgreSQL includes PostGIS. The `CREATE EXTENSION IF NOT EXISTS postgis` in migration 001 works +correctly on Neon. However, Neon's auto-suspend feature (free tier: suspends after 5 minutes of inactivity) means the +first PostGIS query after a cold start may be slow while the extension re-initialises its internal state. This is not a +bug but worth noting for UX — the first proximity search after inactivity may take 2–5 seconds. + +--- + +## Part C — Frontend: Current State (Nothing Implemented) + +The location feature is **completely absent from the frontend**. Here is the current gap map: + +| Feature | Backend | Frontend | +| ------------------------------------------------ | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| Proximity search (`lat`, `lng`, `radius` params) | ✅ Fully implemented | ❌ No UI. The `searchListingsSchema` on the frontend has `lat?`, `lng?`, `radius?` in `ListingSearchParams` but `browse.tsx` never passes them. | +| Coordinate input for `student_room` listings | ✅ Accepted and stored | ❌ The listing creation form in `listings.tsx` has no lat/lng fields | +| "Near me" button | ❌ Does not exist | ❌ Does not exist | +| Map display | ❌ No map rendering | ❌ No map rendering | +| Distance shown on card | ❌ Not returned in search response | ❌ Not rendered | +| City-text search | ✅ `LOWER(city) LIKE $city%` | ✅ Wired to city input in browse.tsx | +| Rent index context | ✅ `rentDeviation` in response | ❌ Never rendered (covered in main audit) | + +--- + +## Part D — Frontend System Design: Full Location Implementation + +Below is a complete, production-ready design for adding location features to the Roomies frontend. All components are +designed for SSR compatibility with TanStack Start, caching with TanStack Query, and graceful degradation for Android +clients. + +--- + +### D1. Location State Hook + +Create `src/hooks/useUserLocation.ts`: + +```ts +// src/hooks/useUserLocation.ts +import { useState, useCallback, useRef } from "react"; + +type LocationState = + | { status: "idle" } + | { status: "requesting" } + | { status: "granted"; lat: number; lng: number; accuracy: number } + | { status: "denied"; reason: "permission" | "unavailable" | "timeout" } + | { status: "unsupported" }; + +const GEOLOCATION_OPTIONS: PositionOptions = { + enableHighAccuracy: false, // false = cell/wifi, faster & less battery drain + timeout: 8000, // 8 s — generous for India mobile networks + maximumAge: 5 * 60 * 1000, // reuse cached position up to 5 min old +}; + +export function useUserLocation() { + const [state, setState] = useState({ status: "idle" }); + const hasRequested = useRef(false); + + const requestLocation = useCallback(async (): Promise => { + // Guard: only one inflight request at a time + if (state.status === "requesting") return state; + + if (typeof navigator === "undefined" || !navigator.geolocation) { + const next: LocationState = { status: "unsupported" }; + setState(next); + return next; + } + + // Check permission status first — avoids surprising the user on second click + if ("permissions" in navigator) { + try { + const perm = await navigator.permissions.query({ name: "geolocation" }); + if (perm.state === "denied") { + const next: LocationState = { status: "denied", reason: "permission" }; + setState(next); + return next; + } + } catch { + // Permissions API not available in all browsers — proceed anyway + } + } + + setState({ status: "requesting" }); + hasRequested.current = true; + + return new Promise((resolve) => { + navigator.geolocation.getCurrentPosition( + (position) => { + const next: LocationState = { + status: "granted", + lat: position.coords.latitude, + lng: position.coords.longitude, + accuracy: position.coords.accuracy, // metres + }; + setState(next); + resolve(next); + }, + (error) => { + const reason = + error.code === GeolocationPositionError.PERMISSION_DENIED ? "permission" + : error.code === GeolocationPositionError.TIMEOUT ? "timeout" + : "unavailable"; + const next: LocationState = { status: "denied", reason }; + setState(next); + resolve(next); + }, + GEOLOCATION_OPTIONS, + ); + }); + }, [state]); + + const clear = useCallback(() => setState({ status: "idle" }), []); + + return { locationState: state, requestLocation, clear }; +} +``` + +**Why `enableHighAccuracy: false`?** On mobile networks in India, GPS lock can take 15–30 seconds. Cell/WiFi +triangulation is accurate to ~300 m — more than sufficient for a 5 km radius search. This avoids battery drain and the +long wait. + +**Why `maximumAge: 5 * 60 * 1000`?** The browser caches the position for 5 minutes. If the user runs two searches, the +second request resolves instantly without a new GPS poll. + +--- + +### D2. Location Persistence (Cross-Session) + +Cache the last known location in `sessionStorage` so it survives a page refresh within the same tab: + +```ts +// src/lib/locationStorage.ts + +const KEY = "roomies_last_location"; + +export interface StoredLocation { + lat: number; + lng: number; + accuracy: number; + storedAt: number; // epoch ms +} + +const MAX_AGE_MS = 15 * 60 * 1000; // 15 minutes + +export const locationStorage = { + save(lat: number, lng: number, accuracy: number) { + try { + const data: StoredLocation = { lat, lng, accuracy, storedAt: Date.now() }; + sessionStorage.setItem(KEY, JSON.stringify(data)); + } catch { + /* sessionStorage blocked in private mode */ + } + }, + + load(): StoredLocation | null { + try { + const raw = sessionStorage.getItem(KEY); + if (!raw) return null; + const data: StoredLocation = JSON.parse(raw); + if (Date.now() - data.storedAt > MAX_AGE_MS) { + sessionStorage.removeItem(KEY); + return null; + } + return data; + } catch { + return null; + } + }, + + clear() { + try { + sessionStorage.removeItem(KEY); + } catch {} + }, +}; +``` + +Update `useUserLocation` to initialise from storage and save on success: + +```ts +// Add to useUserLocation.ts +import { locationStorage } from '@/lib/locationStorage'; + +// In the hook body, initialise state from storage: +const [state, setState] = useState(() => { + const stored = locationStorage.load(); + if (stored) { + return { status: 'granted', lat: stored.lat, lng: stored.lng, accuracy: stored.accuracy }; + } + return { status: 'idle' }; +}); + +// In the success callback: +const next: LocationState = { status: 'granted', ... }; +locationStorage.save(position.coords.latitude, position.coords.longitude, position.coords.accuracy); +setState(next); +``` + +--- + +### D3. "Near Me" Button Component + +Create `src/components/NearMeButton.tsx`: + +```tsx +// src/components/NearMeButton.tsx +import { MapPin, Loader2, MapPinOff } from "lucide-react"; +import { Button } from "#/components/ui/button"; +import { useUserLocation } from "#/hooks/useUserLocation"; +import { toast } from "#/components/ui/sonner"; + +interface NearMeButtonProps { + /** Called with coordinates when location is granted */ + onLocation: (lat: number, lng: number) => void; + /** Called when user clears location filter */ + onClear: () => void; + isActive: boolean; + radiusKm?: number; +} + +export function NearMeButton({ onLocation, onClear, isActive, radiusKm = 5 }: NearMeButtonProps) { + const { locationState, requestLocation, clear } = useUserLocation(); + + const handleClick = async () => { + if (isActive) { + clear(); + onClear(); + return; + } + + const result = await requestLocation(); + + if (result.status === "granted") { + onLocation(result.lat, result.lng); + } else if (result.status === "denied" && result.reason === "permission") { + toast.error("Location access denied", { + description: "Enable location in your browser settings, or enter a city name manually.", + }); + } else if (result.status === "denied" && result.reason === "timeout") { + toast.error("Location request timed out", { + description: "Try again or search by city name instead.", + }); + } else if (result.status === "unsupported") { + toast.error("Location not available", { + description: "Your browser does not support location access. Search by city name instead.", + }); + } + }; + + const isLoading = locationState.status === "requesting"; + + return ( + + ); +} +``` + +--- + +### D4. Browse Page Integration (`browse.tsx`) + +The existing filter state needs three new fields: + +```ts +// Add to ListingFilters or use a local extension: +interface GeoFilter { + lat?: number; + lng?: number; + radius: number; // metres, default 5000 +} +``` + +In `browse.tsx`, add `geoFilter` state and wire it to the search: + +```tsx +// --- New state --- +const [geoFilter, setGeoFilter] = useState({ radius: 5000 }); + +// --- Pass to searchListings --- +queryFn: async ({ pageParam }) => { + return searchListings({ + ...activeFiltersKey, + lat: geoFilter.lat, + lng: geoFilter.lng, + radius: geoFilter.lat !== undefined ? geoFilter.radius : undefined, + limit: 20, + cursorTime: pageParam?.cursorTime, + cursorId: pageParam?.cursorId, + }); +}, + +// --- Query key must include geoFilter to bust cache on location change --- +queryKey: queryKeys.listings({ ...activeFiltersKey, geo: geoFilter }), +``` + +Add the `NearMeButton` and a radius slider to the search bar row: + +```tsx +
+ {/* City search */} +
+ + setTempFilters((prev) => ({ ...prev, city: e.target.value }))} + onKeyDown={(e) => e.key === "Enter" && handleApplyFilters()} + className="pl-10" + /> +
+ + {/* Near Me — clears city filter when active (location and city are mutually exclusive) */} + { + setGeoFilter({ lat, lng, radius: 5000 }); + setFilters((prev) => ({ ...prev, city: "" })); // Clear city when using geo + setTempFilters((prev) => ({ ...prev, city: "" })); + }} + onClear={() => setGeoFilter({ radius: 5000 })} + /> + + {/* Radius selector (shown only when geo active) */} + {geoFilter.lat !== undefined && ( + + )} + + + +
+``` + +**City vs. Geo mutual exclusivity:** The backend accepts both `city` and `lat/lng` simultaneously, but the query would +be oddly restrictive. The UX convention for discovery apps is to use one or the other — clearing city when geo is +activated, and clearing geo when city is typed. + +--- + +### D5. Listing Card — Distance Display + +The backend does not currently return the computed distance in the search response. To show "2.3 km away" on cards, +either: + +**Option A (recommended for Tier 0): Client-side calculation** + +```ts +// src/lib/distance.ts +export function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number { + const R = 6371; // Earth radius km + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLng = ((lng2 - lng1) * Math.PI) / 180; + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLng / 2) ** 2; + return R * 2 * Math.asin(Math.sqrt(a)); +} + +export function formatDistance(km: number): string { + if (km < 1) return `${Math.round(km * 1000)} m away`; + return `${km.toFixed(1)} km away`; +} +``` + +In the listing card, when `geoFilter.lat` is defined and `listing.latitude`/`listing.longitude` are present: + +```tsx +{ + geoFilter.lat !== undefined && listing.latitude && listing.longitude && ( +
+ + {formatDistance(haversineKm(geoFilter.lat!, geoFilter.lng!, listing.latitude, listing.longitude))} +
+ ); +} +``` + +**Option B (future): Add `ST_Distance` to backend response** + +```sql +-- Add to searchListings SELECT: +ROUND( + ST_Distance( + COALESCE(l.location, p.location)::geography, + ST_SetSRID(ST_MakePoint($lng, $lat), 4326)::geography + ) +)::int AS distance_metres +``` + +This requires adding `distanceMetres` to the response type and only populates when a proximity search is active. Option +A avoids a backend change and works for all devices including Android. + +--- + +### D6. Student Room Listing Creation — Coordinate Input + +The `ListingForm` in `listings.tsx` needs lat/lng fields for `student_room` type listings. The cleanest UX is an +address-lookup field with a "Use My Location" button: + +```tsx +{ + formData.listingType === "student_room" && ( +
+ +

+ Adding coordinates lets students find you in proximity searches +

+ +
+ + onChange({ + ...formData, + latitude: e.target.value ? parseFloat(e.target.value) : undefined, + }) + } + /> + + onChange({ + ...formData, + longitude: e.target.value ? parseFloat(e.target.value) : undefined, + }) + } + /> +
+ + onChange({ ...formData, latitude: lat, longitude: lng })} + onClear={() => onChange({ ...formData, latitude: undefined, longitude: undefined })} + radiusKm={0} // not a search, just setting point + /> + + {formData.latitude && formData.longitude && ( +

+ 📍 {formData.latitude.toFixed(4)}, {formData.longitude.toFixed(4)} +

+ )} +
+ ); +} +``` + +--- + +### D7. Listing Detail Page — Map Embed + +Show a static map on the listing detail page. Since the app currently has no map library dependency, the cheapest +approach is an `