Files
personal-website/src/routes/projects/house-tracker/+page.server.ts
Stan Runge 0846cf7d77 feat: house tracker with home worker, alerts and listing map
Collects Pararius rent/buy listings into Postgres via a worker that runs on a
residential connection, sends Discord/ntfy alerts for new listings matching
rules, and shows an admin-only map, market stats and worker log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HfF5tnChi8tkCx9svJP6D
2026-10-11 20:57:20 +00:00

236 lines
9.2 KiB
TypeScript

import { fail, redirect } from '@sveltejs/kit';
import { and, asc, desc, eq, gte, ilike, isNull, lte, or, sql, type SQL } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { houseAlertRules, houseListings, houseWorkerRuns } from '$lib/server/db/schema';
import { requireAdmin } from '$lib/server/authorization';
import { sendAlert } from '$lib/server/house-tracker';
import { normalizeCity } from '$lib/house-tracker/alerts';
import { listingStatuses, propertyTypes } from '$lib/house-tracker/types';
import type { Actions, PageServerLoad } from './$types';
const int = (value: string | null) => {
const n = Number(value);
return value && Number.isInteger(n) && n >= 0 && n < 100_000_000 ? n : null;
};
export const load: PageServerLoad = async (event) => {
if (!event.locals.user) redirect(303, '/login?returnTo=/projects/house-tracker');
requireAdmin(event);
const params = event.url.searchParams;
const offer = params.get('offer') === 'buy' ? 'buy' : 'rent';
const city = (params.get('city') ?? '').toLowerCase().replace(/[^a-z0-9-]/g, '');
const min = int(params.get('min'));
const max = int(params.get('max'));
const showRemoved = params.get('removed') === '1';
const status = params.get('status') ?? '';
const q = (params.get('q') ?? '').trim().slice(0, 80);
const sort = ['new', 'price', 'ppm2', 'area'].includes(params.get('sort') ?? '')
? (params.get('sort') as string)
: 'new';
const where: (SQL | undefined)[] = [eq(houseListings.offer, offer)];
if (city) where.push(eq(houseListings.citySlug, city));
if (min != null) where.push(gte(houseListings.priceEur, min));
if (max != null) where.push(lte(houseListings.priceEur, max));
if (!showRemoved) where.push(isNull(houseListings.removedAt));
if (status === 'any') where.push(sql`${houseListings.status} IS NOT NULL`);
else if ((listingStatuses as readonly string[]).includes(status))
where.push(eq(houseListings.status, status));
if (q) {
const pattern = `%${q.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
where.push(
or(
ilike(houseListings.title, pattern),
ilike(houseListings.neighbourhood, pattern),
ilike(houseListings.postalCode, pattern),
ilike(houseListings.agent, pattern)
)
);
}
const ppm2 = sql<number>`${houseListings.priceEur}::float / nullif(${houseListings.areaM2}, 0)`;
const order = {
new: [desc(houseListings.firstSeenAt)],
price: [asc(houseListings.priceEur), desc(houseListings.firstSeenAt)],
ppm2: [sql`${ppm2} ASC NULLS LAST`, desc(houseListings.firstSeenAt)],
area: [sql`${houseListings.areaM2} DESC NULLS LAST`, desc(houseListings.firstSeenAt)]
}[sort]!;
const [listings, cities, stats, rules, runs, health] = await Promise.all([
db
.select({
id: houseListings.id,
source: houseListings.source,
url: houseListings.url,
propertyType: houseListings.propertyType,
title: houseListings.title,
city: houseListings.city,
neighbourhood: houseListings.neighbourhood,
postalCode: houseListings.postalCode,
priceEur: houseListings.priceEur,
areaM2: houseListings.areaM2,
rooms: houseListings.rooms,
bedrooms: houseListings.bedrooms,
energyLabel: houseListings.energyLabel,
interior: houseListings.interior,
availableFrom: houseListings.availableFrom,
agent: houseListings.agent,
imageUrl: houseListings.imageUrl,
lat: houseListings.lat,
lng: houseListings.lng,
offeredAt: houseListings.offeredAt,
firstSeenAt: houseListings.firstSeenAt,
firstSeenLane: houseListings.firstSeenLane,
removedAt: houseListings.removedAt,
alertedAt: houseListings.alertedAt,
status: houseListings.status,
notes: houseListings.notes
})
.from(houseListings)
.where(and(...where))
.orderBy(...order)
.limit(400),
db.execute(sql`SELECT city_slug AS slug, min(city) AS name,
count(*) FILTER (WHERE removed_at IS NULL)::int AS active
FROM house_listings WHERE offer = ${offer} AND city_slug IS NOT NULL
GROUP BY city_slug ORDER BY active DESC, slug LIMIT 200`),
db.execute(sql`SELECT coalesce(min(city), city_slug) AS city,
count(*) FILTER (WHERE removed_at IS NULL)::int AS active,
count(*) FILTER (WHERE first_seen_at > now() - interval '7 days')::int AS new_week,
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_eur)
FILTER (WHERE removed_at IS NULL AND price_eur > 0) AS median_price,
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_eur::float / area_m2)
FILTER (WHERE removed_at IS NULL AND price_eur > 0 AND area_m2 > 0) AS median_ppm2,
percentile_cont(0.5) WITHIN GROUP (
ORDER BY extract(epoch FROM removed_at - coalesce(offered_at, first_seen_at)) / 86400)
FILTER (WHERE removed_at > now() - interval '90 days') AS median_days_listed
FROM house_listings WHERE offer = ${offer} AND city_slug IS NOT NULL
GROUP BY city_slug ORDER BY active DESC LIMIT 30`),
db.select().from(houseAlertRules).orderBy(asc(houseAlertRules.id)),
db.select().from(houseWorkerRuns).orderBy(desc(houseWorkerRuns.createdAt)).limit(25),
db.execute(sql`SELECT
(SELECT max(created_at) FROM house_worker_runs WHERE status = 'ok') AS last_ok,
(SELECT max(created_at) FROM house_worker_runs WHERE status = 'challenged') AS last_challenge,
(SELECT count(*)::int FROM house_listings) AS total,
(SELECT count(*)::int FROM house_listings WHERE alerted_at > now() - interval '7 days') AS alerts_week,
(SELECT percentile_cont(0.5) WITHIN GROUP (
ORDER BY extract(epoch FROM first_seen_at - offered_at) / 60)
FROM house_listings WHERE first_seen_lane = 'fast' AND offered_at IS NOT NULL
AND first_seen_at >= offered_at AND first_seen_at - offered_at < interval '3 days'
AND first_seen_at > now() - interval '30 days')
AS median_detect_minutes`)
]);
return {
filters: { offer, city, min, max, showRemoved, status, q, sort },
listings,
cities: cities as unknown as { slug: string; name: string | null; active: number }[],
stats: stats as unknown as {
city: string;
active: number;
new_week: number;
median_price: number | null;
median_ppm2: number | null;
median_days_listed: number | null;
}[],
rules,
runs,
health: health[0] as {
last_ok: Date | string | null;
last_challenge: Date | string | null;
total: number;
alerts_week: number;
median_detect_minutes: number | null;
},
statuses: listingStatuses,
propertyTypes
};
};
const nullableInt = (value: FormDataEntryValue | null) => {
const text = String(value ?? '').trim();
if (!text) return null;
const n = Number(text);
return Number.isInteger(n) && n >= 0 && n < 100_000_000 ? n : undefined;
};
export const actions: Actions = {
status: async (event) => {
requireAdmin(event);
const form = await event.request.formData();
const id = Number(form.get('id'));
const status = String(form.get('status') ?? '');
const notes = String(form.get('notes') ?? '')
.trim()
.slice(0, 2000);
if (!Number.isInteger(id) || id <= 0) return fail(400, { message: 'Invalid listing' });
if (status && !(listingStatuses as readonly string[]).includes(status))
return fail(400, { message: 'Invalid status' });
await db
.update(houseListings)
.set({ status: status || null, ...(form.has('notes') ? { notes: notes || null } : {}) })
.where(eq(houseListings.id, id));
return { ok: true };
},
saveRule: async (event) => {
requireAdmin(event);
const form = await event.request.formData();
const id = Number(form.get('id') || 0);
const name = String(form.get('name') ?? '')
.trim()
.slice(0, 80);
const minPrice = nullableInt(form.get('minPrice'));
const maxPrice = nullableInt(form.get('maxPrice'));
const minArea = nullableInt(form.get('minArea'));
const minBedrooms = nullableInt(form.get('minBedrooms'));
if (!name) return fail(400, { message: 'Give the rule a name' });
if (
minPrice === undefined ||
maxPrice === undefined ||
minArea === undefined ||
minBedrooms === undefined
)
return fail(400, { message: 'Numbers must be whole and positive' });
const values = {
name,
enabled: form.get('enabled') === 'on',
offer: form.get('offer') === 'buy' ? 'buy' : 'rent',
minPrice,
maxPrice,
minArea,
minBedrooms,
cities: String(form.get('cities') ?? '')
.split(',')
.map(normalizeCity)
.filter(Boolean)
.slice(0, 60),
propertyTypes: form
.getAll('propertyTypes')
.map(String)
.filter((type) => (propertyTypes as readonly string[]).includes(type))
};
if (id > 0) await db.update(houseAlertRules).set(values).where(eq(houseAlertRules.id, id));
else await db.insert(houseAlertRules).values(values);
return { ok: true };
},
deleteRule: async (event) => {
requireAdmin(event);
const id = Number((await event.request.formData()).get('id'));
if (!Number.isInteger(id) || id <= 0) return fail(400, { message: 'Invalid rule' });
await db.delete(houseAlertRules).where(eq(houseAlertRules.id, id));
return { ok: true };
},
testAlert: async (event) => {
requireAdmin(event);
const sent = await sendAlert({
title: '🏠 House tracker test',
body: 'If you can read this, alerts work.',
url: `${event.url.origin}/projects/house-tracker`
});
return sent
? { message: 'Test alert sent' }
: fail(400, {
message: 'No alert channel accepted it. Set HOUSE_DISCORD_WEBHOOK_URL or HOUSE_NTFY_URL.'
});
}
};