house tracker: contact status per listing (whose turn, last message, viewing) and labelled filters
Some checks failed
Deploy production / deploy (push) Has been cancelled
Some checks failed
Deploy production / deploy (push) Has been cancelled
This commit is contained in:
@@ -6,6 +6,8 @@ import { siteKeyFromUrl } from './link-key';
|
||||
import { listingRef, parseEuros, parseInteger } from './pararius';
|
||||
import {
|
||||
listingDetailSchema,
|
||||
contactParties,
|
||||
contactStatuses,
|
||||
listingStatuses,
|
||||
offers,
|
||||
propertyTypes,
|
||||
@@ -163,7 +165,13 @@ export const manualListingSchema = listingDetailSchema
|
||||
offeredAt: lenientDate,
|
||||
firstSeenAt: lenientDate,
|
||||
status: z.enum(listingStatuses).nullish(),
|
||||
notes: z.string().trim().max(2000).nullish()
|
||||
notes: z.string().trim().max(2000).nullish(),
|
||||
contactStatus: z.enum(contactStatuses).nullish(),
|
||||
contactAt: lenientDate,
|
||||
contactBy: z.enum(contactParties).nullish(),
|
||||
contactNote: z.string().trim().max(500).nullish(),
|
||||
contactRef: z.string().trim().max(500).nullish(),
|
||||
viewingAt: lenientDate
|
||||
});
|
||||
export type ManualListing = z.infer<typeof manualListingSchema>;
|
||||
|
||||
@@ -182,7 +190,8 @@ const fieldAliases: Record<string, string> = {
|
||||
available: 'availableFrom',
|
||||
seen: 'firstSeenAt',
|
||||
firstSeen: 'firstSeenAt',
|
||||
offered: 'offeredAt'
|
||||
offered: 'offeredAt',
|
||||
contact: 'contactStatus'
|
||||
};
|
||||
|
||||
export function normalizeKeys(input: Record<string, unknown>): Record<string, unknown> {
|
||||
@@ -242,19 +251,24 @@ export const columns = {
|
||||
offeredAt: 'offered_at',
|
||||
firstSeenAt: 'first_seen_at',
|
||||
status: 'status',
|
||||
notes: 'notes'
|
||||
notes: 'notes',
|
||||
contactStatus: 'contact_status',
|
||||
contactAt: 'contact_at',
|
||||
contactBy: 'contact_by',
|
||||
contactNote: 'contact_note',
|
||||
contactRef: 'contact_ref',
|
||||
viewingAt: 'viewing_at'
|
||||
} as const satisfies Partial<Record<keyof ManualListing, string>>;
|
||||
|
||||
const dateFields = new Set(['offeredAt', 'firstSeenAt', 'contactAt', 'viewingAt']);
|
||||
|
||||
/** Only the columns the user actually gave; `null` clears a column on update. */
|
||||
export function toColumns(listing: ManualListing): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [field, column] of Object.entries(columns)) {
|
||||
const value = listing[field as keyof ManualListing];
|
||||
if (value === undefined) continue;
|
||||
out[column] =
|
||||
value !== null && (field === 'offeredAt' || field === 'firstSeenAt')
|
||||
? new Date(value as string)
|
||||
: value;
|
||||
out[column] = value !== null && dateFields.has(field) ? new Date(value as string) : value;
|
||||
}
|
||||
if (listing.city !== undefined) out.city_slug = citySlugFromName(listing.city);
|
||||
return out;
|
||||
|
||||
@@ -61,6 +61,26 @@ export const listingStatuses = [
|
||||
] as const;
|
||||
export type ListingStatus = (typeof listingStatuses)[number];
|
||||
|
||||
// Where the conversation with the agent or landlord stands: whose turn it is.
|
||||
export const contactStatuses = [
|
||||
'waiting_on_me',
|
||||
'not_contacted',
|
||||
'waiting_on_them',
|
||||
'viewing',
|
||||
'closed'
|
||||
] as const;
|
||||
export type ContactStatus = (typeof contactStatuses)[number];
|
||||
export const contactStatusLabels: Record<ContactStatus, string> = {
|
||||
waiting_on_me: 'my turn',
|
||||
not_contacted: 'not contacted',
|
||||
waiting_on_them: 'waiting on them',
|
||||
viewing: 'viewing planned',
|
||||
closed: 'closed'
|
||||
};
|
||||
// Who sent the last message.
|
||||
export const contactParties = ['me', 'them'] as const;
|
||||
export type ContactParty = (typeof contactParties)[number];
|
||||
|
||||
export const lanes = ['fast', 'slow'] as const;
|
||||
export type Lane = (typeof lanes)[number];
|
||||
|
||||
|
||||
@@ -83,11 +83,25 @@ export const houseListings = houseSchema.table(
|
||||
lbmYear: integer('lbm_year'),
|
||||
lbmDetail: jsonb('lbm_detail').$type<Liveability>(),
|
||||
lbmQuery: text('lbm_query'),
|
||||
lbmScoredAt: timestamptz('lbm_scored_at')
|
||||
lbmScoredAt: timestamptz('lbm_scored_at'),
|
||||
// Contact with the agent/landlord (see contactStatuses in house-tracker/types.ts):
|
||||
// whose turn it is, the last message (when, from whom, a one-line summary), where the
|
||||
// conversation lives (mail thread, chat) and the viewing time once one is planned.
|
||||
contactStatus: text('contact_status'),
|
||||
contactAt: timestamptz('contact_at'),
|
||||
contactBy: text('contact_by'),
|
||||
contactNote: text('contact_note'),
|
||||
contactRef: text('contact_ref'),
|
||||
viewingAt: timestamptz('viewing_at')
|
||||
},
|
||||
(table) => [
|
||||
check('listings_offer_check', sql`offer IN ('rent', 'buy')`),
|
||||
check('listings_price_eur_check', sql`price_eur IS NULL OR price_eur >= 0`),
|
||||
check(
|
||||
'listings_contact_status_check',
|
||||
sql`contact_status IS NULL OR contact_status IN ('waiting_on_me', 'not_contacted', 'waiting_on_them', 'viewing', 'closed')`
|
||||
),
|
||||
check('listings_contact_by_check', sql`contact_by IS NULL OR contact_by IN ('me', 'them')`),
|
||||
uniqueIndex('listings_source_idx').on(table.source, table.sourceId),
|
||||
index('listings_active_idx')
|
||||
.on(table.offer, table.firstSeenAt.desc().nullsFirst())
|
||||
@@ -96,6 +110,9 @@ export const houseListings = houseSchema.table(
|
||||
index('listings_details_idx')
|
||||
.on(table.firstSeenAt.desc().nullsFirst())
|
||||
.where(sql`detail_fetched_at IS NULL`),
|
||||
index('listings_contact_idx')
|
||||
.on(table.contactStatus)
|
||||
.where(sql`contact_status IS NOT NULL`),
|
||||
index('listings_address_idx')
|
||||
.on(table.addressKey)
|
||||
.where(sql`address_key IS NOT NULL`)
|
||||
|
||||
Reference in New Issue
Block a user