diff options
| author | JustZvan <justzvan@justzvan.xyz> | 2026-02-06 12:16:40 +0100 |
|---|---|---|
| committer | JustZvan <justzvan@justzvan.xyz> | 2026-02-06 12:16:40 +0100 |
| commit | e904e9634548e47d611bdcbb88d7b180b927fd5f (patch) | |
| tree | 21aa5be08fc5b22585508c0263ee5ea4effcc593 /src/routes | |
feat: initial commit!
Diffstat (limited to 'src/routes')
| -rw-r--r-- | src/routes/kid.ts | 107 | ||||
| -rw-r--r-- | src/routes/parent.ts | 1041 | ||||
| -rw-r--r-- | src/routes/signin.ts | 175 | ||||
| -rw-r--r-- | src/routes/signup.ts | 100 |
4 files changed, 1423 insertions, 0 deletions
diff --git a/src/routes/kid.ts b/src/routes/kid.ts new file mode 100644 index 0000000..a326d5b --- /dev/null +++ b/src/routes/kid.ts @@ -0,0 +1,107 @@ +import express from "express"; +import { authDevice } from "../middleware/auth"; +import { db } from "../db/db"; +import { deviceConfig } from "../db/schema"; +import { eq } from "drizzle-orm"; +import { logger } from "../lib/pino"; +import { z } from "zod"; + +/** Schema for validating device IDs from authenticated requests */ +const DeviceIdSchema = z + .number() + .int() + .positive("Device ID must be a positive integer"); + +const router: express.Router = express.Router(); + +router.get("/kid/getconfig", authDevice, async (req, res) => { + const deviceId = req.user!.id; + + const parsed = DeviceIdSchema.safeParse(deviceId); + if (!parsed.success) { + logger.error( + { deviceId, error: parsed.error }, + "Invalid device ID in getconfig request", + ); + res.status(400).json({ + success: false, + reason: parsed.error.issues[0]?.message || "Invalid device ID", + }); + return; + } + + try { + let config; + try { + config = await db + .select() + .from(deviceConfig) + .where(eq(deviceConfig.deviceId, deviceId)) + .limit(1); + } catch (dbError) { + logger.error( + { error: dbError, deviceId }, + "Database error fetching device config", + ); + throw dbError; + } + + if (config.length === 0) { + try { + const newConfig = await db + .insert(deviceConfig) + .values({ deviceId }) + .returning(); + config = newConfig; + logger.info({ deviceId }, "Created default device config"); + } catch (insertError) { + logger.error( + { error: insertError, deviceId }, + "Failed to create default device config", + ); + throw insertError; + } + } + + const cfg = config[0]; + if (!cfg) { + logger.error( + { deviceId }, + "Config is unexpectedly undefined after creation", + ); + res.status(500).json({ + success: false, + reason: "Failed to get device configuration", + }); + return; + } + + logger.debug( + { + deviceId, + config: { + disableBuddy: cfg.disableBuddy, + blockAdultSites: cfg.blockAdultSites, + }, + }, + "Device config retrieved successfully", + ); + + res.json({ + success: true, + config: { + disableBuddy: cfg.disableBuddy, + blockAdultSites: cfg.blockAdultSites, + familyLinkAntiCircumvention: cfg.familyLinkAntiCircumvention, + }, + }); + } catch (e) { + logger.error({ error: e, deviceId }, "Failed to get device config"); + res.status(500).json({ + success: false, + reason: "Failed to get device configuration", + }); + } +}); + +export default router; diff --git a/src/routes/parent.ts b/src/routes/parent.ts new file mode 100644 index 0000000..db7206d --- /dev/null +++ b/src/routes/parent.ts @@ -0,0 +1,1041 @@ +import express from "express"; +import { authParent } from "../middleware/auth"; +import { db } from "../db/db"; +import { deviceConfig, linkedDevices, users, alerts } from "../db/schema"; +import { eq, and, desc } from "drizzle-orm"; +import { isValidPushToken } from "../notifications/push"; +import { logger } from "../lib/pino"; +import { z } from "zod"; + +/** Validates email verification code from user input */ +const VerifyEmailSchema = z.object({ + code: z.string().min(1, "Verification code cannot be empty"), +}); + +/** Validates device ID from URL parameters */ +const DeviceIdParamSchema = z.object({ + deviceId: z.string().regex(/^\d+$/, "Device ID must be numeric"), +}); + +/** Validates control settings updates with allowed keys and boolean values */ +const ControlsUpdateSchema = z.object({ + key: z.enum([ + "disable_buddy", + "adult_sites", + "new_people", + "block_strangers", + "notify_dangerous_messages", + "notify_new_contact_added", + "family_link_anti_circumvention", + ]), + value: z.boolean(), +}); + +/** Validates device nickname changes */ +const DeviceRenameSchema = z.object({ + name: z.string().min(1, "Name cannot be empty").max(255, "Name too long"), +}); + +/** Validates push notification token format */ +const PushTokenSchema = z.object({ + token: z.string().min(1, "Token cannot be empty"), +}); + +function createParentRouter( + onlineDevices: Map<number, { connectedAt: number }>, +) { + const router: express.Router = express.Router(); + + /** + * Converts a Unix timestamp to a human-readable relative time string. + * Returns things like "Just now", "5m ago", "2d ago", etc. + */ + const formatLastOnline = (timestamp: number | null | undefined): string => { + if (!timestamp) return "Never"; + const lastOnlineDate = new Date(timestamp * 1000); + const now = new Date(); + const diffMs = now.getTime() - lastOnlineDate.getTime(); + const diffSecs = Math.floor(diffMs / 1000); + const diffMins = Math.floor(diffSecs / 60); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffSecs < 60) return "Just now"; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return lastOnlineDate.toLocaleDateString(); + }; + + router.post("/parent/verifyemail", authParent, async (req, res) => { + const parentId = req.user!.id; + + const parsed = VerifyEmailSchema.safeParse(req.body); + if (!parsed.success) { + logger.warn( + { parentId, error: parsed.error }, + "Invalid verification code in request", + ); + return res.status(400).json({ + success: false, + reason: parsed.error.issues[0]?.message || "Invalid verification code", + }); + } + + const { code } = parsed.data; + + try { + const user = await db + .select() + .from(users) + .where(eq(users.id, parentId)) + .limit(1); + + if (user.length === 0) { + logger.warn({ parentId }, "User not found for email verification"); + return res + .status(404) + .json({ success: false, reason: "User not found" }); + } + + const storedCode = user[0]!.emailCode; + if (!storedCode) { + logger.warn({ parentId }, "No verification code set for user"); + return res + .status(400) + .json({ success: false, reason: "No verification code set" }); + } + + if (storedCode !== code) { + logger.warn({ parentId }, "Incorrect email verification code"); + return res + .status(400) + .json({ success: false, reason: "Incorrect verification code" }); + } + + try { + await db + .update(users) + .set({ emailVerified: true }) + .where(eq(users.id, parentId)); + logger.info({ parentId }, "Email verified successfully"); + return res.json({ success: true }); + } catch (updateError) { + logger.error( + { error: updateError, parentId }, + "Database error updating email verification", + ); + throw updateError; + } + } catch (e) { + logger.error({ error: e, parentId }, "Failed to verify email"); + return res + .status(500) + .json({ success: false, reason: "Failed to verify email" }); + } + }); + + router.get("/parent/profile", authParent, async (req, res) => { + const parentId = req.user!.id; + + try { + const user = await db + .select({ + email: users.email, + emailVerified: users.emailVerified, + }) + .from(users) + .where(eq(users.id, parentId)) + .limit(1); + + if (user.length === 0) { + logger.warn({ parentId }, "User not found for profile request"); + return res + .status(404) + .json({ success: false, reason: "User not found" }); + } + + logger.debug({ parentId }, "Profile retrieved successfully"); + return res.json({ + success: true, + profile: { + email: user[0]!.email, + emailVerified: user[0]!.emailVerified ?? false, + }, + }); + } catch (e) { + logger.error({ error: e, parentId }, "Failed to get profile"); + return res + .status(500) + .json({ success: false, reason: "Failed to get profile" }); + } + }); + + router.get("/parent/devices", authParent, async (req, res) => { + const parentId = req.user!.id; + + if (!parentId || typeof parentId !== "number") { + logger.error({ parentId }, "Invalid parent ID in devices request"); + res.status(400).json({ + success: false, + reason: "Invalid parent ID", + }); + return; + } + + try { + const devices = await db + .select() + .from(linkedDevices) + .where(eq(linkedDevices.parentId, parentId)); + + logger.debug( + { parentId, deviceCount: devices.length }, + "Retrieved parent devices", + ); + + res.json({ + success: true, + devices: devices.map((d) => ({ + id: d.id.toString(), + name: d.nickname, + status: onlineDevices.has(d.id) ? "online" : "offline", + lastCheck: formatLastOnline(d.lastOnline), + })), + }); + } catch (e) { + logger.error({ error: e, parentId }, "Failed to get devices"); + res.status(500).json({ + success: false, + reason: "Failed to get devices", + }); + } + }); + + router.get("/parent/controls/:deviceId", authParent, async (req, res) => { + const parentId = req.user!.id; + + const paramsParsed = DeviceIdParamSchema.safeParse(req.params); + if (!paramsParsed.success) { + logger.warn( + { deviceId: req.params.deviceId, parentId, error: paramsParsed.error }, + "Invalid device ID in controls request", + ); + res.status(400).json({ + success: false, + reason: "Invalid device ID", + }); + return; + } + + const deviceId = parseInt(paramsParsed.data.deviceId); + + try { + // Verify the device belongs to this parent + let device; + try { + device = await db + .select() + .from(linkedDevices) + .where( + and( + eq(linkedDevices.id, deviceId), + eq(linkedDevices.parentId, parentId), + ), + ) + .limit(1); + } catch (dbError) { + logger.error( + { error: dbError, deviceId, parentId }, + "Database error verifying device ownership", + ); + throw dbError; + } + + if (device.length === 0) { + logger.warn( + { deviceId, parentId }, + "Device not found or does not belong to parent", + ); + res.status(404).json({ + success: false, + reason: "Device not found", + }); + return; + } + + // Get or create config for this device + let config; + try { + config = await db + .select() + .from(deviceConfig) + .where(eq(deviceConfig.deviceId, deviceId)) + .limit(1); + } catch (dbError) { + logger.error( + { error: dbError, deviceId }, + "Database error fetching device config", + ); + throw dbError; + } + + if (config.length === 0) { + // Create default config for new device + try { + const newConfig = await db + .insert(deviceConfig) + .values({ deviceId }) + .returning(); + config = newConfig; + logger.info({ deviceId }, "Created default config for device"); + } catch (insertError) { + logger.error( + { error: insertError, deviceId }, + "Failed to create default device config", + ); + throw insertError; + } + } + + const cfg = config[0]; + if (!cfg) { + logger.error({ deviceId }, "Config is unexpectedly undefined"); + res.status(500).json({ + success: false, + reason: "Failed to get controls", + }); + return; + } + + logger.debug( + { deviceId, parentId }, + "Device controls retrieved successfully", + ); + + res.json({ + success: true, + safetyControls: [ + { + key: "disable_buddy", + title: "Disable Buddy", + description: "Temporarily disable Buddy", + defaultValue: cfg.disableBuddy, + }, + { + key: "adult_sites", + title: "Adult sites", + description: "Block adult websites.", + defaultValue: cfg.blockAdultSites, + }, + { + key: "family_link_anti_circumvention", + title: "Anti-Circumvention", + description: "Prevent disabling of Family Link protections.", + defaultValue: cfg.familyLinkAntiCircumvention, + }, + { + key: "new_people", + title: "New contact alerts", + description: "Get notified when your child chats with someone new.", + defaultValue: cfg.newContactAlerts, + }, + { + key: "block_strangers", + title: "Block communications with strangers", + description: "Block or scan communications with strangers.", + defaultValue: cfg.blockStrangers, + }, + { + key: "notify_dangerous_messages", + title: "Dangerous messages notifications", + description: "Notify when messages are potentially dangerous.", + defaultValue: cfg.notifyDangerousMessages, + }, + { + key: "notify_new_contact_added", + title: "New contact added notifications", + description: "Notify when a new contact is added.", + defaultValue: cfg.notifyNewContactAdded, + }, + ], + }); + } catch (e) { + logger.error({ error: e, deviceId, parentId }, "Failed to get controls"); + res.status(500).json({ + success: false, + reason: "Failed to get controls", + }); + } + }); + + // Update a safety control for a specific device + router.post("/parent/controls/:deviceId", authParent, async (req, res) => { + const parentId = req.user!.id; + + const paramsParsed = DeviceIdParamSchema.safeParse(req.params); + if (!paramsParsed.success) { + logger.warn( + { deviceId: req.params.deviceId, parentId, error: paramsParsed.error }, + "Invalid device ID in controls update", + ); + res.status(400).json({ + success: false, + reason: "Invalid device ID", + }); + return; + } + + const bodyParsed = ControlsUpdateSchema.safeParse(req.body); + if (!bodyParsed.success) { + logger.warn( + { body: req.body, parentId, error: bodyParsed.error }, + "Invalid request body for controls update", + ); + res.status(400).json({ + success: false, + reason: bodyParsed.error.issues[0]?.message || "Invalid request body", + }); + return; + } + + const deviceId = parseInt(paramsParsed.data.deviceId); + const { key, value } = bodyParsed.data; + + // Map frontend keys to database columns + const keyMap: Record<string, keyof typeof deviceConfig.$inferSelect> = { + disable_buddy: "disableBuddy", + adult_sites: "blockAdultSites", + new_people: "newContactAlerts", + block_strangers: "blockStrangers", + notify_dangerous_messages: "notifyDangerousMessages", + notify_new_contact_added: "notifyNewContactAdded", + family_link_anti_circumvention: "familyLinkAntiCircumvention", + }; + + const dbKey = keyMap[key]; + if (!dbKey) { + logger.warn({ key, deviceId, parentId }, "Unknown control key"); + res.status(400).json({ + success: false, + reason: "Unknown control key", + }); + return; + } + + try { + // Verify the device belongs to this parent + let device; + try { + device = await db + .select() + .from(linkedDevices) + .where( + and( + eq(linkedDevices.id, deviceId), + eq(linkedDevices.parentId, parentId), + ), + ) + .limit(1); + } catch (dbError) { + logger.error( + { error: dbError, deviceId, parentId }, + "Database error verifying device ownership for control update", + ); + throw dbError; + } + + if (device.length === 0) { + logger.warn( + { deviceId, parentId }, + "Device not found for control update", + ); + res.status(404).json({ + success: false, + reason: "Device not found", + }); + return; + } + + // Ensure config exists + let existingConfig; + try { + existingConfig = await db + .select() + .from(deviceConfig) + .where(eq(deviceConfig.deviceId, deviceId)) + .limit(1); + } catch (dbError) { + logger.error( + { error: dbError, deviceId }, + "Database error fetching config for update", + ); + throw dbError; + } + + if (existingConfig.length === 0) { + try { + await db.insert(deviceConfig).values({ deviceId }); + logger.info( + { deviceId }, + "Created default config for control update", + ); + } catch (insertError) { + logger.error( + { error: insertError, deviceId }, + "Failed to create config for control update", + ); + throw insertError; + } + } + + // Update the specific field + try { + await db + .update(deviceConfig) + .set({ [dbKey]: value }) + .where(eq(deviceConfig.deviceId, deviceId)); + logger.info( + { deviceId, key, value, dbKey }, + "Device control updated successfully", + ); + } catch (updateError) { + logger.error( + { error: updateError, deviceId, key, value }, + "Database error updating control", + ); + throw updateError; + } + + res.json({ + success: true, + }); + } catch (e) { + logger.error( + { error: e, deviceId, parentId, key }, + "Failed to update control", + ); + res.status(500).json({ + success: false, + reason: "Failed to update control", + }); + } + }); + + // Rename a device + router.post( + "/parent/device/:deviceId/rename", + authParent, + async (req, res) => { + const parentId = req.user!.id; + + const paramsParsed = DeviceIdParamSchema.safeParse(req.params); + if (!paramsParsed.success) { + logger.warn( + { + deviceId: req.params.deviceId, + parentId, + error: paramsParsed.error, + }, + "Invalid device ID in rename request", + ); + return res + .status(400) + .json({ success: false, reason: "Invalid device ID" }); + } + + const bodyParsed = DeviceRenameSchema.safeParse(req.body); + if (!bodyParsed.success) { + logger.warn( + { body: req.body, parentId, error: bodyParsed.error }, + "Invalid name in rename request", + ); + return res.status(400).json({ + success: false, + reason: bodyParsed.error.issues[0]?.message || "Invalid name", + }); + } + + const deviceId = parseInt(paramsParsed.data.deviceId); + const { name } = bodyParsed.data; + + try { + // Verify the device belongs to this parent + let device; + try { + device = await db + .select() + .from(linkedDevices) + .where( + and( + eq(linkedDevices.id, deviceId), + eq(linkedDevices.parentId, parentId), + ), + ) + .limit(1); + } catch (dbError) { + logger.error( + { error: dbError, deviceId, parentId }, + "Database error verifying device ownership for rename", + ); + throw dbError; + } + + if (device.length === 0) { + logger.warn({ deviceId, parentId }, "Device not found for rename"); + return res + .status(404) + .json({ success: false, reason: "Device not found" }); + } + + // Update the device name + try { + await db + .update(linkedDevices) + .set({ nickname: name }) + .where(eq(linkedDevices.id, deviceId)); + logger.info( + { deviceId, oldName: device[0]!.nickname, newName: name }, + "Device renamed successfully", + ); + } catch (updateError) { + logger.error( + { error: updateError, deviceId, name }, + "Database error renaming device", + ); + throw updateError; + } + + res.json({ success: true }); + } catch (e) { + logger.error( + { error: e, deviceId, parentId }, + "Failed to rename device", + ); + res + .status(500) + .json({ success: false, reason: "Failed to rename device" }); + } + }, + ); + + // Get home dashboard data + router.get("/parent/home", authParent, async (req, res) => { + const parentId = req.user!.id; + + try { + // Get linked devices count + let devices; + try { + devices = await db + .select() + .from(linkedDevices) + .where(eq(linkedDevices.parentId, parentId)); + } catch (dbError) { + logger.error( + { error: dbError, parentId }, + "Database error fetching devices for home dashboard", + ); + throw dbError; + } + + // Check if any device is online + const anyDeviceOnline = devices.some((d) => onlineDevices.has(d.id)); + + logger.debug( + { parentId, deviceCount: devices.length, anyDeviceOnline }, + "Home dashboard data retrieved", + ); + + // TODO: Add alerts table and query real alert stats + res.json({ + success: true, + overallStatus: "all_clear", + deviceOnline: anyDeviceOnline, + alertStats: { + last24Hours: 0, + thisWeekReviewed: 0, + }, + }); + } catch (e) { + logger.error({ error: e, parentId }, "Failed to get home data"); + res.status(500).json({ + success: false, + reason: "Failed to get home data", + }); + } + }); + + // Get home dashboard data for a specific device + router.get("/parent/home/:deviceId", authParent, async (req, res) => { + const parentId = req.user!.id; + + const paramsParsed = DeviceIdParamSchema.safeParse(req.params); + if (!paramsParsed.success) { + logger.warn( + { deviceId: req.params.deviceId, parentId, error: paramsParsed.error }, + "Invalid device ID in home request", + ); + res.status(400).json({ + success: false, + reason: "Invalid device ID", + }); + return; + } + + const deviceId = parseInt(paramsParsed.data.deviceId); + + try { + // Verify the device belongs to this parent + let device; + try { + device = await db + .select() + .from(linkedDevices) + .where( + and( + eq(linkedDevices.id, deviceId), + eq(linkedDevices.parentId, parentId), + ), + ) + .limit(1); + } catch (dbError) { + logger.error( + { error: dbError, deviceId, parentId }, + "Database error fetching device for home data", + ); + throw dbError; + } + + if (device.length === 0) { + logger.warn({ deviceId, parentId }, "Device not found for home data"); + res.status(404).json({ + success: false, + reason: "Device not found", + }); + return; + } + + // Check if this device is online using in-memory tracking + const isDeviceOnline = onlineDevices.has(deviceId); + + logger.debug( + { deviceId, parentId, isDeviceOnline }, + "Device home data retrieved", + ); + + // TODO: Add alerts table and query real alert stats for this device + res.json({ + success: true, + overallStatus: "all_clear", + deviceOnline: isDeviceOnline, + alertStats: { + last24Hours: 0, + thisWeekReviewed: 0, + }, + }); + } catch (e) { + logger.error( + { error: e, deviceId, parentId }, + "Failed to get device home data", + ); + res.status(500).json({ + success: false, + reason: "Failed to get home data", + }); + } + }); + + // Get activity data + router.get("/parent/activity", authParent, async (req, res) => { + // TODO: Implement real activity tracking + res.json({ + success: true, + period: "Last 7 days", + metrics: [ + { + id: "messaging", + icon: "chatbubbles", + title: "Messaging activity", + description: "About the same as usual", + level: "Normal", + }, + { + id: "new_people", + icon: "people", + title: "New people", + description: "No new contacts", + level: "Low", + }, + { + id: "late_night", + icon: "time", + title: "Late-night use", + description: "No late night activity", + level: "Normal", + }, + ], + }); + }); + + // Register push notification token + router.post("/parent/push-token", authParent, async (req, res) => { + const parentId = req.user!.id; + + const parsed = PushTokenSchema.safeParse(req.body); + if (!parsed.success) { + logger.warn( + { parentId, error: parsed.error }, + "Invalid push token in registration request", + ); + res.status(400).json({ + success: false, + reason: parsed.error.issues[0]?.message || "Invalid push token", + }); + return; + } + + const { token } = parsed.data; + + // Validate Expo push token format + if (!isValidPushToken(token)) { + logger.warn({ parentId, token }, "Invalid Expo push token format"); + res.status(400).json({ + success: false, + reason: "Invalid Expo push token format", + }); + return; + } + + try { + // Get current tokens + let user; + try { + user = await db + .select({ pushTokens: users.pushTokens }) + .from(users) + .where(eq(users.id, parentId)) + .limit(1); + } catch (dbError) { + logger.error( + { error: dbError, parentId }, + "Database error fetching user for push token", + ); + throw dbError; + } + + if (user.length === 0) { + logger.error( + { parentId }, + "User not found for push token registration", + ); + res.status(404).json({ + success: false, + reason: "User not found", + }); + return; + } + + const currentTokens = user[0]!.pushTokens || []; + + // Only add if not already present + if (!currentTokens.includes(token)) { + const updatedTokens = [...currentTokens, token]; + try { + await db + .update(users) + .set({ pushTokens: updatedTokens }) + .where(eq(users.id, parentId)); + logger.info( + { parentId, tokenCount: updatedTokens.length }, + "Push token registered successfully", + ); + } catch (updateError) { + logger.error( + { error: updateError, parentId }, + "Database error updating push tokens", + ); + throw updateError; + } + } else { + logger.debug({ parentId }, "Push token already registered"); + } + + res.json({ success: true }); + } catch (e) { + logger.error({ error: e, parentId }, "Failed to save push token"); + res.status(500).json({ + success: false, + reason: "Failed to save push token", + }); + } + }); + + // Remove push notification token + router.delete("/parent/push-token", authParent, async (req, res) => { + const parentId = req.user!.id; + + const parsed = PushTokenSchema.safeParse(req.body); + if (!parsed.success) { + logger.warn( + { parentId, error: parsed.error }, + "Invalid push token in removal request", + ); + res.status(400).json({ + success: false, + reason: parsed.error.issues[0]?.message || "Invalid push token", + }); + return; + } + + const { token } = parsed.data; + + try { + // Get current tokens + let user; + try { + user = await db + .select({ pushTokens: users.pushTokens }) + .from(users) + .where(eq(users.id, parentId)) + .limit(1); + } catch (dbError) { + logger.error( + { error: dbError, parentId }, + "Database error fetching user for push token removal", + ); + throw dbError; + } + + if (user.length === 0) { + logger.error({ parentId }, "User not found for push token removal"); + res.status(404).json({ + success: false, + reason: "User not found", + }); + return; + } + + const currentTokens = user[0]!.pushTokens || []; + const updatedTokens = currentTokens.filter((t) => t !== token); + + try { + await db + .update(users) + .set({ pushTokens: updatedTokens }) + .where(eq(users.id, parentId)); + logger.info( + { + parentId, + removedToken: currentTokens.includes(token), + tokenCount: updatedTokens.length, + }, + "Push token removal processed", + ); + } catch (updateError) { + logger.error( + { error: updateError, parentId }, + "Database error removing push token", + ); + throw updateError; + } + + res.json({ success: true }); + } catch (e) { + logger.error({ error: e, parentId }, "Failed to remove push token"); + res.status(500).json({ + success: false, + reason: "Failed to remove push token", + }); + } + }); + + // Get alerts for the parent + router.get("/parent/alerts", authParent, async (req, res) => { + const parentId = req.user!.id; + + try { + let parentAlerts; + try { + parentAlerts = await db + .select({ + id: alerts.id, + deviceId: alerts.deviceId, + deviceName: linkedDevices.nickname, + category: alerts.category, + title: alerts.title, + message: alerts.message, + summary: alerts.summary, + confidence: alerts.confidence, + packageName: alerts.packageName, + timestamp: alerts.timestamp, + read: alerts.read, + }) + .from(alerts) + .innerJoin(linkedDevices, eq(alerts.deviceId, linkedDevices.id)) + .where(eq(alerts.parentId, parentId)) + .orderBy(desc(alerts.timestamp)); + } catch (dbError) { + logger.error( + { error: dbError, parentId }, + "Database error fetching alerts", + ); + throw dbError; + } + + const formatTimeLabel = (timestamp: number): string => { + const date = new Date(timestamp * 1000); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 1000 / 60); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 1) return "Just now"; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString(); + }; + + const formattedAlerts = parentAlerts.map((alert) => ({ + id: alert.id.toString(), + title: alert.title, + timeLabel: formatTimeLabel(alert.timestamp), + whatHappened: `${alert.packageName || "An app"} on ${ + alert.deviceName + } received: "${alert.message}"`, + whyItMatters: alert.summary, + suggestedAction: + alert.category === "sexual_predator" + ? "This requires immediate attention. Consider reviewing the device's activity and having a conversation with your child about online safety." + : alert.category === "grooming" + ? "Review this message carefully and discuss online safety with your child. Consider limiting contact with unknown individuals." + : "Monitor this activity and discuss appropriate online behavior with your child.", + severity: (alert.confidence >= 80 ? "needs_attention" : "gentle") as + | "needs_attention" + | "gentle", + })); + + logger.debug( + { parentId, alertCount: formattedAlerts.length }, + "Alerts retrieved successfully", + ); + + res.json({ + success: true, + alerts: formattedAlerts, + }); + } catch (e) { + logger.error({ error: e, parentId }, "Failed to fetch alerts"); + res.status(500).json({ + success: false, + reason: "Failed to fetch alerts", + }); + } + }); + + return router; +} + +export default createParentRouter; diff --git a/src/routes/signin.ts b/src/routes/signin.ts new file mode 100644 index 0000000..7c17432 --- /dev/null +++ b/src/routes/signin.ts @@ -0,0 +1,175 @@ +import argon2 from "argon2"; +import express from "express"; +import { users, linkedDevices } from "../db/schema"; +import { db } from "../db/db"; +import { Infer, z } from "zod"; +import { signJwt } from "../account/jwt"; +import { eq } from "drizzle-orm"; +import { logger } from "../lib/pino"; + +/** Validates signin request body with email and password */ +const SigninBodySchema = z.object({ + email: z + .string() + .transform((val) => val.trim()) + .pipe( + z + .email({ error: "Invalid email" }) + .nonempty({ error: "Email can't be empty" }), + ), + password: z.string(), +}); + +const router: express.Router = express.Router(); + +router.post("/signin", async (req, res) => { + const body: Infer<typeof SigninBodySchema> = req.body; + logger.info({ email: body.email }, "Signin attempt initiated"); + + const parsed = SigninBodySchema.safeParse(body); + + if (!parsed.success) { + logger.warn( + { email: body.email, error: parsed.error }, + "Signin validation failed", + ); + res.send({ + success: false, + reason: parsed.error, + token: "", + }); + return; + } + + const existingUser = ( + await db.select().from(users).where(eq(users.email, body.email)).limit(1) + )[0]; + + if (!existingUser) { + logger.warn({ email: body.email }, "Signin failed: user not found"); + res.send({ + success: false, + reason: "Invalid email or password", + }); + return; + } + + const validPassword = await argon2.verify( + existingUser.password, + body.password, + ); + + if (!validPassword) { + logger.warn( + { email: body.email, userId: existingUser.id }, + "Signin failed: invalid password", + ); + res.send({ + success: false, + reason: "Invalid email or password", + }); + return; + } + + const jwt = await signJwt( + { id: existingUser.id, type: "parent" }, + "urn:buddy:users", + ); + + logger.info( + { userId: existingUser.id, email: body.email }, + "User signin successful", + ); + + res.send({ + success: true, + token: jwt, + reason: "", + }); +}); + +router.post("/kid/link", async (req, res) => { + const body: Infer<typeof SigninBodySchema> = req.body; + + const parsed = SigninBodySchema.safeParse(body); + + if (!parsed.success) { + logger.warn({ error: parsed.error }, "Kid link validation failed"); + res.send({ + success: false, + reason: parsed.error, + token: "", + }); + return; + } + + logger.info({ email: parsed.data.email }, "Kid link request initiated"); + + const existingUser = ( + await db + .select() + .from(users) + .where(eq(users.email, parsed.data.email)) + .limit(1) + )[0]; + + if (!existingUser) { + logger.warn( + { email: parsed.data.email }, + "Kid link failed: user not found", + ); + res.send({ + success: false, + reason: "Invalid email or password", + }); + return; + } + + logger.debug({ email: parsed.data.email }, "User found for kid link"); + + const validPassword = await argon2.verify( + existingUser.password, + parsed.data.password, + ); + + if (!validPassword) { + res.send({ + success: false, + reason: "Invalid email or password", + }); + return; + } + + if (!existingUser.emailVerified) { + res.send({ + success: false, + reason: "You must verify your email in the parent app before using Buddy", + }); + return; + } + + const newDevice = ( + await db + .insert(linkedDevices) + .values({ parentId: existingUser.id }) + .returning({ id: linkedDevices.id }) + )[0]; + + const jwt = await signJwt( + { id: newDevice!.id, type: "child" }, + "urn:buddy:devices", + ); + + logger.info( + { deviceId: newDevice!.id, parentId: existingUser.id }, + "New child device linked successfully", + ); + + res.send({ + success: true, + token: jwt, + reason: "", + }); +}); + +export default router; diff --git a/src/routes/signup.ts b/src/routes/signup.ts new file mode 100644 index 0000000..ae05e4f --- /dev/null +++ b/src/routes/signup.ts @@ -0,0 +1,100 @@ +import argon2 from "argon2"; +import express from "express"; +import { users } from "../db/schema"; +import { db } from "../db/db"; +import { Infer, z } from "zod"; +import { signJwt } from "../account/jwt"; +import { eq } from "drizzle-orm"; +import { verificationEmailQueue } from "../queue/email"; +import { logger } from "../lib/pino"; + +/** Validates signup request with email and password fields */ +const SignupBodySchema = z.object({ + email: z + .email({ error: "Invalid email" }) + .nonempty({ error: "Email can't be empty" }), + password: z.string(), +}); + +/** + * Generates a random 6-digit verification code. + * Used for email verification during signup. + */ +export function generate6DigitCode(): string { + return Math.floor(Math.random() * 1_000_000) + .toString() + .padStart(6, "0"); +} + +const router: express.Router = express.Router(); + +router.post("/signup", async (req, res) => { + const body: Infer<typeof SignupBodySchema> = req.body; + logger.info({ email: body.email }, "Signup attempt initiated"); + + const parsed = SignupBodySchema.safeParse(body); + if (!parsed.success) { + logger.warn( + { email: body.email, error: parsed.error }, + "Signup validation failed", + ); + return res.send({ + success: false, + reason: parsed.error, + token: "", + }); + } + + const existingUsers = await db + .select() + .from(users) + .where(eq(users.email, body.email)); + + if (existingUsers.length > 0) { + logger.warn({ email: body.email }, "Signup failed: email already in use"); + return res.send({ + success: false, + reason: "Email already used!", + }); + } + + const hashedPassword = await argon2.hash(body.password); + const code = generate6DigitCode(); + + const user = await db + .insert(users) + .values({ + email: body.email, + password: hashedPassword, + emailCode: code, + }) + .returning(); + + await verificationEmailQueue.add("sendVerificationEmail", { + email: body.email, + code, + }); + + logger.info( + { userId: user[0]!.id, email: body.email }, + "Verification email queued", + ); + + const jwt = await signJwt( + { id: user[0]!.id, type: "parent" }, + "urn:buddy:users", + ); + + logger.info( + { userId: user[0]!.id, email: body.email }, + "User signup completed successfully", + ); + + res.send({ + success: true, + token: jwt, + reason: "", + }); +}); + +export default router; |