diff --git a/api/src/gateway/gateway.controller.ts b/api/src/gateway/gateway.controller.ts index 45aab7d..0eab5a2 100644 --- a/api/src/gateway/gateway.controller.ts +++ b/api/src/gateway/gateway.controller.ts @@ -26,6 +26,8 @@ import { SendBulkSMSInputDTO, SendSMSInputDTO, UpdateSMSStatusDTO, + HeartbeatInputDTO, + HeartbeatResponseDTO, } from './gateway.dto' import { GatewayService } from './gateway.service' import { CanModifyDevice } from './guards/can-modify-device.guard' @@ -70,6 +72,18 @@ export class GatewayController { return { data } } + @ApiOperation({ summary: 'Device heartbeat' }) + @UseGuards(AuthGuard, CanModifyDevice) + @Post('/devices/:id/heartbeat') + @HttpCode(HttpStatus.OK) + async heartbeat( + @Param('id') deviceId: string, + @Body() input: HeartbeatInputDTO, + ): Promise { + const data = await this.gatewayService.heartbeat(deviceId, input) + return data + } + @ApiOperation({ summary: 'Delete device' }) @UseGuards(AuthGuard, CanModifyDevice) @Delete('/devices/:id') diff --git a/api/src/gateway/gateway.dto.ts b/api/src/gateway/gateway.dto.ts index f02795d..d08df9a 100644 --- a/api/src/gateway/gateway.dto.ts +++ b/api/src/gateway/gateway.dto.ts @@ -299,3 +299,134 @@ export class UpdateSMSStatusDTO { }) errorMessage?: string } + +export class HeartbeatInputDTO { + @ApiProperty({ + type: String, + required: false, + description: 'FCM token for push notifications', + }) + fcmToken?: string + + @ApiProperty({ + type: Number, + required: false, + description: 'Battery percentage (0-100)', + }) + batteryPercentage?: number + + @ApiProperty({ + type: Boolean, + required: false, + description: 'Whether device is currently charging', + }) + isCharging?: boolean + + @ApiProperty({ + type: String, + required: false, + description: 'Network type', + enum: ['wifi', 'cellular', 'none'], + }) + networkType?: 'wifi' | 'cellular' | 'none' + + @ApiProperty({ + type: String, + required: false, + description: 'App version name', + }) + appVersionName?: string + + @ApiProperty({ + type: Number, + required: false, + description: 'App version code', + }) + appVersionCode?: number + + @ApiProperty({ + type: Number, + required: false, + description: 'Device uptime in milliseconds since boot', + }) + deviceUptimeMillis?: number + + @ApiProperty({ + type: Number, + required: false, + description: 'Free memory in bytes', + }) + memoryFreeBytes?: number + + @ApiProperty({ + type: Number, + required: false, + description: 'Total memory in bytes', + }) + memoryTotalBytes?: number + + @ApiProperty({ + type: Number, + required: false, + description: 'Max memory in bytes', + }) + memoryMaxBytes?: number + + @ApiProperty({ + type: Number, + required: false, + description: 'Available storage in bytes', + }) + storageAvailableBytes?: number + + @ApiProperty({ + type: Number, + required: false, + description: 'Total storage in bytes', + }) + storageTotalBytes?: number + + @ApiProperty({ + type: String, + required: false, + description: 'Device timezone (e.g., "America/New_York")', + }) + timezone?: string + + @ApiProperty({ + type: String, + required: false, + description: 'Device locale (e.g., "en_US")', + }) + locale?: string + + @ApiProperty({ + type: Boolean, + required: false, + description: 'Whether receive SMS feature is enabled', + }) + receiveSMSEnabled?: boolean +} + +export class HeartbeatResponseDTO { + @ApiProperty({ + type: Boolean, + required: true, + description: 'Whether the heartbeat was successful', + }) + success: boolean + + @ApiProperty({ + type: Boolean, + required: true, + description: 'Whether the FCM token was updated', + }) + fcmTokenUpdated: boolean + + @ApiProperty({ + type: Date, + required: true, + description: 'Server timestamp of the heartbeat', + }) + lastHeartbeat: Date +} diff --git a/api/src/gateway/gateway.service.ts b/api/src/gateway/gateway.service.ts index 1f26ed5..7285faf 100644 --- a/api/src/gateway/gateway.service.ts +++ b/api/src/gateway/gateway.service.ts @@ -10,6 +10,8 @@ import { SendBulkSMSInputDTO, SendSMSInputDTO, UpdateSMSStatusDTO, + HeartbeatInputDTO, + HeartbeatResponseDTO, } from './gateway.dto' import { User } from '../users/schemas/user.schema' import { AuthService } from '../auth/auth.service' @@ -917,4 +919,129 @@ const updatedSms = await this.smsModel.findByIdAndUpdate( messages: smsMessages }; } + + async heartbeat( + deviceId: string, + input: HeartbeatInputDTO, + ): Promise { + const device = await this.deviceModel.findById(deviceId) + + if (!device) { + throw new HttpException( + { + success: false, + error: 'Device not found', + }, + HttpStatus.NOT_FOUND, + ) + } + + const now = new Date() + const updateData: any = { + lastHeartbeat: now, + } + + let fcmTokenUpdated = false + + // Update FCM token if provided and different + if (input.fcmToken && input.fcmToken !== device.fcmToken) { + updateData.fcmToken = input.fcmToken + fcmTokenUpdated = true + } + + // Update receiveSMSEnabled if provided and different + if ( + input.receiveSMSEnabled !== undefined && + input.receiveSMSEnabled !== device.receiveSMSEnabled + ) { + updateData.receiveSMSEnabled = input.receiveSMSEnabled + } + + // Update batteryInfo if provided + if (input.batteryPercentage !== undefined || input.isCharging !== undefined) { + updateData.batteryInfo = { + ...(device.batteryInfo || {}), + ...(input.batteryPercentage !== undefined && { percentage: input.batteryPercentage }), + ...(input.isCharging !== undefined && { isCharging: input.isCharging }), + lastUpdated: now, + } + } + + // Update networkInfo if provided + if (input.networkType !== undefined) { + updateData.networkInfo = { + ...(device.networkInfo || {}), + type: input.networkType, + lastUpdated: now, + } + } + + // Update appVersionInfo if provided + if (input.appVersionName !== undefined || input.appVersionCode !== undefined) { + updateData.appVersionInfo = { + ...(device.appVersionInfo || {}), + ...(input.appVersionName !== undefined && { versionName: input.appVersionName }), + ...(input.appVersionCode !== undefined && { versionCode: input.appVersionCode }), + lastUpdated: now, + } + } + + // Update deviceUptimeInfo if provided + if (input.deviceUptimeMillis !== undefined) { + updateData.deviceUptimeInfo = { + ...(device.deviceUptimeInfo || {}), + uptimeMillis: input.deviceUptimeMillis, + lastUpdated: now, + } + } + + // Update memoryInfo if any memory field provided + if ( + input.memoryFreeBytes !== undefined || + input.memoryTotalBytes !== undefined || + input.memoryMaxBytes !== undefined + ) { + updateData.memoryInfo = { + ...(device.memoryInfo || {}), + ...(input.memoryFreeBytes !== undefined && { freeBytes: input.memoryFreeBytes }), + ...(input.memoryTotalBytes !== undefined && { totalBytes: input.memoryTotalBytes }), + ...(input.memoryMaxBytes !== undefined && { maxBytes: input.memoryMaxBytes }), + lastUpdated: now, + } + } + + // Update storageInfo if any storage field provided + if ( + input.storageAvailableBytes !== undefined || + input.storageTotalBytes !== undefined + ) { + updateData.storageInfo = { + ...(device.storageInfo || {}), + ...(input.storageAvailableBytes !== undefined && { availableBytes: input.storageAvailableBytes }), + ...(input.storageTotalBytes !== undefined && { totalBytes: input.storageTotalBytes }), + lastUpdated: now, + } + } + + // Update systemInfo if timezone or locale provided + if (input.timezone !== undefined || input.locale !== undefined) { + updateData.systemInfo = { + ...(device.systemInfo || {}), + ...(input.timezone !== undefined && { timezone: input.timezone }), + ...(input.locale !== undefined && { locale: input.locale }), + lastUpdated: now, + } + } + + // Update device with all changes + await this.deviceModel.findByIdAndUpdate(deviceId, { + $set: updateData, + }) + + return { + success: true, + fcmTokenUpdated, + lastHeartbeat: now, + } + } } diff --git a/api/src/gateway/schemas/device.schema.ts b/api/src/gateway/schemas/device.schema.ts index 77abd5f..7ee8958 100644 --- a/api/src/gateway/schemas/device.schema.ts +++ b/api/src/gateway/schemas/device.schema.ts @@ -49,6 +49,107 @@ export class Device { @Prop({ type: Number, default: 0 }) receivedSMSCount: number + + @Prop({ type: Boolean, default: true }) + heartbeatEnabled: boolean + + @Prop({ type: Number, default: 30 }) + heartbeatIntervalMinutes: number + + @Prop({ type: Boolean, default: false }) + receiveSMSEnabled: boolean + + @Prop({ type: Date }) + lastHeartbeat: Date + + @Prop({ + type: { + percentage: Number, + isCharging: Boolean, + lastUpdated: Date, + }, + }) + batteryInfo: { + percentage?: number + isCharging?: boolean + lastUpdated?: Date + } + + @Prop({ + type: { + type: String, + lastUpdated: Date, + }, + }) + networkInfo: { + type?: 'wifi' | 'cellular' | 'none' + lastUpdated?: Date + } + + @Prop({ + type: { + versionName: String, + versionCode: Number, + lastUpdated: Date, + }, + }) + appVersionInfo: { + versionName?: string + versionCode?: number + lastUpdated?: Date + } + + @Prop({ + type: { + uptimeMillis: Number, + lastUpdated: Date, + }, + }) + deviceUptimeInfo: { + uptimeMillis?: number + lastUpdated?: Date + } + + @Prop({ + type: { + freeBytes: Number, + totalBytes: Number, + maxBytes: Number, + lastUpdated: Date, + }, + }) + memoryInfo: { + freeBytes?: number + totalBytes?: number + maxBytes?: number + lastUpdated?: Date + } + + @Prop({ + type: { + availableBytes: Number, + totalBytes: Number, + lastUpdated: Date, + }, + }) + storageInfo: { + availableBytes?: number + totalBytes?: number + lastUpdated?: Date + } + + @Prop({ + type: { + timezone: String, + locale: String, + lastUpdated: Date, + }, + }) + systemInfo: { + timezone?: string + locale?: string + lastUpdated?: Date + } } export const DeviceSchema = SchemaFactory.createForClass(Device)