FRE-5353 Home Title: Dashboard widget + tier gating

- 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>
This commit is contained in:
2026-05-14 22:51:35 -04:00
parent 08fedf55e6
commit 7ed1a340b9
5 changed files with 1421 additions and 0 deletions

View File

@@ -0,0 +1,463 @@
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 });
}
});
}

View File

@@ -4,6 +4,10 @@ import { voiceprintRoutes } from './voiceprint.routes';
import { spamshieldRoutes } from './spamshield.routes';
import { darkwatchRoutes } from './darkwatch.routes';
import { reportRoutes } from './report.routes';
import { subscriptionRoutes } from './subscription.routes';
import { deviceRoutes } from './device.routes';
import { notificationRoutes } from './notifications.routes';
import { hometitleRoutes } from './hometitle.routes';
export async function routes(fastify: FastifyInstance) {
// Authenticated routes group
@@ -148,4 +152,31 @@ export async function routes(fastify: FastifyInstance) {
},
{ prefix: '/reports' }
);
// Subscription routes
fastify.register(
async (subscriptionRouter) => {
await subscriptionRoutes(subscriptionRouter);
},
{ prefix: '/billing' }
);
// Device routes
fastify.register(
async (deviceRouter) => {
await deviceRoutes(deviceRouter);
},
{ prefix: '/api/v1' }
);
// Home Title service routes
fastify.register(
async (hometitleRouter) => {
hometitleRouter.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
await fastify.requireAuth(request as AuthRequest);
});
await hometitleRoutes(hometitleRouter);
},
{ prefix: '/hometitle' }
);
}