mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 05:02:32 +00:00
56ca2c5ba9
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.
269 lines
9.7 KiB
Lua
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)
|