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:
128
src/lib/server/db/schema/house-tracker.ts
Normal file
128
src/lib/server/db/schema/house-tracker.ts
Normal 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')`)]
|
||||
);
|
||||
@@ -10,3 +10,4 @@ export * from './animaldex';
|
||||
export * from './lists';
|
||||
export * from './money-tracker';
|
||||
export * from './cs-prices';
|
||||
export * from './house-tracker';
|
||||
|
||||
Reference in New Issue
Block a user