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
24 changes: 20 additions & 4 deletions migrations/006: Proper rent index.sql
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
-- rent_index: materialised p25/p50/p75 per (city, locality, room_type).
-- Refreshed nightly by cron/rentIndexRefresh.js.
-- Listings JOIN this table to expose rentDeviation in API responses.
--
-- Fix (originally migration 011): The trigger now also fires when expires_at
-- changes so that renewals (status stays 'active' but expires_at advances)
-- correctly record a 'listing_renewed' observation. The UPDATE condition was
-- broadened to: NEW.status = 'active' AND (OLD.status <> 'active' OR
-- NEW.expires_at IS DISTINCT FROM OLD.expires_at).

CREATE TABLE IF NOT EXISTS rent_observations (
observation_id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
Expand Down Expand Up @@ -56,8 +62,13 @@ CREATE TABLE IF NOT EXISTS rent_index (
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.
-- and after UPDATE when status flips to 'active' OR expires_at changes while
-- already active (i.e. a renewal). Runs inside the same transaction as the
-- listing write — observation is never lost.
--
-- Fix vs original: the UPDATE branch previously only fired when OLD.status <>
-- 'active', which meant renewals (status unchanged, only expires_at advancing)
-- were silently dropped. Now we also fire when expires_at changes.
CREATE OR REPLACE FUNCTION capture_rent_observation()
RETURNS TRIGGER AS $$
BEGIN
Expand All @@ -75,8 +86,11 @@ BEGIN

ELSIF TG_OP = 'UPDATE'
AND NEW.status = 'active'
AND OLD.status <> 'active'
AND NEW.deleted_at IS NULL
AND (
OLD.status <> 'active'
OR NEW.expires_at IS DISTINCT FROM OLD.expires_at
)
THEN
INSERT INTO rent_observations
(listing_id, city, locality, room_type, rent_per_month, source)
Expand All @@ -94,8 +108,10 @@ BEGIN
END;
$$ LANGUAGE plpgsql;

-- Watch both status and expires_at so renewals (expires_at advances while
-- status stays 'active') also fire the trigger.
DROP TRIGGER IF EXISTS trg_capture_rent_observation ON listings;

CREATE TRIGGER trg_capture_rent_observation
AFTER INSERT OR UPDATE OF status ON listings
AFTER INSERT OR UPDATE OF status, expires_at ON listings
FOR EACH ROW EXECUTE FUNCTION capture_rent_observation();
75 changes: 53 additions & 22 deletions migrations/007_fix_roommate_constraints.sql
Original file line number Diff line number Diff line change
@@ -1,41 +1,72 @@
-- Active: 1777192065763@@127.0.0.1@5432@roomies_db
-- Migration 007: Fix roommate_blocks FK cascade + redundant index + add looking timestamp check
-- Migration 007: Fix roommate_blocks FK cascade + redundant index + CHECK constraint
--
-- Fixes applied from migration 005:
-- 1. Add CHECK constraint: looking_for_roommate = TRUE requires looking_updated_at IS NOT NULL
-- 2. Change roommate_blocks FKs from ON DELETE RESTRICT to ON DELETE CASCADE
-- 3. Drop redundant idx_roommate_blocks_blocker (covered by PK on blocker_id, blocked_id)
--
-- Revised approach (safe for environments that already have data in roommate_blocks):
-- Instead of DROP TABLE + recreate (which destroys all block rows), we use
-- ALTER TABLE to swap FK actions and add the CHECK constraint in-place.
-- A fresh environment that has no roommate_blocks table yet gets a CREATE TABLE.

-- Fix 1: 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().
-- Fix 1: Backfill timestamp — required before adding the CHECK constraint.
UPDATE student_profiles
SET
looking_updated_at = NOW()
WHERE
looking_for_roommate = TRUE
AND looking_updated_at IS NULL;
SET looking_updated_at = NOW()
WHERE looking_for_roommate = TRUE
AND looking_updated_at IS NULL;

-- Add CHECK constraint (safe now that all TRUE rows have a timestamp).
ALTER TABLE student_profiles
ADD CONSTRAINT chk_looking_has_timestamp CHECK (
looking_for_roommate = FALSE
OR looking_updated_at IS NOT NULL
);

-- Fix 2 + 3: 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.
-- Fix 2 + 3: Create roommate_blocks with CASCADE if it doesn't exist yet
-- (fresh environment), otherwise alter FKs in-place to preserve existing data.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = 'roommate_blocks'
) THEN
-- Fresh environment: create with correct constraints from the start.
CREATE TABLE roommate_blocks (
blocker_id UUID NOT NULL REFERENCES users (user_id) ON DELETE CASCADE,
blocked_id UUID NOT NULL REFERENCES users (user_id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (blocker_id, blocked_id),
CONSTRAINT chk_no_self_block CHECK (blocker_id <> blocked_id)
);
ELSE
-- Existing environment: swap RESTRICT FKs to CASCADE without touching data.
ALTER TABLE roommate_blocks
DROP CONSTRAINT IF EXISTS roommate_blocks_blocker_id_fkey,
DROP CONSTRAINT IF EXISTS roommate_blocks_blocked_id_fkey;

-- 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;
ALTER TABLE roommate_blocks
ADD CONSTRAINT roommate_blocks_blocker_id_fkey
FOREIGN KEY (blocker_id) REFERENCES users (user_id) ON DELETE CASCADE,
ADD CONSTRAINT roommate_blocks_blocked_id_fkey
FOREIGN KEY (blocked_id) REFERENCES users (user_id) ON DELETE CASCADE;

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)
);
-- Add self-block check only if it doesn't already exist.
IF NOT EXISTS (
SELECT 1 FROM information_schema.check_constraints
WHERE constraint_name = 'chk_no_self_block'
) THEN
ALTER TABLE roommate_blocks
ADD CONSTRAINT chk_no_self_block CHECK (blocker_id <> blocked_id);
END IF;
END IF;
END;
$$;

-- Fix 3: Drop redundant index (blocker_id is the leftmost column of the PK,
-- so the PK index already satisfies all blocker_id lookups).
DROP INDEX IF EXISTS idx_roommate_blocks_blocker;

-- Only keep the blocked_id index (blocker_id is covered by the PK leftmost prefix)
-- Keep only the blocked_id index (needed for reverse-lookup: "who is blocking me").
CREATE INDEX IF NOT EXISTS idx_roommate_blocks_blocked ON roommate_blocks (blocked_id);
13 changes: 10 additions & 3 deletions migrations/009_idx_listings_posted_by_status_city.sql
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@
--
-- The existing idx_listings_posted_by covers posted_by alone but not the
-- full predicate. This index covers the common access pattern.
--
-- Revised (originally migration 013): The original index keyed raw city but the
-- query uses LOWER(l.city) LIKE LOWER($n), so the planner could not use it.
-- The index is recreated as an expression index on LOWER(city) so the EXISTS
-- subquery is covered and the planner can use an index scan.

DROP INDEX IF EXISTS idx_listings_posted_by_status_city;

CREATE INDEX IF NOT EXISTS idx_listings_posted_by_status_city ON listings (posted_by, status, city)
WHERE
deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_listings_posted_by_status_city
ON listings (posted_by, status, LOWER(city))
WHERE deleted_at IS NULL;
3 changes: 2 additions & 1 deletion src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ app.use(
return callback(null, true);
}

callback(new Error(`CORS: origin '${origin}' is not allowed`));
logger.warn({ origin }, "CORS: origin not allowed");
callback(null, false);
},

credentials: true,
Expand Down
44 changes: 35 additions & 9 deletions src/controllers/auth.controller.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
// src/controllers/auth.controller.js
//
// Token transport strategy (mobile + web):
// - Cookies are always set (HttpOnly, Secure) — browser clients use these.
// - The JSON body includes tokens ONLY when the request signals it is a
// native mobile client, detected via the X-Client-Type: mobile header.
// Android / iOS clients must send this header to receive tokens in the body.
// - Browser clients (no header) get { authenticated: true } — no token leak.
//
// This keeps the API secure for browsers while remaining compatible with native
// apps that cannot access HttpOnly cookies.

import * as authService from "../services/auth.service.js";
import { parseTtlSeconds } from "../services/auth.service.js";
import { AppError } from "../middleware/errorHandler.js";
Expand All @@ -22,12 +34,30 @@ const clearAuthCookies = (res) => {
});
};

/**
* Returns true when the caller is a native mobile app.
* Mobile clients MUST send `X-Client-Type: mobile` to receive tokens in the body.
* Browsers never send this header, so they only get HttpOnly cookies.
*/
const isMobileClient = (req) => req.headers["x-client-type"]?.toLowerCase() === "mobile";

/**
* Build the JSON data payload for auth responses.
* - Mobile: full token pair so the client can store them in secure storage.
* - Browser: no tokens in body; cookies carry the session.
*/
const authResponseData = (req, tokens) => {
if (isMobileClient(req)) {
return tokens; // { accessToken, refreshToken, user, sid }
}
return { user: tokens.user }; // cookies-only transport for browsers
};

export const register = async (req, res, next) => {
try {
const tokens = await authService.register(req.body);
setAuthCookies(res, tokens.accessToken, tokens.refreshToken);

res.status(201).json({ status: "success", data: tokens });
res.status(201).json({ status: "success", data: authResponseData(req, tokens) });
} catch (err) {
next(err);
}
Expand All @@ -37,8 +67,7 @@ export const login = async (req, res, next) => {
try {
const tokens = await authService.login(req.body);
setAuthCookies(res, tokens.accessToken, tokens.refreshToken);

res.json({ status: "success", data: tokens });
res.json({ status: "success", data: authResponseData(req, tokens) });
} catch (err) {
next(err);
}
Expand Down Expand Up @@ -81,10 +110,8 @@ export const refresh = async (req, res, next) => {
}

const tokens = await authService.refresh(incomingRefreshToken);

setAuthCookies(res, tokens.accessToken, tokens.refreshToken);

res.json({ status: "success", data: tokens });
res.json({ status: "success", data: authResponseData(req, tokens) });
} catch (err) {
next(err);
}
Expand Down Expand Up @@ -149,8 +176,7 @@ export const googleCallback = async (req, res, next) => {
try {
const tokens = await authService.googleOAuth(req.body);
setAuthCookies(res, tokens.accessToken, tokens.refreshToken);

res.json({ status: "success", data: tokens });
res.json({ status: "success", data: authResponseData(req, tokens) });
} catch (err) {
next(err);
}
Expand Down
8 changes: 7 additions & 1 deletion src/controllers/verification.controller.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
// src/controllers/verification.controller.js

import * as verificationService from "../services/verification.service.js";

export const submitDocument = async (req, res, next) => {
try {
const result = await verificationService.submitDocument(req.user.userId, req.params.userId, req.body);
const result = await verificationService.submitDocument(
req.user.userId,
req.user.userId, // targetUserId = self
req.body,
);
res.status(201).json({ status: "success", data: result });
} catch (err) {
next(err);
Expand Down
10 changes: 8 additions & 2 deletions src/cron/savedSearchAlert.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,14 @@ import { logger } from "../logger/index.js";
import { enqueueNotificationsBulk } from "../workers/notificationQueue.js";

const SCHEDULE = process.env.CRON_SAVED_SEARCH_ALERT ?? "0 8 * * *";
const SEARCH_BATCH_SIZE = Number(process.env.SAVED_SEARCH_ALERT_BATCH_SIZE) || 500;
const NOTIFICATION_CHUNK_SIZE = Number(process.env.SAVED_SEARCH_ALERT_NOTIFICATION_CHUNK_SIZE) || 100;

const toPositiveInt = (value, fallback) => {
const n = Number(value);
return Number.isInteger(n) && n > 0 ? n : fallback;
};

const SEARCH_BATCH_SIZE = toPositiveInt(process.env.SAVED_SEARCH_ALERT_BATCH_SIZE, 500);
const NOTIFICATION_CHUNK_SIZE = toPositiveInt(process.env.SAVED_SEARCH_ALERT_NOTIFICATION_CHUNK_SIZE, 100);

const chunk = (items, size) => {
const chunks = [];
Expand Down
3 changes: 2 additions & 1 deletion src/db/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { logger } from "../logger/index.js";

const { Pool } = pg;

const DB_POOL_MAX = Number(process.env.DB_POOL_MAX) || 10;
const parsedPoolMax = Number.parseInt(process.env.DB_POOL_MAX ?? "", 10);
const DB_POOL_MAX = Number.isInteger(parsedPoolMax) && parsedPoolMax > 0 ? parsedPoolMax : 10;

export const pool = new Pool({
connectionString: config.DATABASE_URL,
Expand Down
40 changes: 5 additions & 35 deletions src/db/utils/roommateCompatibility.js
Original file line number Diff line number Diff line change
@@ -1,31 +1,7 @@
// 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 {};

Expand All @@ -47,13 +23,16 @@ export const scoreUsersForUser = async (requestingUserId, candidateIds, client =
);

// Step 2 — individual preference counts for union computation.
// We fetch the requesting user alongside the candidates in one query.
// FIX: pass a single array $1 for ANY($1::uuid[]) — previously the code
// spread [requestingUserId, ...candidateIds] which bound multiple positional
// params while the SQL only had one placeholder ($1), causing a DB error.
const allIds = [requestingUserId, ...candidateIds];
const { rows: countRows } = await client.query(
`SELECT user_id, COUNT(*)::int AS pref_count
FROM user_preferences
WHERE user_id = ANY($1::uuid[])
GROUP BY user_id`,
[requestingUserId, ...candidateIds],
[allIds],
);

const myCount = countRows.find((r) => r.user_id === requestingUserId)?.pref_count ?? 0;
Expand All @@ -64,8 +43,6 @@ export const scoreUsersForUser = async (requestingUserId, candidateIds, client =
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;
}, {});
Expand All @@ -74,17 +51,10 @@ export const scoreUsersForUser = async (requestingUserId, candidateIds, client =
{ 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(
Expand Down
3 changes: 2 additions & 1 deletion src/middleware/requireAdmin.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { AppError } from "./errorHandler.js";

const assertEmailVerified = (req, res, next) => {
if (!req.user) {
return next(new AppError("authenticate middleware must run before requireAdmin", 500));
// 401 — the caller is unauthenticated, not a server misconfiguration.
return next(new AppError("Authentication required", 401));
}
if (!req.user.isEmailVerified) {
return next(new AppError("Email verification required", 403));
Expand Down
Loading
Loading