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
329 lines
11 KiB
TypeScript
329 lines
11 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|