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/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 new file mode 100644 index 0000000..851e863 --- /dev/null +++ b/src/controllers/rentIndex.controller.js @@ -0,0 +1,21 @@ +// src/controllers/rentIndex.controller.js + +import * as rentIndexService from "../services/rentIndex.service.js"; + +export const getRentIndex = async (req, res, next) => { + try { + const { city, locality, roomType } = req.query; + + if (!city || !locality || !roomType) { + return res.status(400).json({ + status: "error", + message: "Missing required query parameters: city, locality, roomType", + }); + } + + const result = await rentIndexService.getRentIndex({ city, locality, roomType }); + res.json({ status: "success", data: result }); + } catch (err) { + next(err); + } +}; diff --git a/src/controllers/roommate.controller.js b/src/controllers/roommate.controller.js new file mode 100644 index 0000000..72a30c5 --- /dev/null +++ b/src/controllers/roommate.controller.js @@ -0,0 +1,25 @@ +// src/controllers/roommate.controller.js + +import * as roommateService from "../services/roommate.service.js"; + +const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); + +export const getFeed = asyncHandler(async (req, res) => { + const result = await roommateService.getRoommateFeed(req.user.userId, req.query); + res.json({ status: "success", data: result }); +}); + +export const updateRoommateProfile = asyncHandler(async (req, res) => { + const result = await roommateService.updateRoommateProfile(req.user.userId, req.params.userId, req.body); + res.json({ status: "success", data: result }); +}); + +export const blockUser = asyncHandler(async (req, res) => { + const result = await roommateService.blockUser(req.user.userId, req.params.targetUserId); + res.json({ status: "success", data: result }); +}); + +export const unblockUser = asyncHandler(async (req, res) => { + const result = await roommateService.unblockUser(req.user.userId, req.params.targetUserId); + res.json({ status: "success", data: result }); +}); diff --git a/src/cron/hardDeleteCleanup.js b/src/cron/hardDeleteCleanup.js index 280b1f3..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,7 +142,11 @@ const runHardDeleteCleanup = async () => { ); results.listing_photos = lp; - 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} @@ -153,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 @@ -167,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 @@ -175,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 @@ -187,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 @@ -195,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 @@ -209,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 @@ -217,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 @@ -259,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/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..cfc8bc0 --- /dev/null +++ b/src/db/utils/roommateCompatibility.js @@ -0,0 +1,102 @@ +// 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"; +import { logger } from "../../logger/index.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 } +// 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 {}; + + 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]], + ); + + 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; + }, {}); + } 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) => { + 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/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..6717fc6 --- /dev/null +++ b/src/routes/roommate.js @@ -0,0 +1,61 @@ +// src/routes/roommate.js + +import { Router } from "express"; +import { authenticate } from "../middleware/authenticate.js"; +import { authorize } from "../middleware/authorize.js"; +import { validate } from "../middleware/validate.js"; +import { AppError } from "../middleware/errorHandler.js"; +import { + getRoommateFeedSchema, + updateRoommateProfileSchema, + blockTargetParamsSchema, +} from "../validators/roommate.validators.js"; +import * as roommateController from "../controllers/roommate.controller.js"; + +export const roommateRouter = Router(); + +// Middleware: authenticated user must match :userId param +const requireSelf = (req, res, next) => { + if (req.user?.userId !== req.params.userId) { + return next(new AppError("Forbidden", 403)); + } + next(); +}; + +// Feed — any authenticated student can browse +roommateRouter.get( + "/roommates", + authenticate, + authorize("student"), + validate(getRoommateFeedSchema), + roommateController.getFeed, +); + +// Opt-in toggle — own profile only +roommateRouter.put( + "/:userId/roommate-profile", + authenticate, + authorize("student"), + requireSelf, + validate(updateRoommateProfileSchema), + roommateController.updateRoommateProfile, +); + +// Block / unblock — :userId must be the authenticated user +roommateRouter.post( + "/:userId/block/:targetUserId", + authenticate, + authorize("student"), + requireSelf, + validate(blockTargetParamsSchema), + roommateController.blockUser, +); + +roommateRouter.delete( + "/:userId/block/:targetUserId", + authenticate, + authorize("student"), + requireSelf, + validate(blockTargetParamsSchema), + roommateController.unblockUser, +); diff --git a/src/routes/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/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/services/listing.service.js b/src/services/listing.service.js index 378347f..481ff48 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(", "); @@ -147,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 @@ -157,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 @@ -164,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], ); @@ -300,13 +337,20 @@ 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 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); + const converted = toRupees(listing); + + return { + ...converted, + rentDeviation: rentDeviationPct(listing.rent_per_month, listing.ri_p50), + rentIndex: formatRentIndex(listing), + }; }; export const searchListings = async (userId, filters) => { @@ -420,6 +464,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 +491,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 +551,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 +561,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..4055444 --- /dev/null +++ b/src/services/rentIndex.service.js @@ -0,0 +1,55 @@ +// src/services/rentIndex.service.js +// + + +import { pool } from "../db/client.js"; +import { AppError } from "../middleware/errorHandler.js"; + +const paiseToRupees = (paise) => (paise != null ? Math.round(paise / 100) : null); + +export const getRentIndex = async ({ city, locality, roomType }) => { + 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.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' + 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 + 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`, + [normCity, normLocality, roomType], + ); + + const row = rows[0]; + + if (row.p50 == null) { + throw new AppError("No rent index data available for this city / room type combination yet", 404); + } + + return { + city: normCity, + locality: normLocality, // normalized value (null when blank/absent) + 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..aa6ef55 --- /dev/null +++ b/src/services/roommate.service.js @@ -0,0 +1,268 @@ +// src/services/roommate.service.js +// + +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); + + const callerHasPrefs = await hasPreferences(requestingUserId); + + 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, "\\_"); + + 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; + } + + if (callerHasPrefs && candidateIds.length) { + const scores = await scoreUsersForUser(requestingUserId, candidateIds); + for (const item of items) { + item._score = scores[item.user_id] ?? 0; + } + } + } + + const lastItem = hasNextPage ? items[items.length - 1] : null; + const nextCursor = + hasNextPage && lastItem?.looking_updated_at != null ? + { + cursorTime: lastItem.looking_updated_at.toISOString(), + cursorId: lastItem.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, + }; +}; + +export const blockUser = async (requestingUserId, targetUserId) => { + if (requestingUserId === targetUserId) { + throw new AppError("You cannot block yourself", 422); + } + + 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 — 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 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 }; +}; + +// ─── 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..f3ea6a8 --- /dev/null +++ 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/validators/roommate.validators.js b/src/validators/roommate.validators.js new file mode 100644 index 0000000..f43c64c --- /dev/null +++ b/src/validators/roommate.validators.js @@ -0,0 +1,33 @@ +// 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 blockTargetParamsSchema = z.object({ + params: z.object({ + userId: z.uuid({ error: "Invalid user ID" }), + targetUserId: z.uuid({ error: "Invalid target user ID" }), + }), +}); 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 = () => {