From 947f9c626bde0605f6a4acf8ea7e497aee5f4514 Mon Sep 17 00:00:00 2001 From: Sumit1642 Date: Sun, 26 Apr 2026 22:34:37 +0530 Subject: [PATCH 1/4] feat: add rent index controller, service, and routes - Implemented `getRentIndex` controller to fetch rent index data based on city, locality, and room type. - Created `rentIndex.service.js` for querying the rent index table. - Added routes for rent index in `rentIndex.js` and integrated it into the main router. - Developed a cron job to refresh rent index data nightly based on recent rent observations. - Introduced roommate controller and service for managing roommate profiles, blocking/unblocking users, and fetching roommate feed. - Added validation schemas for rent index and roommate operations. - Enhanced listing service to include rent index data in listing responses. --- feat.md | 20 + migrations/005: Roommate matching support.sql | 34 ++ migrations/006: Proper rent index.sql | 101 ++++ roommate_matching_system_design.html | 491 ++++++++++++++++++ src/controllers/rentIndex.controller.js | 13 + src/controllers/roommate.controller.js | 39 ++ src/cron/rentIndexRefresh.js | 129 +++++ src/db/utils/roommateCompatibility.js | 80 +++ src/routes/index.js | 4 +- src/routes/rentIndex.js | 11 + src/routes/roommate.js | 58 +++ src/routes/student.js | 15 + src/services/listing.service.js | 77 ++- src/services/rentIndex.service.js | 61 +++ src/services/roommate.service.js | 267 ++++++++++ src/validators/rentIndex.validators.js | 0 src/validators/roommate.validators.js | 41 ++ 17 files changed, 1438 insertions(+), 3 deletions(-) create mode 100644 feat.md create mode 100644 migrations/005: Roommate matching support.sql create mode 100644 migrations/006: Proper rent index.sql create mode 100644 roommate_matching_system_design.html create mode 100644 src/controllers/rentIndex.controller.js create mode 100644 src/controllers/roommate.controller.js create mode 100644 src/cron/rentIndexRefresh.js create mode 100644 src/db/utils/roommateCompatibility.js create mode 100644 src/routes/rentIndex.js create mode 100644 src/routes/roommate.js create mode 100644 src/services/rentIndex.service.js create mode 100644 src/services/roommate.service.js create mode 100644 src/validators/rentIndex.validators.js create mode 100644 src/validators/roommate.validators.js diff --git a/feat.md b/feat.md new file mode 100644 index 0000000..b65b9ab --- /dev/null +++ b/feat.md @@ -0,0 +1,20 @@ +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/migrations/005: Roommate matching support.sql b/migrations/005: Roommate matching support.sql new file mode 100644 index 0000000..fa2fd80 --- /dev/null +++ b/migrations/005: Roommate matching support.sql @@ -0,0 +1,34 @@ +-- Active: 1777192065763@@127.0.0.1@5432@roomies_db +-- Migration 005: Roommate matching support +-- +-- Adds opt-in roommate-seeking flag and bio to student_profiles. +-- Creates roommate_blocks so students can hide specific users from their feed. + +ALTER TABLE student_profiles +ADD COLUMN IF NOT EXISTS looking_for_roommate BOOLEAN NOT NULL DEFAULT FALSE, +ADD COLUMN IF NOT EXISTS roommate_bio TEXT, +ADD COLUMN IF NOT EXISTS looking_updated_at TIMESTAMPTZ; + +-- Sparse index — only indexes rows that are actively seeking. +-- The roommate feed query filters on this exact condition so the index is hit on every feed request. +CREATE INDEX IF NOT EXISTS idx_student_roommate_lookup ON student_profiles ( + looking_updated_at DESC, + user_id +) +WHERE + looking_for_roommate = TRUE + AND deleted_at IS NULL; + +-- Block table — bidirectional blocking is handled at the service layer +-- by checking both (blocker=caller, blocked=candidate) and vice-versa. +CREATE TABLE IF NOT EXISTS roommate_blocks ( + blocker_id UUID NOT NULL REFERENCES users (user_id) ON DELETE RESTRICT, + blocked_id UUID NOT NULL REFERENCES users (user_id) ON DELETE RESTRICT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (blocker_id, blocked_id), + CONSTRAINT chk_no_self_block CHECK (blocker_id <> blocked_id) +); + +CREATE INDEX IF NOT EXISTS idx_roommate_blocks_blocker ON roommate_blocks (blocker_id); + +CREATE INDEX IF NOT EXISTS idx_roommate_blocks_blocked ON roommate_blocks (blocked_id); \ No newline at end of file diff --git a/migrations/006: Proper rent index.sql b/migrations/006: Proper rent index.sql new file mode 100644 index 0000000..9ba2732 --- /dev/null +++ b/migrations/006: Proper rent index.sql @@ -0,0 +1,101 @@ +-- Active: 1777192065763@@127.0.0.1@5432@roomies_db +-- Migration 006: Proper rent index +-- +-- rent_observations: one row per active listing event (created / renewed). +-- Written by a DB trigger on listings — no application code path can forget it. +-- +-- rent_index: materialised p25/p50/p75 per (city, locality, room_type). +-- Refreshed nightly by cron/rentIndexRefresh.js. +-- Listings JOIN this table to expose rentDeviation in API responses. + +CREATE TABLE IF NOT EXISTS rent_observations ( + observation_id UUID PRIMARY KEY DEFAULT gen_random_uuid (), + listing_id UUID NOT NULL REFERENCES listings (listing_id) ON DELETE CASCADE, + city VARCHAR(100) NOT NULL, + locality VARCHAR(100), -- normalised to LOWER(TRIM(...)), NULL for city-wide + room_type room_type_enum NOT NULL, + rent_per_month INTEGER NOT NULL, -- paise, same unit as listings.rent_per_month + observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + -- 'listing_created' | 'listing_renewed' — set by the trigger + source VARCHAR(30) NOT NULL DEFAULT 'listing_created', + CONSTRAINT chk_positive_rent CHECK (rent_per_month > 0) +); + +-- Index optimised for the cron aggregation query: GROUP BY city, locality, room_type +-- filtered to observations within the rolling 180-day window. +CREATE INDEX IF NOT EXISTS idx_rent_obs_aggregation ON rent_observations ( + city, + locality, + room_type, + observed_at DESC +); + +-- Fast lookup for cascading hard-deletes (cleanup cron). +CREATE INDEX IF NOT EXISTS idx_rent_obs_listing_id ON rent_observations (listing_id); + +-- Materialised rent index — upserted by cron, never written from app code. +-- locality IS NULL means the row is the city-wide fallback used when no +-- locality-specific data meets the minimum sample threshold. +CREATE TABLE IF NOT EXISTS rent_index ( + rent_index_id UUID PRIMARY KEY DEFAULT gen_random_uuid (), + city VARCHAR(100) NOT NULL, + locality VARCHAR(100), -- NULL = city-wide fallback + room_type room_type_enum NOT NULL, + p25 INTEGER NOT NULL, -- 25th percentile, paise + p50 INTEGER NOT NULL, -- median + p75 INTEGER NOT NULL, -- 75th percentile + sample_count INTEGER NOT NULL, + computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_rent_index UNIQUE (city, locality, room_type), + CONSTRAINT chk_rent_index_order CHECK ( + p25 <= p50 + AND p50 <= p75 + ) +); + +CREATE INDEX IF NOT EXISTS idx_rent_index_lookup ON rent_index (city, locality, room_type); + +-- Trigger function: fires after INSERT on listings (new listing goes active) +-- and after UPDATE when status flips to 'active' (listing renewed / reactivated). +-- Runs inside the same transaction as the listing write — observation is never lost. +CREATE OR REPLACE FUNCTION capture_rent_observation() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' AND NEW.status = 'active' AND NEW.deleted_at IS NULL THEN + INSERT INTO rent_observations + (listing_id, city, locality, room_type, rent_per_month, source) + VALUES ( + NEW.listing_id, + NEW.city, + NULLIF(LOWER(TRIM(COALESCE(NEW.locality, ''))), ''), + NEW.room_type, + NEW.rent_per_month, + 'listing_created' + ); + + ELSIF TG_OP = 'UPDATE' + AND NEW.status = 'active' + AND 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, + NULLIF(LOWER(TRIM(COALESCE(NEW.locality, ''))), ''), + NEW.room_type, + NEW.rent_per_month, + 'listing_renewed' + ); + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_capture_rent_observation ON listings; + +CREATE TRIGGER trg_capture_rent_observation + AFTER INSERT OR UPDATE OF status ON listings + FOR EACH ROW EXECUTE FUNCTION capture_rent_observation(); \ No newline at end of file diff --git a/roommate_matching_system_design.html b/roommate_matching_system_design.html new file mode 100644 index 0000000..097e7ba --- /dev/null +++ b/roommate_matching_system_design.html @@ -0,0 +1,491 @@ + +

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.
+
+ + 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 2/4] 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 3/4] 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 4/4] 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" }),