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:
2026-10-11 22:04:15 +02:00
parent 0ee9ed0abb
commit 0846cf7d77
30 changed files with 4269 additions and 1 deletions

View 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.'
});
}
};

View File

@@ -0,0 +1,412 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { onMount } from 'svelte';
import ExternalLink from 'lucide-svelte/icons/external-link';
import BellRing from 'lucide-svelte/icons/bell-ring';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import ListingMap from '$lib/house-tracker/ListingMap.svelte';
import { relativeTime } from '$lib/relative-time';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let now = $state(Date.now());
onMount(() => {
const timer = setInterval(() => (now = Date.now()), 30_000);
return () => clearInterval(timer);
});
let highlighted = $state<number | null>(null);
const euro = new Intl.NumberFormat('nl-NL', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0
});
const clock = new Intl.DateTimeFormat('nl-NL', {
timeZone: 'Europe/Amsterdam',
weekday: 'short',
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
const suffix = $derived(data.filters.offer === 'rent' ? '/mo' : '');
function price(value: number | null) {
return value == null ? 'on request' : `${euro.format(value)}${suffix}`;
}
function minutes(from: Date | string | null, to: Date | string | null) {
if (!from || !to) return null;
return Math.round((new Date(to).getTime() - new Date(from).getTime()) / 60_000);
}
function delayLabel(value: number | null) {
if (value == null || value < 0) return null;
if (value < 60) return `+${value} min`;
if (value < 48 * 60) return `+${Math.round(value / 60)} h`;
return `+${Math.round(value / 1440)} d`;
}
const workerAge = $derived(
data.health.last_ok ? now - new Date(data.health.last_ok).getTime() : Infinity
);
const workerState = $derived(
data.health.last_challenge &&
(!data.health.last_ok || new Date(data.health.last_challenge) > new Date(data.health.last_ok))
? 'challenged'
: workerAge < 75 * 60_000
? 'healthy'
: 'quiet'
);
const mappable = $derived(data.listings.filter((row) => row.lat != null).length);
</script>
<svelte:head>
<title>house tracker</title>
<meta name="robots" content="noindex" />
</svelte:head>
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6">
<div class="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-4xl font-semibold tracking-tight">house tracker</h1>
<p class="mt-1 text-sm text-muted-foreground">
{data.health.total} listings collected · {data.health.alerts_week}
{data.health.alerts_week === 1 ? 'alert' : 'alerts'} this week
{#if data.health.median_detect_minutes != null}
· the fast lane spots new listings {delayLabel(
Math.round(data.health.median_detect_minutes)
)?.slice(1)} after they go up (median)
{/if}
</p>
</div>
<div class="flex items-center gap-2 text-sm">
<span
class={[
'inline-block size-2.5 rounded-full',
workerState === 'healthy' && 'bg-green-600',
workerState === 'quiet' && 'bg-amber-500',
workerState === 'challenged' && 'bg-red-600'
]}
></span>
{#if workerState === 'challenged'}
worker was challenged {relativeTime(data.health.last_challenge!, now)}, backing off
{:else if data.health.last_ok}
worker last synced {relativeTime(data.health.last_ok, now)}
{:else}
worker has not synced yet
{/if}
</div>
</div>
<form method="GET" class="mb-4 grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-8">
<NativeSelect name="offer" value={data.filters.offer} aria-label="Rent or buy">
<option value="rent">rent</option>
<option value="buy">buy</option>
</NativeSelect>
<NativeSelect name="city" value={data.filters.city} aria-label="City">
<option value="">all cities</option>
{#each data.cities as city (city.slug)}
<option value={city.slug}>{city.name ?? city.slug} ({city.active})</option>
{/each}
</NativeSelect>
<Input name="min" type="number" min="0" placeholder="min €" value={data.filters.min ?? ''} />
<Input name="max" type="number" min="0" placeholder="max €" value={data.filters.max ?? ''} />
<Input name="q" placeholder="street, area, agent" value={data.filters.q} class="col-span-2" />
<NativeSelect name="sort" value={data.filters.sort} aria-label="Sort">
<option value="new">newest</option>
<option value="price">cheapest</option>
<option value="ppm2">cheapest per m²</option>
<option value="area">biggest</option>
</NativeSelect>
<NativeSelect name="status" value={data.filters.status} aria-label="My status">
<option value="">any status</option>
<option value="any">tracked only</option>
{#each data.statuses as status (status)}<option value={status}>{status}</option>{/each}
</NativeSelect>
<label class="col-span-2 flex items-center gap-2 text-sm">
<input type="checkbox" name="removed" value="1" checked={data.filters.showRemoved} />
include listings that are gone
</label>
<Button type="submit" class="col-span-2 sm:col-span-1">filter</Button>
</form>
<ListingMap
listings={data.listings}
offer={data.filters.offer}
onselect={(id) => (highlighted = id)}
/>
<p class="mt-1 mb-6 text-xs text-muted-foreground">
{mappable} of {data.listings.length} shown listings have coordinates (they arrive with the detail
page fetch).
</p>
<ul class="divide-y divide-border rounded-lg border border-border">
{#each data.listings as listing (listing.id)}
{@const delay = minutes(listing.offeredAt, listing.firstSeenAt)}
<li
id="listing-{listing.id}"
class={[
'flex gap-3 p-3 sm:gap-4',
highlighted === listing.id && 'bg-muted',
listing.removedAt && 'opacity-60'
]}
>
{#if listing.imageUrl}
<img
src={listing.imageUrl}
alt=""
loading="lazy"
referrerpolicy="no-referrer"
class="hidden size-24 shrink-0 rounded-md object-cover sm:block"
/>
{/if}
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-baseline justify-between gap-x-3">
<!-- External listing site. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a
href={listing.url}
target="_blank"
rel="noreferrer"
class="inline-flex items-center gap-1 font-medium hover:underline"
>
{listing.title ?? 'Listing'}
<ExternalLink class="size-3.5 text-muted-foreground" />
</a>
<span class="font-semibold tabular-nums">{price(listing.priceEur)}</span>
</div>
<div class="mt-0.5 text-sm text-muted-foreground">
{[
listing.postalCode,
[listing.city, listing.neighbourhood].filter(Boolean).join(', '),
listing.areaM2 != null && `${listing.areaM2} m²`,
listing.priceEur &&
listing.areaM2 &&
`${euro.format(listing.priceEur / listing.areaM2)}/m²`,
listing.rooms != null && `${listing.rooms} rooms`,
listing.energyLabel && `label ${listing.energyLabel}`,
listing.interior,
listing.availableFrom && `available ${listing.availableFrom.toLowerCase()}`
]
.filter(Boolean)
.join(' · ')}
</div>
<div
class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground"
>
{#if listing.offeredAt}
<span title="published on the site"
>posted {clock.format(new Date(listing.offeredAt))}</span
>
{/if}
<span title="first seen by the worker">
seen {relativeTime(listing.firstSeenAt, now)}
{#if listing.firstSeenLane !== 'seed' && delayLabel(delay)}({delayLabel(delay)}, {listing.firstSeenLane}
lane){/if}
</span>
{#if listing.alertedAt}<Badge variant="secondary"
><BellRing class="mr-1 size-3" />alerted</Badge
>{/if}
{#if listing.removedAt}<Badge variant="outline"
>gone {relativeTime(listing.removedAt, now)}</Badge
>{/if}
{#if listing.agent}<span>{listing.agent}</span>{/if}
<span>{listing.source}</span>
</div>
</div>
<form
method="POST"
action="?/status"
use:enhance={() =>
async ({ update }) =>
update({ reset: false, invalidateAll: false })}
class="shrink-0"
>
<input type="hidden" name="id" value={listing.id} />
<NativeSelect
name="status"
value={listing.status ?? ''}
aria-label="My status"
class="h-8 text-xs"
onchange={(event) => (event.currentTarget as HTMLSelectElement).form?.requestSubmit()}
>
<option value="">—</option>
{#each data.statuses as status (status)}<option value={status}>{status}</option>{/each}
</NativeSelect>
<noscript><Button type="submit" size="sm" variant="outline">save</Button></noscript>
</form>
</li>
{:else}
<li class="p-6 text-center text-sm text-muted-foreground">
No listings yet. Start the home worker (see docs/house-tracker.md).
</li>
{/each}
</ul>
{#if data.listings.length === 400}
<p class="mt-2 text-xs text-muted-foreground">
Showing the first 400. Narrow the filters to see more.
</p>
{/if}
<h2 class="mt-12 mb-3 text-2xl font-semibold">market ({data.filters.offer})</h2>
<div class="overflow-x-auto rounded-lg border border-border">
<table class="w-full text-sm">
<thead class="bg-muted/50 text-left text-xs text-muted-foreground">
<tr>
<th class="px-3 py-2">city</th>
<th class="px-3 py-2 text-right">active</th>
<th class="px-3 py-2 text-right">new (7d)</th>
<th class="px-3 py-2 text-right">median price</th>
<th class="px-3 py-2 text-right">median €/m²</th>
<th class="px-3 py-2 text-right">median days listed</th>
</tr>
</thead>
<tbody>
{#each data.stats as row (row.city)}
<tr class="border-t border-border tabular-nums">
<td class="px-3 py-2">{row.city}</td>
<td class="px-3 py-2 text-right">{row.active}</td>
<td class="px-3 py-2 text-right">{row.new_week}</td>
<td class="px-3 py-2 text-right"
>{row.median_price != null ? price(Math.round(row.median_price)) : '—'}</td
>
<td class="px-3 py-2 text-right"
>{row.median_ppm2 != null ? euro.format(row.median_ppm2) : '—'}</td
>
<td class="px-3 py-2 text-right"
>{row.median_days_listed != null ? Math.round(row.median_days_listed) : '—'}</td
>
</tr>
{:else}
<tr
><td colspan="6" class="px-3 py-4 text-center text-muted-foreground">no data yet</td
></tr
>
{/each}
</tbody>
</table>
</div>
<p class="mt-1 text-xs text-muted-foreground">
"Days listed" needs a few full sweeps before listings can be seen disappearing.
</p>
<div class="mt-12 mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 class="text-2xl font-semibold">alerts</h2>
<form method="POST" action="?/testAlert" use:enhance>
<Button type="submit" variant="outline" size="sm"
><BellRing class="mr-1 size-4" />send test alert</Button
>
</form>
</div>
{#if form?.message}<p class="mb-3 text-sm">{form.message}</p>{/if}
<div class="grid gap-3 md:grid-cols-2">
{#each [...data.rules, null] as rule (rule?.id ?? 'new')}
<Card.Root class="py-0">
<Card.Content class="p-4">
<form method="POST" action="?/saveRule" use:enhance class="grid gap-2 text-sm">
<input type="hidden" name="id" value={rule?.id ?? ''} />
<div class="flex items-center gap-2">
<Input name="name" value={rule?.name ?? ''} placeholder="new rule name" required />
<label class="flex items-center gap-1 whitespace-nowrap">
<input type="checkbox" name="enabled" checked={rule?.enabled ?? true} /> on
</label>
</div>
<div class="grid grid-cols-3 gap-2">
<NativeSelect name="offer" value={rule?.offer ?? 'rent'} aria-label="Offer">
<option value="rent">rent</option>
<option value="buy">buy</option>
</NativeSelect>
<Input
name="minPrice"
type="number"
min="0"
placeholder="min €"
value={rule?.minPrice ?? ''}
/>
<Input
name="maxPrice"
type="number"
min="0"
placeholder="max €"
value={rule?.maxPrice ?? ''}
/>
<Input
name="minArea"
type="number"
min="0"
placeholder="min m²"
value={rule?.minArea ?? ''}
/>
<Input
name="minBedrooms"
type="number"
min="0"
placeholder="min bedrooms"
value={rule?.minBedrooms ?? ''}
class="col-span-2"
/>
</div>
<Input
name="cities"
placeholder="cities, comma separated (empty = anywhere)"
value={rule?.cities.join(', ') ?? ''}
/>
<div class="flex flex-wrap gap-3">
{#each data.propertyTypes as type (type)}
<label class="flex items-center gap-1">
<input
type="checkbox"
name="propertyTypes"
value={type}
checked={rule?.propertyTypes.includes(type) ?? false}
/>
{type}
</label>
{/each}
</div>
<div class="flex gap-2">
<Button type="submit" size="sm">{rule ? 'save' : 'add rule'}</Button>
{#if rule}
<Button type="submit" size="sm" variant="ghost" formaction="?/deleteRule"
>delete</Button
>
{/if}
</div>
</form>
</Card.Content>
</Card.Root>
{/each}
</div>
<p class="mt-1 text-xs text-muted-foreground">
Unchecked property types means all types. Listings without a size pass size limits, so you never
miss one because a search card left it out.
</p>
<h2 class="mt-12 mb-3 text-2xl font-semibold">worker log</h2>
<div class="overflow-x-auto rounded-lg border border-border">
<table class="w-full text-xs">
<tbody>
{#each data.runs as run (run.id)}
<tr class="border-t border-border first:border-t-0">
<td class="px-3 py-1.5 whitespace-nowrap text-muted-foreground"
>{relativeTime(run.createdAt, now)}</td
>
<td class="px-3 py-1.5">{run.lane}</td>
<td class={['px-3 py-1.5', run.status !== 'ok' && 'font-medium text-red-600']}>
{run.status}{run.httpStatus ? ` ${run.httpStatus}` : ''}
</td>
<td class="px-3 py-1.5 tabular-nums">{run.found ?? ''}</td>
<td class="max-w-md truncate px-3 py-1.5 text-muted-foreground" title={run.url}>
{run.message ?? run.url.replace(/^https:\/\/www\./, '')}
</td>
</tr>
{:else}
<tr><td class="px-3 py-4 text-center text-muted-foreground">nothing yet</td></tr>
{/each}
</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,11 @@
import { json } from '@sveltejs/kit';
import { detailsRequestSchema } from '$lib/house-tracker/types';
import { requireWorker, saveDetails } from '$lib/server/house-tracker';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request }) => {
requireWorker(request);
const parsed = detailsRequestSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return json({ error: parsed.error.issues.slice(0, 5) }, { status: 400 });
return json(await saveDetails(parsed.data));
};

View File

@@ -0,0 +1,12 @@
import { json } from '@sveltejs/kit';
import { reportRequestSchema } from '$lib/house-tracker/types';
import { recordReport, requireWorker } from '$lib/server/house-tracker';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request }) => {
requireWorker(request);
const parsed = reportRequestSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return json({ error: parsed.error.issues.slice(0, 5) }, { status: 400 });
await recordReport(parsed.data);
return json({ ok: true });
};

View File

@@ -0,0 +1,11 @@
import { json } from '@sveltejs/kit';
import { syncRequestSchema } from '$lib/house-tracker/types';
import { requireWorker, syncCards } from '$lib/server/house-tracker';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request }) => {
requireWorker(request);
const parsed = syncRequestSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return json({ error: parsed.error.issues.slice(0, 5) }, { status: 400 });
return json(await syncCards(parsed.data));
};