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:
463
packages/api/src/routes/hometitle.routes.ts
Normal file
463
packages/api/src/routes/hometitle.routes.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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' }
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user