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
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:
328
apps/server/src/routes/notificationPreferences.ts
Normal file
328
apps/server/src/routes/notificationPreferences.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user