Files
Type 56ca2c5ba9 ADD - introduce Health phone app
Add the Health app with server-authoritative health, wellness, medical ID, and emergency-contact data flows.

Register the app in the phone UI, add localized configuration and NUI coverage, persist its schema through the resource migration and install SQL, and include design references and the WebP app icon.
2026-08-14 23:23:46 +02:00

269 lines
9.7 KiB
Lua

Bridge.Database.AfterMigration("sky_phone", function()
local activity_reports = {}
local allowed_blood_types = {
[""] = true,
["A+"] = true,
["A-"] = true,
["B+"] = true,
["B-"] = true,
["AB+"] = true,
["AB-"] = true,
["O+"] = true,
["O-"] = true,
}
local function character_identifier(source, require_session)
if require_session then
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return nil, error_response
end
end
local identifier = Bridge.Framework.GetIdentifier(source)
if type(identifier) ~= "string" or identifier == "" then
return nil, { success = false, error = "health_unavailable" }
end
return identifier
end
local function player_name(source)
local firstname = Bridge.Framework.GetFirstname(source)
local lastname = Bridge.Framework.GetLastname(source)
local name = ((firstname or "") .. " " .. (lastname or "")):match("^%s*(.-)%s*$")
if name == "" then
return GetPlayerName(source) or ("Player %s"):format(source)
end
return name
end
local function clean_text(value, maximum_length)
if type(value) ~= "string" or value:find("[%z\1-\31]") then
return nil
end
local cleaned = value:match("^%s*(.-)%s*$")
if #cleaned > maximum_length then
return nil
end
return cleaned
end
local function empty_profile(source)
return {
playerName = player_name(source),
bloodType = "",
allergies = "",
conditions = "",
medication = "",
emergencyName = "",
emergencyRelation = "",
emergencyPhone = "",
}
end
local function profile_for(source, identifier)
local rows = Bridge.Database.Query([[
SELECT `blood_type`, `allergies`, `conditions`, `medication`,
`emergency_name`, `emergency_relation`, `emergency_phone`
FROM `sky_phone_health_profiles`
WHERE `owner_identifier` = ?
LIMIT 1
]], { identifier })
local row = rows[1]
if not row then
return empty_profile(source)
end
return {
playerName = player_name(source),
bloodType = row.blood_type or "",
allergies = row.allergies or "",
conditions = row.conditions or "",
medication = row.medication or "",
emergencyName = row.emergency_name or "",
emergencyRelation = row.emergency_relation or "",
emergencyPhone = row.emergency_phone or "",
}
end
local function day_key(timestamp)
return os.date("%Y-%m-%d", timestamp)
end
local function activity_history(identifier)
local rows = Bridge.Database.Query([[
SELECT DATE_FORMAT(`activity_date`, '%Y-%m-%d') AS `activity_date`,
`steps`, `distance_meters`, `active_seconds`, `energy_kcal`
FROM `sky_phone_health_daily`
WHERE `owner_identifier` = ?
AND `activity_date` >= DATE_SUB(CURDATE(), INTERVAL 13 DAY)
ORDER BY `activity_date` ASC
]], { identifier })
local by_date = {}
for _, row in ipairs(rows) do
by_date[row.activity_date] = row
end
local now = os.time()
local days = {}
local previous_week_steps = 0
for days_ago = 13, 0, -1 do
local date = day_key(now - days_ago * 86400)
local row = by_date[date]
local item = {
date = date,
steps = math.max(0, tonumber(row and row.steps) or 0),
distanceMeters = math.max(0, tonumber(row and row.distance_meters) or 0),
activeSeconds = math.max(0, tonumber(row and row.active_seconds) or 0),
energyKcal = math.max(0, tonumber(row and row.energy_kcal) or 0),
}
if days_ago >= 7 then
previous_week_steps = previous_week_steps + item.steps
else
days[#days + 1] = item
end
end
return days, previous_week_steps
end
local function overview(source, identifier)
local days, previous_week_steps = activity_history(identifier)
return {
dailyStepGoal = Config.Health.DailyStepGoal,
days = days,
previousWeekSteps = previous_week_steps,
medicalId = profile_for(source, identifier),
emergencyNumber = Config.Health.EmergencyNumber,
}
end
Bridge.Callbacks.Register("sky_phone:health:overview", function(source)
if not SkyPhone.AllowOperation(source, "health_overview", 60, 60) then
return { success = false, error = "rate_limited" }
end
local identifier, error_response = character_identifier(source, true)
if not identifier then
return error_response
end
return { success = true, data = overview(source, identifier) }
end)
Bridge.Callbacks.Register("sky_phone:health:save-profile", function(source, data)
if not SkyPhone.AllowOperation(source, "health_profile_save", 12, 60) then
return { success = false, error = "rate_limited" }
end
local identifier, error_response = character_identifier(source, true)
if not identifier then
return error_response
end
if type(data) ~= "table" then
return { success = false, error = "invalid_request" }
end
local blood_type = clean_text(data.bloodType, 3)
local allergies = clean_text(data.allergies, Config.Health.ProfileTextMaxLength)
local conditions = clean_text(data.conditions, Config.Health.ProfileTextMaxLength)
local medication = clean_text(data.medication, Config.Health.ProfileTextMaxLength)
local emergency_name = clean_text(data.emergencyName, 80)
local emergency_relation = clean_text(data.emergencyRelation, 40)
local emergency_phone = clean_text(data.emergencyPhone, 24)
if not blood_type or not allowed_blood_types[blood_type]
or not allergies or not conditions or not medication
or not emergency_name or not emergency_relation or not emergency_phone
then
return { success = false, error = "invalid_request" }
end
if emergency_phone ~= "" then
emergency_phone = SkyPhoneSimNumber.Normalize(
emergency_phone,
Config.Sim.NumberLength,
Config.Sim.NumberPrefix
)
if not emergency_phone then
return { success = false, error = "invalid_phone_number" }
end
end
Bridge.Database.Query([[
INSERT INTO `sky_phone_health_profiles`
(`owner_identifier`, `blood_type`, `allergies`, `conditions`, `medication`,
`emergency_name`, `emergency_relation`, `emergency_phone`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
`blood_type` = VALUES(`blood_type`),
`allergies` = VALUES(`allergies`),
`conditions` = VALUES(`conditions`),
`medication` = VALUES(`medication`),
`emergency_name` = VALUES(`emergency_name`),
`emergency_relation` = VALUES(`emergency_relation`),
`emergency_phone` = VALUES(`emergency_phone`)
]], {
identifier,
blood_type,
allergies,
conditions,
medication,
emergency_name,
emergency_relation,
emergency_phone,
})
return { success = true, data = profile_for(source, identifier) }
end)
RegisterNetEvent("sky_phone:health:record-activity", function(data)
local player_source = source
if type(data) ~= "table"
or type(data.steps) ~= "number" or data.steps ~= math.floor(data.steps)
or type(data.distanceMeters) ~= "number" or data.distanceMeters ~= math.floor(data.distanceMeters)
or type(data.activeSeconds) ~= "number" or data.activeSeconds ~= math.floor(data.activeSeconds)
or data.steps < 0 or data.distanceMeters < 0 or data.activeSeconds < 0
then
Bridge.Debug("warn", "[sky_phone] Rejected malformed health activity from source %s.", tostring(player_source))
return
end
if not SkyPhone.AllowOperation(player_source, "health_activity", Config.Health.ReportsPerMinute, 60) then
return
end
local identifier = character_identifier(player_source, false)
if not identifier then
return
end
local now = os.time()
local previous = activity_reports[player_source]
local elapsed = previous and math.max(1, now - previous) or Config.Health.ReportIntervalSeconds + 5
activity_reports[player_source] = now
local maximum_distance = math.ceil(elapsed * Config.Health.MaximumSpeedMetersPerSecond)
local maximum_steps = math.ceil(data.distanceMeters / 0.35) + 4
if data.distanceMeters > maximum_distance
or data.activeSeconds > elapsed + 5
or data.distanceMeters > data.activeSeconds * Config.Health.MaximumSpeedMetersPerSecond + 5
or data.steps > maximum_steps
then
Bridge.Debug("warn", "[sky_phone] Rejected implausible health activity from source %s.", tostring(player_source))
return
end
if data.steps == 0 and data.distanceMeters == 0 and data.activeSeconds == 0 then
return
end
local energy_kcal = math.floor(data.distanceMeters * 0.06 + 0.5)
Bridge.Database.Query([[
INSERT INTO `sky_phone_health_daily`
(`owner_identifier`, `activity_date`, `steps`, `distance_meters`, `active_seconds`, `energy_kcal`)
VALUES (?, CURDATE(), ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
`steps` = `steps` + VALUES(`steps`),
`distance_meters` = `distance_meters` + VALUES(`distance_meters`),
`active_seconds` = `active_seconds` + VALUES(`active_seconds`),
`energy_kcal` = `energy_kcal` + VALUES(`energy_kcal`)
]], { identifier, data.steps, data.distanceMeters, data.activeSeconds, energy_kcal })
TriggerClientEvent("sky_phone:health:changed", player_source)
end)
AddEventHandler("playerDropped", function()
activity_reports[source] = nil
end)
end)