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
This commit is contained in:
235
src/routes/projects/house-tracker/+page.server.ts
Normal file
235
src/routes/projects/house-tracker/+page.server.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
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.'
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user