- Add hometitle API routes: properties CRUD, changes, alerts, scan - Implement Premium tier gating with 402 responses for non-Premium users - Enforce max 5 properties per Premium subscription (0 for Free/Basic, 3 for Plus) - Build DashboardPage with PropertyCard, AddPropertyForm, AlertsList components - Add dashboard CSS styles with responsive design - Register hometitle routes under /hometitle prefix with auth middleware Co-Authored-By: Paperclip <noreply@paperclip.ing>
464 lines
14 KiB
TypeScript
464 lines
14 KiB
TypeScript
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<string, number> = {
|
|
free: 0,
|
|
basic: 0,
|
|
plus: 3,
|
|
premium: 5,
|
|
};
|
|
|
|
const HOMETITLE_TIER_ORDER: Record<string, number> = {
|
|
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<string | null> {
|
|
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<boolean> {
|
|
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<string, typeof allRecentAlerts>();
|
|
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<string, unknown> = {
|
|
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 });
|
|
}
|
|
});
|
|
}
|