Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions migrations/005: Roommate matching support.sql
Original file line number Diff line number Diff line change
@@ -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);
101 changes: 101 additions & 0 deletions migrations/006: Proper rent index.sql
Original file line number Diff line number Diff line change
@@ -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();
41 changes: 41 additions & 0 deletions migrations/007_fix_roommate_constraints.sql
Original file line number Diff line number Diff line change
@@ -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);
8 changes: 8 additions & 0 deletions migrations/008_fix_rent_index_redundant_index.sql
Original file line number Diff line number Diff line change
@@ -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;
13 changes: 13 additions & 0 deletions migrations/009_idx_listings_posted_by_status_city.sql
Original file line number Diff line number Diff line change
@@ -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;
21 changes: 21 additions & 0 deletions src/controllers/rentIndex.controller.js
Original file line number Diff line number Diff line change
@@ -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);
}
};
25 changes: 25 additions & 0 deletions src/controllers/roommate.controller.js
Original file line number Diff line number Diff line change
@@ -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 });
});
Loading
Loading