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,128 @@
import {
bigint,
boolean,
check,
doublePrecision,
index,
integer,
jsonb,
pgTable,
text,
uniqueIndex
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { id, timestamptz } from './helpers';
// Created by scripts/house-tracker-migration.sql; keep both in sync.
export const houseListings = pgTable(
'house_listings',
{
id: id(),
source: text('source').notNull(),
sourceId: text('source_id').notNull(),
externalId: text('external_id'),
url: text('url').notNull(),
offer: text('offer').notNull(),
propertyType: text('property_type').notNull(),
title: text('title'),
street: text('street'),
postalCode: text('postal_code'),
city: text('city'),
citySlug: text('city_slug'),
neighbourhood: text('neighbourhood'),
priceEur: integer('price_eur'),
areaM2: integer('area_m2'),
rooms: integer('rooms'),
bedrooms: integer('bedrooms'),
bathrooms: integer('bathrooms'),
constructionYear: integer('construction_year'),
energyLabel: text('energy_label'),
interior: text('interior'),
depositEur: integer('deposit_eur'),
availableFrom: text('available_from'),
contractType: text('contract_type'),
agent: text('agent'),
imageUrl: text('image_url'),
description: text('description'),
features: jsonb('features').$type<Record<string, string>>().notNull().default({}),
lat: doublePrecision('lat'),
lng: doublePrecision('lng'),
offeredAt: timestamptz('offered_at'),
firstSeenAt: timestamptz('first_seen_at').notNull().defaultNow(),
firstSeenLane: text('first_seen_lane').notNull().default('slow'),
lastSeenAt: timestamptz('last_seen_at').notNull().defaultNow(),
detailFetchedAt: timestamptz('detail_fetched_at'),
removedAt: timestamptz('removed_at'),
alertedAt: timestamptz('alerted_at'),
status: text('status'),
notes: text('notes')
},
(table) => [
check('house_listings_offer_check', sql`offer IN ('rent', 'buy')`),
check('house_listings_price_eur_check', sql`price_eur IS NULL OR price_eur >= 0`),
uniqueIndex('house_listings_source_idx').on(table.source, table.sourceId),
index('house_listings_active_idx')
.on(table.offer, table.firstSeenAt.desc())
.where(sql`removed_at IS NULL`),
index('house_listings_city_idx').on(table.source, table.offer, table.citySlug),
index('house_listings_details_idx')
.on(table.firstSeenAt.desc())
.where(sql`detail_fetched_at IS NULL`)
]
);
export const housePriceHistory = pgTable(
'house_price_history',
{
listingId: integer('listing_id')
.notNull()
.references(() => houseListings.id, { onDelete: 'cascade' }),
observedAt: timestamptz('observed_at').notNull().defaultNow(),
priceEur: integer('price_eur').notNull()
},
(table) => [index('house_price_history_listing_idx').on(table.listingId, table.observedAt)]
);
export const houseSearches = pgTable('house_searches', {
searchUrl: text('search_url').primaryKey(),
source: text('source').notNull(),
lane: text('lane').notNull(),
firstSyncedAt: timestamptz('first_synced_at').notNull().defaultNow(),
lastSyncedAt: timestamptz('last_synced_at').notNull().defaultNow(),
lastFound: integer('last_found').notNull().default(0)
});
export const houseWorkerRuns = pgTable(
'house_worker_runs',
{
id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(),
source: text('source').notNull(),
lane: text('lane').notNull(),
url: text('url').notNull(),
status: text('status').notNull(),
httpStatus: integer('http_status'),
found: integer('found'),
durationMs: integer('duration_ms'),
message: text('message'),
createdAt: timestamptz('created_at').notNull().defaultNow()
},
(table) => [index('house_worker_runs_created_idx').on(table.createdAt.desc())]
);
export const houseAlertRules = pgTable(
'house_alert_rules',
{
id: id(),
name: text('name').notNull(),
enabled: boolean('enabled').notNull().default(true),
offer: text('offer').notNull().default('rent'),
minPrice: integer('min_price'),
maxPrice: integer('max_price'),
minArea: integer('min_area'),
minBedrooms: integer('min_bedrooms'),
cities: text('cities').array().notNull().default([]),
propertyTypes: text('property_types').array().notNull().default([]),
createdAt: timestamptz('created_at').notNull().defaultNow()
},
() => [check('house_alert_rules_offer_check', sql`offer IN ('rent', 'buy')`)]
);

View File

@@ -10,3 +10,4 @@ export * from './animaldex';
export * from './lists';
export * from './money-tracker';
export * from './cs-prices';
export * from './house-tracker';