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
236 lines
9.2 KiB
TypeScript
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.'
|
|
});
|
|
}
|
|
};
|