import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { prisma, SubscriptionTier } from '@shieldai/db'; import { detectChanges, shouldTriggerAlert } from '@shieldai/hometitle'; import { homeTitleAlertPipeline } from '@shieldai/hometitle'; import { AuthRequest } from '../auth.middleware'; const HOMETITLE_PROPERTY_LIMITS: Record = { free: 0, basic: 0, plus: 3, premium: 5, }; const HOMETITLE_TIER_ORDER: Record = { free: 0, basic: 1, plus: 2, premium: 3, }; /** * Middleware: require Premium tier for home title features. * Returns subscriptionId if user is Premium, otherwise replies with 402. */ async function requirePremiumTier( request: FastifyRequest, reply: FastifyReply, ): Promise { const authReq = request as AuthRequest; const userId = authReq.user?.id; if (!userId) { await reply.code(401).send({ error: 'User not authenticated' }); return null; } const subscription = await prisma.subscription.findFirst({ where: { userId, status: 'active' }, select: { id: true, tier: true }, }); if (!subscription) { await reply.code(402).send({ error: 'Subscription required', message: 'A home title monitoring subscription is required', }); return null; } if (subscription.tier !== 'premium') { const currentTier = HOMETITLE_TIER_ORDER[subscription.tier] ?? 0; const nextTier = 'plus'; await reply.code(402).send({ error: 'Premium tier required', message: `Home title monitoring requires a Premium subscription. You are currently on ${subscription.tier}.`, currentTier: subscription.tier, upgradeTo: nextTier, }); return null; } return subscription.id; } /** * Check property limit for the user's tier. * Returns true if under limit, replies 400 if exceeded. */ async function checkPropertyLimit( reply: FastifyReply, subscriptionId: string, ): Promise { const subscription = await prisma.subscription.findUnique({ where: { id: subscriptionId }, select: { tier: true }, }); if (!subscription) { return false; } const limit = HOMETITLE_PROPERTY_LIMITS[subscription.tier] ?? 0; const count = await prisma.watchlistItem.count({ where: { subscriptionId, type: 'address' }, }); if (count >= limit) { await reply.code(400).send({ error: 'Property limit reached', message: `You have reached the maximum of ${limit} properties for your ${subscription.tier} tier.`, currentCount: count, limit, upgradeTo: 'premium', }); return false; } return true; } export async function hometitleRoutes(fastify: FastifyInstance) { // GET /hometitle/properties - List monitored properties with status fastify.get('/properties', async (request: FastifyRequest, reply: FastifyReply) => { const subscriptionId = await requirePremiumTier(request, reply); if (!subscriptionId) return; try { const watchlistItems = await prisma.watchlistItem.findMany({ where: { subscriptionId, type: 'address', isActive: true }, orderBy: { createdAt: 'desc' }, }); const itemIds = watchlistItems.map((item) => item.id); // Batch query: fetch all recent alerts in one query const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); const allRecentAlerts = await prisma.alert.findMany({ where: { subscriptionId, watchlistItemId: { in: itemIds }, createdAt: { gte: sevenDaysAgo }, }, orderBy: { createdAt: 'desc' }, }); // Group alerts by watchlistItemId const alertsByItem = new Map(); for (const alert of allRecentAlerts) { const arr = alertsByItem.get(alert.watchlistItemId) || []; arr.push(alert); alertsByItem.set(alert.watchlistItemId, arr); } const properties = watchlistItems.map((item) => { const recentAlerts = (alertsByItem.get(item.id) || []).slice(0, 3); const hasRecentAlerts = recentAlerts.length > 0; const latestAlert = recentAlerts[0]; let status: 'monitored' | 'alert' | 'error' = 'monitored'; if (hasRecentAlerts && latestAlert) { status = latestAlert.severity === 'CRITICAL' ? 'alert' : 'monitored'; } return { id: item.id, address: item.value, status, lastScan: null, lastChange: latestAlert ? latestAlert.createdAt : null, alertCount: recentAlerts.length, addedAt: item.createdAt, }; }); return reply.send({ properties }); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to list properties'; return reply.code(500).send({ error: message }); } }); // GET /hometitle/properties/stats - Dashboard widget stats (available to all active subscriptions) fastify.get('/properties/stats', async (request: FastifyRequest, reply: FastifyReply) => { const authReq = request as AuthRequest; const userId = authReq.user?.id; if (!userId) { return reply.code(401).send({ error: 'User not authenticated' }); } const subscription = await prisma.subscription.findFirst({ where: { userId, status: 'active' }, select: { id: true, tier: true }, }); if (!subscription) { return reply.code(404).send({ error: 'Active subscription not found' }); } try { const propertyCount = await prisma.watchlistItem.count({ where: { subscriptionId: subscription.id, type: 'address', isActive: true }, }); const limit = HOMETITLE_PROPERTY_LIMITS[subscription.tier] ?? 0; const isPremium = subscription.tier === 'premium'; // Count recent alerts (last 7 days) const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); const recentAlertCount = await prisma.alert.count({ where: { subscriptionId: subscription.id, createdAt: { gte: sevenDaysAgo }, }, }); // Count critical alerts const criticalAlertCount = await prisma.alert.count({ where: { subscriptionId: subscription.id, severity: 'CRITICAL', createdAt: { gte: sevenDaysAgo }, }, }); return reply.send({ monitoredProperties: propertyCount, tier: subscription.tier, isPremium, propertyLimit: limit, canAddMore: propertyCount < limit, recentAlertCount, criticalAlertCount, }); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to fetch stats'; return reply.code(500).send({ error: message }); } }); // POST /hometitle/properties - Add property to monitor fastify.post('/properties', async (request: FastifyRequest, reply: FastifyReply) => { const subscriptionId = await requirePremiumTier(request, reply); if (!subscriptionId) return; const body = request.body as { address: string }; if (!body.address || typeof body.address !== 'string') { return reply.code(400).send({ error: 'Invalid request', message: 'Address is required', }); } // Validate address format (permissive: requires number + space + text) const addressPattern = /^\d+[\s,].+$/i; if (!addressPattern.test(body.address.trim())) { return reply.code(400).send({ error: 'Invalid address', message: 'Please enter a valid street address (e.g., 123 Main Street)', }); } const limitReached = await checkPropertyLimit(reply, subscriptionId); if (!limitReached) return; try { // Check for duplicate const existing = await prisma.watchlistItem.findFirst({ where: { subscriptionId, type: 'address', value: body.address.trim() }, }); if (existing) { return reply.code(409).send({ error: 'Duplicate property', message: 'This property is already being monitored', }); } const property = await prisma.watchlistItem.create({ data: { subscriptionId, type: 'address', value: body.address.trim(), }, }); // Trigger initial scan try { const { homeTitleScheduler } = await import('@shieldai/hometitle'); if (homeTitleScheduler.isRunning()) { await homeTitleScheduler.runScan(); } } catch (scanError) { console.error('[HomeTitle] Initial scan error:', scanError); } return reply.code(201).send({ property: { id: property.id, address: property.value, status: 'monitored', addedAt: property.createdAt, }, }); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to add property'; return reply.code(422).send({ error: message }); } }); // DELETE /hometitle/properties/:id - Remove property from monitoring fastify.delete('/properties/:id', async (request: FastifyRequest, reply: FastifyReply) => { const subscriptionId = await requirePremiumTier(request, reply); if (!subscriptionId) return; const id = (request.params as { id: string }).id; try { const result = await prisma.watchlistItem.update({ where: { id, subscriptionId }, data: { isActive: false }, }); return reply.send({ property: { id: result.id, address: result.value, status: 'removed', }, }); } catch { return reply.code(404).send({ error: 'Property not found', message: 'The monitored property does not exist or is not owned by this user', }); } }); // GET /hometitle/changes - Recent property changes fastify.get('/changes', async (request: FastifyRequest, reply: FastifyReply) => { const subscriptionId = await requirePremiumTier(request, reply); if (!subscriptionId) return; const query = request.query as { limit?: number }; const limit = Math.min(query.limit ?? 20, 50); try { const watchlistItemIds = ( await prisma.watchlistItem.findMany({ where: { subscriptionId, type: 'address', isActive: true }, select: { id: true }, }) ).map((item) => item.id); const changes = await prisma.alert.findMany({ where: { subscriptionId, watchlistItemId: { in: watchlistItemIds }, }, orderBy: { createdAt: 'desc' }, take: limit, include: { watchlistItem: true, }, }); const formattedChanges = changes.map((alert) => ({ id: alert.id, propertyAddress: alert.watchlistItem?.value ?? 'Unknown', type: 'property_change', severity: alert.severity, title: alert.title, message: alert.message, isRead: alert.isRead, createdAt: alert.createdAt, })); return reply.send({ changes: formattedChanges }); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to fetch changes'; return reply.code(500).send({ error: message }); } }); // GET /hometitle/alerts - Recent property alerts fastify.get('/alerts', async (request: FastifyRequest, reply: FastifyReply) => { const subscriptionId = await requirePremiumTier(request, reply); if (!subscriptionId) return; const query = request.query as { limit?: number; severity?: string }; const limit = Math.min(query.limit ?? 20, 50); try { const watchlistItemIds = ( await prisma.watchlistItem.findMany({ where: { subscriptionId, type: 'address', isActive: true }, select: { id: true }, }) ).map((item) => item.id); const whereClause: Record = { subscriptionId, watchlistItemId: { in: watchlistItemIds }, }; if (query.severity) { whereClause.severity = query.severity; } const alerts = await prisma.alert.findMany({ where: whereClause, orderBy: { createdAt: 'desc' }, take: limit, include: { watchlistItem: true, }, }); const formattedAlerts = alerts.map((alert) => ({ id: alert.id, propertyAddress: alert.watchlistItem?.value ?? 'Unknown', severity: alert.severity, title: alert.title, message: alert.message, isRead: alert.isRead, channel: alert.channel, createdAt: alert.createdAt, })); return reply.send({ alerts: formattedAlerts }); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to fetch alerts'; return reply.code(500).send({ error: message }); } }); // PATCH /hometitle/alerts/:id/read - Mark alert as read fastify.patch('/alerts/:id/read', async (request: FastifyRequest, reply: FastifyReply) => { const subscriptionId = await requirePremiumTier(request, reply); if (!subscriptionId) return; const id = (request.params as { id: string }).id; try { const alert = await prisma.alert.update({ where: { id, subscriptionId }, data: { isRead: true, readAt: new Date() }, }); return reply.send({ alert: { id: alert.id, isRead: alert.isRead } }); } catch { return reply.code(404).send({ error: 'Alert not found' }); } }); // POST /hometitle/scan - Trigger on-demand scan for all monitored properties fastify.post('/scan', async (request: FastifyRequest, reply: FastifyReply) => { const subscriptionId = await requirePremiumTier(request, reply); if (!subscriptionId) return; try { const { homeTitleScheduler } = await import('@shieldai/hometitle'); const result = await homeTitleScheduler.runScan(); return reply.send({ scan: { scanId: result.scanId, propertiesScanned: result.propertiesScanned, changesDetected: result.changesDetected, alertsCreated: result.alertsCreated, notificationsSent: result.notificationsSent, startedAt: result.startedAt, completedAt: result.completedAt, }, }); } catch (error) { const message = error instanceof Error ? error.message : 'Scan failed'; return reply.code(500).send({ error: message }); } }); }