Initial Upload
Some checks failed
CI / Lint & Typecheck (push) Has been cancelled
CI / Test (routes) (push) Has been cancelled
CI / Test (security) (push) Has been cancelled
CI / Test (services) (push) Has been cancelled
CI / Test (unit) (push) Has been cancelled
CI / Test (integration) (push) Has been cancelled
CI / Test Coverage (push) Has been cancelled
CI / Build (push) Has been cancelled

This commit is contained in:
2025-12-17 12:32:50 +13:00
commit 3015f48118
471 changed files with 141143 additions and 0 deletions

View File

@@ -0,0 +1,328 @@
/**
* Notification Preferences routes - Per-device notification configuration
*
* Mobile device endpoints:
* - GET /notifications/preferences - Get preferences for current device
* - PATCH /notifications/preferences - Update preferences for current device
*/
import type { FastifyPluginAsync } from 'fastify';
import { eq, desc } from 'drizzle-orm';
import { z } from 'zod';
import type { NotificationPreferences, NotificationPreferencesWithStatus } from '@tracearr/shared';
import { db } from '../db/client.js';
import { mobileSessions, notificationPreferences } from '../db/schema.js';
import { getPushRateLimiter } from '../services/pushRateLimiter.js';
// Update preferences schema
const updatePreferencesSchema = z.object({
pushEnabled: z.boolean().optional(),
onViolationDetected: z.boolean().optional(),
onStreamStarted: z.boolean().optional(),
onStreamStopped: z.boolean().optional(),
onConcurrentStreams: z.boolean().optional(),
onNewDevice: z.boolean().optional(),
onTrustScoreChanged: z.boolean().optional(),
onServerDown: z.boolean().optional(),
onServerUp: z.boolean().optional(),
violationMinSeverity: z.number().int().min(1).max(3).optional(),
violationRuleTypes: z.array(z.string()).optional(),
maxPerMinute: z.number().int().min(1).max(60).optional(),
maxPerHour: z.number().int().min(1).max(1000).optional(),
quietHoursEnabled: z.boolean().optional(),
quietHoursStart: z.string().regex(/^\d{2}:\d{2}$/).optional().nullable(),
quietHoursEnd: z.string().regex(/^\d{2}:\d{2}$/).optional().nullable(),
quietHoursTimezone: z.string().max(50).optional(),
quietHoursOverrideCritical: z.boolean().optional(),
});
/**
* Find mobile session by deviceId from JWT claims
* Mobile JWTs include deviceId for targeting the correct device session
*/
async function findMobileSessionByDeviceId(deviceId: string): Promise<{ id: string } | null> {
const session = await db
.select({ id: mobileSessions.id })
.from(mobileSessions)
.where(eq(mobileSessions.deviceId, deviceId))
.limit(1);
return session[0] ?? null;
}
/**
* Find mobile session for user (fallback for legacy tokens without deviceId)
* Gets the most recently active session for the owner
*/
async function findMobileSessionForUserFallback(_userId: string): Promise<{ id: string } | null> {
// Get the most recently active session (desc ordering)
const session = await db
.select({ id: mobileSessions.id })
.from(mobileSessions)
.orderBy(desc(mobileSessions.lastSeenAt))
.limit(1);
return session[0] ?? null;
}
/**
* Transform DB row to API response
*/
function toApiResponse(row: typeof notificationPreferences.$inferSelect): NotificationPreferences {
return {
id: row.id,
mobileSessionId: row.mobileSessionId,
pushEnabled: row.pushEnabled,
onViolationDetected: row.onViolationDetected,
onStreamStarted: row.onStreamStarted,
onStreamStopped: row.onStreamStopped,
onConcurrentStreams: row.onConcurrentStreams,
onNewDevice: row.onNewDevice,
onTrustScoreChanged: row.onTrustScoreChanged,
onServerDown: row.onServerDown,
onServerUp: row.onServerUp,
violationMinSeverity: row.violationMinSeverity,
violationRuleTypes: row.violationRuleTypes ?? [],
maxPerMinute: row.maxPerMinute,
maxPerHour: row.maxPerHour,
quietHoursEnabled: row.quietHoursEnabled,
quietHoursStart: row.quietHoursStart,
quietHoursEnd: row.quietHoursEnd,
quietHoursTimezone: row.quietHoursTimezone ?? 'UTC',
quietHoursOverrideCritical: row.quietHoursOverrideCritical,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
export const notificationPreferencesRoutes: FastifyPluginAsync = async (app) => {
/**
* GET /notifications/preferences - Get preferences for current device
*
* Requires mobile authentication. Returns preferences for the device's session,
* or creates default preferences if none exist.
*/
app.get('/preferences', { preHandler: [app.requireMobile] }, async (request, reply) => {
const authUser = request.user;
// Find mobile session using deviceId (preferred) or fallback to user lookup
const mobileSession = authUser.deviceId
? await findMobileSessionByDeviceId(authUser.deviceId)
: await findMobileSessionForUserFallback(authUser.userId);
if (!mobileSession) {
return reply.notFound('No mobile session found. Please pair the device first.');
}
// Get or create preferences
let prefsRow = await db
.select()
.from(notificationPreferences)
.where(eq(notificationPreferences.mobileSessionId, mobileSession.id))
.limit(1);
if (prefsRow.length === 0) {
// Create default preferences
const inserted = await db
.insert(notificationPreferences)
.values({
mobileSessionId: mobileSession.id,
})
.returning();
prefsRow = inserted;
}
const row = prefsRow[0];
if (!row) {
return reply.internalServerError('Failed to load notification preferences');
}
// Get live rate limit status from Redis
const prefs = toApiResponse(row);
const rateLimiter = getPushRateLimiter();
if (rateLimiter) {
const status = await rateLimiter.getStatus(mobileSession.id, {
maxPerMinute: prefs.maxPerMinute,
maxPerHour: prefs.maxPerHour,
});
const response: NotificationPreferencesWithStatus = {
...prefs,
rateLimitStatus: {
remainingMinute: status.remainingMinute,
remainingHour: status.remainingHour,
resetMinuteIn: status.resetMinuteIn,
resetHourIn: status.resetHourIn,
},
};
return response;
}
return prefs;
});
/**
* PATCH /notifications/preferences - Update preferences for current device
*
* Requires mobile authentication. Updates notification preferences for the
* device's session.
*/
app.patch('/preferences', { preHandler: [app.requireMobile] }, async (request, reply) => {
const body = updatePreferencesSchema.safeParse(request.body);
if (!body.success) {
return reply.badRequest('Invalid request body');
}
const authUser = request.user;
// Find mobile session using deviceId (preferred) or fallback to user lookup
const mobileSession = authUser.deviceId
? await findMobileSessionByDeviceId(authUser.deviceId)
: await findMobileSessionForUserFallback(authUser.userId);
if (!mobileSession) {
return reply.notFound('No mobile session found. Please pair the device first.');
}
// Ensure preferences row exists
let existing = await db
.select()
.from(notificationPreferences)
.where(eq(notificationPreferences.mobileSessionId, mobileSession.id))
.limit(1);
if (existing.length === 0) {
// Create with defaults first
const inserted = await db
.insert(notificationPreferences)
.values({
mobileSessionId: mobileSession.id,
})
.returning();
existing = inserted;
}
const prefsId = existing[0]!.id;
// Build update object
const updateData: Partial<typeof notificationPreferences.$inferInsert> = {
updatedAt: new Date(),
};
if (body.data.pushEnabled !== undefined) {
updateData.pushEnabled = body.data.pushEnabled;
}
if (body.data.onViolationDetected !== undefined) {
updateData.onViolationDetected = body.data.onViolationDetected;
}
if (body.data.onStreamStarted !== undefined) {
updateData.onStreamStarted = body.data.onStreamStarted;
}
if (body.data.onStreamStopped !== undefined) {
updateData.onStreamStopped = body.data.onStreamStopped;
}
if (body.data.onConcurrentStreams !== undefined) {
updateData.onConcurrentStreams = body.data.onConcurrentStreams;
}
if (body.data.onNewDevice !== undefined) {
updateData.onNewDevice = body.data.onNewDevice;
}
if (body.data.onTrustScoreChanged !== undefined) {
updateData.onTrustScoreChanged = body.data.onTrustScoreChanged;
}
if (body.data.onServerDown !== undefined) {
updateData.onServerDown = body.data.onServerDown;
}
if (body.data.onServerUp !== undefined) {
updateData.onServerUp = body.data.onServerUp;
}
if (body.data.violationMinSeverity !== undefined) {
updateData.violationMinSeverity = body.data.violationMinSeverity;
}
if (body.data.violationRuleTypes !== undefined) {
updateData.violationRuleTypes = body.data.violationRuleTypes;
}
if (body.data.maxPerMinute !== undefined) {
updateData.maxPerMinute = body.data.maxPerMinute;
}
if (body.data.maxPerHour !== undefined) {
updateData.maxPerHour = body.data.maxPerHour;
}
if (body.data.quietHoursEnabled !== undefined) {
updateData.quietHoursEnabled = body.data.quietHoursEnabled;
}
if (body.data.quietHoursStart !== undefined) {
updateData.quietHoursStart = body.data.quietHoursStart;
}
if (body.data.quietHoursEnd !== undefined) {
updateData.quietHoursEnd = body.data.quietHoursEnd;
}
if (body.data.quietHoursTimezone !== undefined) {
updateData.quietHoursTimezone = body.data.quietHoursTimezone;
}
if (body.data.quietHoursOverrideCritical !== undefined) {
updateData.quietHoursOverrideCritical = body.data.quietHoursOverrideCritical;
}
// Update preferences
await db
.update(notificationPreferences)
.set(updateData)
.where(eq(notificationPreferences.id, prefsId));
// Return updated preferences
const updated = await db
.select()
.from(notificationPreferences)
.where(eq(notificationPreferences.id, prefsId))
.limit(1);
const row = updated[0];
if (!row) {
return reply.internalServerError('Failed to update notification preferences');
}
app.log.info(
{ userId: authUser.userId, mobileSessionId: mobileSession.id },
'Notification preferences updated'
);
return toApiResponse(row);
});
};
/**
* Get notification preferences for a specific mobile session (internal use)
*/
export async function getPreferencesForSession(
mobileSessionId: string
): Promise<typeof notificationPreferences.$inferSelect | null> {
const prefs = await db
.select()
.from(notificationPreferences)
.where(eq(notificationPreferences.mobileSessionId, mobileSessionId))
.limit(1);
return prefs[0] ?? null;
}
/**
* Get notification preferences for a push token (internal use by push service)
*/
export async function getPreferencesForPushToken(
expoPushToken: string
): Promise<typeof notificationPreferences.$inferSelect | null> {
// Find the mobile session with this push token
const session = await db
.select({ id: mobileSessions.id })
.from(mobileSessions)
.where(eq(mobileSessions.expoPushToken, expoPushToken))
.limit(1);
if (session.length === 0 || !session[0]) {
return null;
}
return getPreferencesForSession(session[0].id);
}