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`${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.' }); } };