// Add, update, list and remove house tracker listings by hand (screenshots, // links from chats). Writes house.listings directly; no website restart needed. // // bun run house add --url --city Gouda --price 1422 --area 84 --bedrooms 2 // bun run house add --json listing.json (or `--json -` for stdin; may be an array) // bun run house update --status viewing --notes "Sat 14:00" // bun run house list [--all] [--city gouda] [--offer buy] [--json] // bun run house remove [--hard] (soft remove = marked gone) // bun run house restore // // DATABASE_URL or DB_URL must point at the database, e.g. `bun --env-file=.env run house list`. import { readFileSync } from 'node:fs'; import { parseArgs } from 'node:util'; import postgres from 'postgres'; import { inferOffer, inferPropertyType, keyFromAddress, keyFromUrl, manualListingSchema, normalizeKeys, toColumns, type ListingKey, type ManualListing } from '../src/lib/house-tracker/manual'; const usage = `usage: bun run house [options] add [--json ] [field flags] insert, or update when the same link/address exists update [field flags] change only the given fields (use "" to clear one) list [--all] [--city ] [--offer rent|buy] [--status ] [--limit n] [--json] remove [--hard] mark gone (or delete with --hard) restore undo a soft remove field flags: --url --offer rent|buy --type apartment|house|room|studio|other --title --street --postal-code --city --neighbourhood --price --area --rooms --bedrooms --bathrooms --year --energy-label --interior --deposit --available-from --contract-type --agent --image-url --description --notes --status interested|contacted|viewing|applied|rejected --lat --lng --offered-at --first-seen (when it was shared) --source --source-id other: --dry-run (print, don't write)`; const fieldFlags = [ 'url', 'offer', 'type', 'title', 'street', 'postal-code', 'city', 'neighbourhood', 'price', 'area', 'rooms', 'bedrooms', 'bathrooms', 'year', 'energy-label', 'interior', 'deposit', 'available-from', 'contract-type', 'agent', 'image-url', 'description', 'notes', 'status', 'lat', 'lng', 'offered-at', 'first-seen', 'source', 'source-id', 'external-id' ] as const; const { values: flags, positionals } = parseArgs({ allowPositionals: true, options: { ...Object.fromEntries(fieldFlags.map((name) => [name, { type: 'string' as const }])), json: { type: 'string' }, all: { type: 'boolean' }, hard: { type: 'boolean' }, 'dry-run': { type: 'boolean' }, limit: { type: 'string' }, help: { type: 'boolean', short: 'h' } } }); const [command, target] = positionals; const flag = (name: string) => (flags as Record)[name]; if (!command || flag('help')) { console.log(usage); process.exit(command ? 0 : 1); } function fieldInput(): Record { const raw: Record = {}; for (const name of fieldFlags) { const value = flag(name); // An empty flag clears the column on update. if (typeof value === 'string') raw[name] = value === '' ? null : value; } return normalizeKeys(raw); } async function jsonInput(): Promise[]> { const source = flag('json'); if (typeof source !== 'string') return []; const text = source === '-' ? readFileSync(0, 'utf8') : readFileSync(source, 'utf8'); const parsed = JSON.parse(text) as unknown; return (Array.isArray(parsed) ? parsed : [parsed]) as Record[]; } function parseListing(raw: Record): ManualListing { const result = manualListingSchema.safeParse(normalizeKeys(raw)); if (!result.success) { const issues = result.error.issues.map((i) => ` ${i.path.join('.')}: ${i.message}`); throw new Error(`invalid listing:\n${issues.join('\n')}`); } return result.data; } function keyFor(listing: ManualListing): ListingKey { const key = listing.url ? keyFromUrl(listing.url) : keyFromAddress(listing); return { ...key, source: listing.source ?? key.source, sourceId: listing.sourceId ?? key.sourceId }; } const dbUrl = process.env.DATABASE_URL ?? process.env.DB_URL; if (!dbUrl) throw new Error('Set DATABASE_URL (or DB_URL), e.g. bun --env-file=.env run house …'); const sql = postgres(dbUrl, { max: 1, onnotice: () => {} }); type Tx = postgres.TransactionSql; const summary = sql`id, source, source_id, offer, property_type, title, city, city_slug, price_eur, area_m2, rooms, bedrooms, status, removed_at, url`; async function findId(ref: string | undefined): Promise { if (!ref) throw new Error('give a listing id or url'); if (/^\d+$/.test(ref)) return Number(ref); const key = keyFromUrl(ref); const [row] = await sql<{ id: number }[]>`select id from house.listings where (source = ${key.source} and source_id = ${key.sourceId}) or url = ${ref} limit 1`; if (!row) throw new Error(`no listing for ${ref}`); return row.id; } // Same convention as the worker: a price_history row whenever the price is new or changes. async function recordPrice(tx: Tx, id: number, previous: number | null, next: unknown) { if (typeof next !== 'number' || next === previous) return; await tx`insert into house.price_history (listing_id, price_eur) values (${id}, ${next})`; } async function add() { const fromFlags = fieldInput(); const inputs = (await jsonInput()).map((item) => ({ ...item, ...fromFlags })); if (!inputs.length) inputs.push(fromFlags); for (const raw of inputs) { const listing = parseListing(raw); const key = keyFor(listing); const hint = `${key.url} ${listing.title ?? ''} ${listing.description?.slice(0, 300) ?? ''}`; const given = toColumns(listing); if (listing.url) given.url = key.url; if (!given.city_slug && key.citySlug) given.city_slug = key.citySlug; const insert = { source: key.source, source_id: key.sourceId, url: key.url, offer: listing.offer ?? key.offer ?? inferOffer(hint) ?? 'rent', property_type: listing.propertyType ?? key.propertyType ?? inferPropertyType(hint) ?? 'other', first_seen_lane: 'manual', // Nothing for the worker to fetch: we filled in what we know. detail_fetched_at: new Date(), ...given }; if (flag('dry-run')) { console.log(insert); continue; } const row = await sql.begin(async (tx) => { const [before] = await tx<{ id: number; price_eur: number | null }[]>` select id, price_eur from house.listings where source = ${key.source} and source_id = ${key.sourceId} for update`; if (before) { const update = { ...given, last_seen_at: new Date(), removed_at: null }; if (listing.offer) Object.assign(update, { offer: listing.offer }); const [updated] = await tx`update house.listings set ${tx(update)} where id = ${before.id} returning ${summary}`; await recordPrice(tx, before.id, before.price_eur, given.price_eur); return { ...updated, action: 'updated' }; } const [inserted] = await tx`insert into house.listings ${tx(insert)} returning ${summary}`; await recordPrice(tx, inserted.id, null, given.price_eur); return { ...inserted, action: 'added' }; }); print([row]); } } async function update() { const id = await findId(target); const listing = parseListing(fieldInput()); const given = toColumns(listing); if (listing.url) given.url = listing.url; if (listing.offer) given.offer = listing.offer; if (listing.propertyType) given.property_type = listing.propertyType; if (!Object.keys(given).length) throw new Error('nothing to update'); if (flag('dry-run')) return console.log({ id, ...given }); const [row] = await sql.begin(async (tx) => { const [before] = await tx<{ price_eur: number | null }[]>` select price_eur from house.listings where id = ${id} for update`; if (!before) throw new Error(`no listing ${id}`); const rows = await tx`update house.listings set ${tx(given)} where id = ${id} returning ${summary}`; await recordPrice(tx, id, before.price_eur, given.price_eur); return rows; }); print([row]); } async function list() { const limit = Number(flag('limit') ?? 100); const city = flag('city'); const offer = flag('offer'); const status = flag('status'); const rows = await sql`select ${summary}, first_seen_at, notes from house.listings where true ${flag('all') ? sql`` : sql`and removed_at is null`} ${typeof city === 'string' ? sql`and city_slug = ${city}` : sql``} ${typeof offer === 'string' ? sql`and offer = ${offer}` : sql``} ${typeof status === 'string' ? sql`and status = ${status}` : sql``} order by first_seen_at desc limit ${limit}`; if (flag('json')) console.log(JSON.stringify(rows, null, 2)); else print(rows); } async function remove(hard: boolean) { const id = await findId(target); const rows = hard ? await sql`delete from house.listings where id = ${id} returning ${summary}` : await sql`update house.listings set removed_at = now() where id = ${id} returning ${summary}`; if (!rows.length) throw new Error(`no listing ${id}`); console.log(hard ? 'deleted:' : 'marked gone:'); print(rows); } async function restore() { const id = await findId(target); const rows = await sql`update house.listings set removed_at = null where id = ${id} returning ${summary}`; print(rows); } function print(rows: readonly Record[]) { if (!rows.length) return console.log('(no listings)'); for (const r of rows) { const bits = [ r.price_eur != null && `€${r.price_eur}${r.offer === 'rent' ? '/m' : ''}`, r.area_m2 != null && `${r.area_m2} m²`, r.rooms != null && `${r.rooms} rooms`, r.bedrooms != null && `${r.bedrooms} bed`, r.status, r.removed_at && 'GONE' ].filter(Boolean); console.log( `${r.action ? `${r.action} ` : ''}#${r.id} [${r.source}] ${r.title ?? '(no title)'}, ${r.city ?? '?'} · ${bits.join(' · ')}\n ${r.url}` ); } } try { if (command === 'add') await add(); else if (command === 'update') await update(); else if (command === 'list' || command === 'ls') await list(); else if (command === 'remove' || command === 'rm') await remove(Boolean(flag('hard'))); else if (command === 'restore') await restore(); else { console.error(usage); process.exitCode = 1; } } catch (error) { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; } finally { await sql.end(); }