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.
This commit is contained in:
Type
2026-08-14 23:23:46 +02:00
parent 25c8adb737
commit 56ca2c5ba9
23 changed files with 2052 additions and 3 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

+7
View File
@@ -32,6 +32,13 @@ describe('app registry', () => {
labelKey: 'Apps.weather.name',
route: '/apps/weather',
})
expect(PHONE_APPS.find((app) => app.id === 'health')).toMatchObject({
category: 'utilities',
gridOrder: 11,
labelKey: 'Apps.health.name',
route: '/apps/health',
})
expect(isPhoneAppId('health')).toBe(true)
expect(PHONE_APPS.find((app) => app.id === 'banking')).toMatchObject({
gridOrder: 5,
labelKey: 'Apps.banking.name',
+18
View File
@@ -35,6 +35,7 @@ import {
UsersRound,
Building2,
Newspaper,
HeartPulse,
} from 'lucide-vue-next'
import { defineAsyncComponent, markRaw, shallowReactive } from 'vue'
@@ -61,6 +62,7 @@ import towerStackIcon from '@/assets/img/app-icons/tower-stack.webp'
import skyFlappyIcon from '@/assets/img/app-icons/sky-flappy.webp'
import neonDropIcon from '@/assets/img/app-icons/neon-drop.webp'
import weatherIcon from '@/assets/img/app-icons/weather.webp'
import healthIcon from '@/assets/img/app-icons/health.webp'
import bankingIcon from '@/assets/img/app-icons/banking.webp'
import billingIcon from '@/assets/img/app-icons/billing.svg'
import garageIcon from '@/assets/img/app-icons/garage.webp'
@@ -87,6 +89,20 @@ import type {
} from '@/types/apps'
export const PHONE_APPS = shallowReactive<PhoneAppDefinition[]>([
{
category: 'utilities',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/HealthApp.vue')),
),
dockOrder: null,
gridOrder: 11,
icon: markRaw(HeartPulse),
iconClass: 'app-icon--health',
iconImage: healthIcon,
id: 'health',
labelKey: 'Apps.health.name',
route: '/apps/health',
},
{
category: 'social',
component: markRaw(
@@ -643,6 +659,7 @@ export const DEFAULT_INSTALLED_PHONE_APP_IDS: ReadonlySet<BuiltinPhoneAppId> =
'settings',
'map',
'calendar',
'health',
])
export const NON_REMOVABLE_PHONE_APP_IDS: ReadonlySet<LaunchablePhoneAppId> =
@@ -654,6 +671,7 @@ export const NON_REMOVABLE_PHONE_APP_IDS: ReadonlySet<LaunchablePhoneAppId> =
'phone',
'messages',
'mail',
'health',
])
export const PHONE_APP_IDS = PHONE_APPS.map((app) => app.id)
+5 -2
View File
@@ -35,7 +35,7 @@ describe('app store', () => {
vi.useRealTimers()
})
it('installs only the fourteen standard built-in apps by default', () => {
it('installs the fifteen system apps by default', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
@@ -55,6 +55,7 @@ describe('app store', () => {
'settings',
'map',
'calendar',
'health',
])
for (const appId of DEFAULT_INSTALLED_PHONE_APP_IDS) {
expect(apps.isInstalled(appId)).toBe(true)
@@ -62,6 +63,7 @@ describe('app store', () => {
expect(apps.isInstalled('banking')).toBe(false)
expect(apps.isInstalled('feather')).toBe(false)
expect(apps.isInstalled('snake')).toBe(false)
expect(apps.isInstalled('health')).toBe(true)
})
it('removes old automatic apps unless the player installed them', () => {
@@ -157,7 +159,7 @@ describe('app store', () => {
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
it('installs an app after showing a three second loading state', () => {
it('installs a downloadable app after a three second loading state', () => {
vi.useFakeTimers()
const apps = useAppStoreStore()
@@ -303,6 +305,7 @@ describe('app store', () => {
'phone',
'messages',
'mail',
'health',
])
for (const appId of NON_REMOVABLE_PHONE_APP_IDS) {
apps.removeHomeApp(appId)
+64
View File
@@ -0,0 +1,64 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useHealthStore } from '@/stores/health'
import type { HealthOverview } from '@/types/health'
import { nuiCall } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
const overview: HealthOverview = {
dailyStepGoal: 8000,
days: [],
emergencyNumber: '911',
medicalId: {
allergies: '',
bloodType: '',
conditions: '',
emergencyName: '',
emergencyPhone: '',
emergencyRelation: '',
medication: '',
playerName: 'Alex Morgan',
},
previousWeekSteps: 0,
snapshot: { healthPercent: 100 },
}
describe('health store', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(nuiCall).mockReset()
})
it('loads the server-authoritative health overview', async () => {
vi.mocked(nuiCall).mockResolvedValue({ data: overview, success: true })
const health = useHealthStore()
await expect(health.load()).resolves.toBe(true)
expect(nuiCall).toHaveBeenCalledWith('health:overview')
expect(health.overview?.dailyStepGoal).toBe(8000)
})
it('persists medical ID edits through NUI', async () => {
const health = useHealthStore()
health.overview = structuredClone(overview)
vi.mocked(nuiCall).mockResolvedValue({
data: { ...overview.medicalId, bloodType: 'O+' },
success: true,
})
await expect(
health.saveMedicalId({
allergies: overview.medicalId.allergies,
bloodType: 'O+',
conditions: overview.medicalId.conditions,
emergencyName: overview.medicalId.emergencyName,
emergencyPhone: overview.medicalId.emergencyPhone,
emergencyRelation: overview.medicalId.emergencyRelation,
medication: overview.medicalId.medication,
}),
).resolves.toBe(true)
expect(health.overview.medicalId.bloodType).toBe('O+')
})
})
+54
View File
@@ -0,0 +1,54 @@
import { defineStore } from 'pinia'
import type {
HealthMedicalId,
HealthMedicalIdInput,
HealthOverview,
} from '@/types/health'
import { nuiCall } from '@/utils/nui'
export const useHealthStore = defineStore('health', {
state: () => ({
error: '',
isLoading: false,
isSaving: false,
overview: null as HealthOverview | null,
requestGeneration: 0,
}),
actions: {
async load(): Promise<boolean> {
const generation = ++this.requestGeneration
this.isLoading = true
const response = await nuiCall<HealthOverview>('health:overview').finally(
() => {
if (generation === this.requestGeneration) this.isLoading = false
},
)
if (generation !== this.requestGeneration) return response.success
if (response.success && response.data) {
this.overview = response.data
this.error = ''
return true
}
this.error = response.error ?? 'request_failed'
return false
},
async saveMedicalId(data: HealthMedicalIdInput): Promise<boolean> {
if (this.isSaving) return false
this.isSaving = true
const response = await nuiCall<HealthMedicalId>(
'health:save-profile',
data,
).finally(() => {
this.isSaving = false
})
if (response.success && response.data && this.overview) {
this.overview.medicalId = response.data
this.error = ''
return true
}
this.error = response.error ?? 'request_failed'
return false
},
},
})
+65
View File
@@ -349,6 +349,70 @@ const companiesFallbackLocales = {
},
}
const healthFallbackLocales = {
name: 'Health',
navigation: 'Health navigation',
tabs: { today: 'Today', trends: 'Trends', medicalId: 'Medical ID' },
loading: 'Loading health data...',
errorTitle: 'Health is unavailable',
errorBody: 'Your activity data could not be loaded.',
tryAgain: 'Try Again',
goal: 'of {count}',
steps: 'Steps',
distance: 'Distance',
active: 'Active',
energy: 'Energy',
kilometers: '{count} km',
minutes: '{count} min',
kilocalories: '{count} kcal',
thisWeek: 'This week',
snapshot: 'Health snapshot',
condition: 'Condition',
recovery: 'Recovery',
currentHealth: 'Current health',
conditions: { good: 'Good', fair: 'Fair', low: 'Low' },
trends: {
title: 'Trends',
week: 'Week',
month: 'Month',
total: '{count} steps',
more: '{count}% more than last week',
less: '{count}% less than last week',
same: 'Same as last week',
dailyAverage: 'Daily average',
activeTime: 'Active time',
dailyActivity: 'Daily activity',
goal: 'Goal',
},
medicalId: {
title: 'Medical ID',
edit: 'Edit',
done: 'Done',
resident: 'Los Santos resident',
emergencyInformation: 'Emergency information',
bloodType: 'Blood type',
allergies: 'Allergies',
conditions: 'Conditions',
medication: 'Medication',
noneRecorded: 'None recorded',
emergencyContact: 'Emergency contact',
contactName: 'Contact name',
relation: 'Relation',
phoneNumber: 'Phone number',
emergencyCall: 'Emergency call',
callContact: 'Call emergency contact',
privacy:
'Information is stored with your character and can be shown to emergency services.',
saveFailed: 'Medical ID could not be saved.',
},
errors: {
invalid_phone_number: 'Enter a valid in-game phone number.',
invalid_request: 'Check the Medical ID fields.',
rate_limited: 'Please wait before trying again.',
request_failed: 'Health could not complete the request.',
},
}
const defaultLocales: LocaleTree = {
Apps: {
easyShare: {
@@ -549,6 +613,7 @@ const defaultLocales: LocaleTree = {
},
},
companies: companiesFallbackLocales,
health: healthFallbackLocales,
crewlink: {
name: 'CrewLink',
connecting: 'Connecting your crew...',
+1
View File
@@ -9,6 +9,7 @@ export type BuiltinPhoneAppId =
| 'clock'
| 'calendar'
| 'weather'
| 'health'
| 'banking'
| 'billing'
| 'garage'
+33
View File
@@ -0,0 +1,33 @@
export type HealthActivityDay = {
activeSeconds: number
date: string
distanceMeters: number
energyKcal: number
steps: number
}
export type HealthMedicalId = {
allergies: string
bloodType: string
conditions: string
emergencyName: string
emergencyPhone: string
emergencyRelation: string
medication: string
playerName: string
}
export type HealthSnapshot = {
healthPercent: number
}
export type HealthOverview = {
dailyStepGoal: number
days: HealthActivityDay[]
emergencyNumber: string
medicalId: HealthMedicalId
previousWeekSteps: number
snapshot: HealthSnapshot
}
export type HealthMedicalIdInput = Omit<HealthMedicalId, 'playerName'>
+14
View File
@@ -402,6 +402,20 @@ describe('home layout', () => {
expect(restored.hidden).not.toContain('phone')
})
it('reveals an existing shortcut when restoring a hidden app', () => {
const hidden = {
...defaults,
grid: [...defaults.grid],
hidden: [...defaults.hidden, 'health' as const],
}
hidden.grid[5] = 'health'
const restored = restoreHomeApp(hidden, 'health')
expect(restored.grid[5]).toBe('health')
expect(restored.hidden).not.toContain('health')
})
it('adds persistent empty pages up to the home screen limit', () => {
let layout = defaults
for (let page = 1; page < MAX_HOME_GRID_PAGES; page += 1) {
+5 -1
View File
@@ -601,7 +601,11 @@ export function restoreHomeApp(
layout.grid.some((item) => itemContainsApp(item, appId)) ||
layout.dock.some((item) => itemContainsApp(item, appId))
) {
return layout
if (!layout.hidden.includes(appId)) return layout
return {
...layout,
hidden: layout.hidden.filter((id) => id !== appId),
}
}
const grid = layout.grid.map(cloneItem)
placeInFirstEmptySlot(grid, appId)
+1
View File
@@ -101,6 +101,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
flare: { enabled: true, sounds: true },
'app-store': { enabled: true, sounds: true },
calculator: { enabled: true, sounds: true },
health: { enabled: true, sounds: true },
snake: { enabled: true, sounds: true },
memory: { enabled: true, sounds: true },
'number-merge': { enabled: true, sounds: true },
@@ -0,0 +1,29 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('./HealthApp.vue', import.meta.url), 'utf8')
describe('HealthApp Sky UI contract', () => {
it('uses the shared page, navbar, scroll owner, and pill navigation', () => {
expect(source).toContain('<SkyAppPage')
expect(source).toContain('<SkyNavbar')
expect(source).toContain('<SkyScrollArea')
expect(source).toContain('with-tabbar')
expect(source).toContain('<SkyPillNavigation')
expect(source).not.toContain(
'padding: 0 var(--sky-page-gutter) var(--sky-page-space)',
)
})
it('keeps every user-facing label in the locale tree', () => {
expect(source).toContain("phone.t('Apps.health.name')")
expect(source).toContain("phone.t('Apps.health.tabs.today')")
expect(source).toContain("phone.t('Apps.health.medicalId.title')")
})
it('uses the real Health NUI data flow', () => {
expect(source).toContain('void health.load()')
expect(source).toContain("nuiCall('calls:dial'")
expect(source).toContain('health.saveMedicalId')
})
})
File diff suppressed because it is too large Load Diff
+53
View File
@@ -40,6 +40,41 @@ function unixTime(offsetSeconds = 0) {
return Math.floor(Date.now() / 1000) + offsetSeconds
}
function healthDate(dayOffset) {
const value = new Date()
value.setDate(value.getDate() + dayOffset)
return value.toISOString().slice(0, 10)
}
const healthStepSamples = [4289, 6312, 3421, 7024, 8618, 4302, 6420]
let healthMedicalId = {
allergies: '',
bloodType: 'O+',
conditions: '',
emergencyName: 'Jamie Morgan',
emergencyPhone: '5550102211',
emergencyRelation: 'Sibling',
medication: '',
playerName: 'Alex Morgan',
}
function healthOverview() {
return {
dailyStepGoal: 8000,
days: healthStepSamples.map((steps, index) => ({
activeSeconds: Math.round(steps * 0.36),
date: healthDate(index - 6),
distanceMeters: Math.round(steps * 0.75),
energyKcal: Math.round(steps * 0.045),
steps,
})),
emergencyNumber: '911',
medicalId: healthMedicalId,
previousWeekSteps: 36980,
snapshot: { healthPercent: 96 },
}
}
function memoWaveform(phase = 0) {
return Array.from({ length: 48 }, (_, index) =>
Number(
@@ -7532,6 +7567,24 @@ app.post('/api/:endpoint', (request, response) => {
response.json({ success: true, data: bankingOverview() })
return
}
if (endpoint === 'health:overview') {
response.json({ success: true, data: healthOverview() })
return
}
if (endpoint === 'health:save-profile') {
healthMedicalId = {
...healthMedicalId,
allergies: String(request.body.allergies ?? ''),
bloodType: String(request.body.bloodType ?? ''),
conditions: String(request.body.conditions ?? ''),
emergencyName: String(request.body.emergencyName ?? ''),
emergencyPhone: String(request.body.emergencyPhone ?? ''),
emergencyRelation: String(request.body.emergencyRelation ?? ''),
medication: String(request.body.medication ?? ''),
}
response.json({ success: true, data: healthMedicalId })
return
}
if (endpoint === 'billing:overview') {
response.json({
success: true,
+24
View File
@@ -7,6 +7,7 @@ const browserDataRequests = [
['development:bootstrap', {}],
['account:devices', {}],
['banking:overview', {}],
['health:overview', {}],
['billing:overview', {}],
['billing:list', { filter: 'all', limit: 20, offset: 0 }],
['calendar:list', { endsAt: 4_102_444_800, startsAt: 0 }],
@@ -321,6 +322,29 @@ async function verifyStatefulActions(baseUrl) {
)
assert.equal(bankingAfter.bank, bankingBefore.bank - 125)
const health = await expectSuccess(baseUrl, 'health:overview', {}, true)
assert.equal(health.dailyStepGoal, 8000)
assert.equal(health.days.length, 7)
assert.equal(health.days.at(-1).steps, 6420)
assert.equal(health.snapshot.healthPercent, 96)
const medicalId = await expectSuccess(
baseUrl,
'health:save-profile',
{
allergies: 'Penicillin',
bloodType: 'A+',
conditions: 'Asthma',
emergencyName: 'Jamie Morgan',
emergencyPhone: '5550102211',
emergencyRelation: 'Sibling',
medication: 'Inhaler',
},
true,
)
assert.equal(medicalId.bloodType, 'A+')
assert.equal(medicalId.allergies, 'Penicillin')
let radio = await expectSuccess(
baseUrl,
'radio:connect',
+10
View File
@@ -270,6 +270,16 @@ Config.Banking = {
HistoryLimit = 50,
}
Config.Health = {
DailyStepGoal = 8000,
SampleIntervalMs = 500,
ReportIntervalSeconds = 30,
ReportsPerMinute = 4,
MaximumSpeedMetersPerSecond = 12.0,
EmergencyNumber = "911",
ProfileTextMaxLength = 500,
}
Config.Billing = {
Enabled = true,
Currency = "$",
+45
View File
@@ -184,6 +184,51 @@ Locales["en"] = {
},
},
Apps = {
health = {
name = "Health",
navigation = "Health navigation",
tabs = { today = "Today", trends = "Trends", medicalId = "Medical ID" },
loading = "Loading health data...",
errorTitle = "Health is unavailable",
errorBody = "Your activity data could not be loaded.",
tryAgain = "Try Again",
goal = "of {count}",
steps = "Steps",
distance = "Distance",
active = "Active",
energy = "Energy",
kilometers = "{count} km",
minutes = "{count} min",
kilocalories = "{count} kcal",
thisWeek = "This week",
snapshot = "Health snapshot",
condition = "Condition",
recovery = "Recovery",
currentHealth = "Current health",
conditions = { good = "Good", fair = "Fair", low = "Low" },
trends = {
title = "Trends", week = "Week", month = "Month", total = "{count} steps",
more = "{count}% more than last week", less = "{count}% less than last week",
same = "Same as last week", dailyAverage = "Daily average", activeTime = "Active time",
dailyActivity = "Daily activity", goal = "Goal",
},
medicalId = {
title = "Medical ID", edit = "Edit", done = "Done", resident = "Los Santos resident",
emergencyInformation = "Emergency information", bloodType = "Blood type",
allergies = "Allergies", conditions = "Conditions", medication = "Medication",
noneRecorded = "None recorded", emergencyContact = "Emergency contact",
contactName = "Contact name", relation = "Relation", phoneNumber = "Phone number",
emergencyCall = "Emergency call", callContact = "Call emergency contact",
privacy = "Information is stored with your character and can be shown to emergency services.",
saveFailed = "Medical ID could not be saved.",
},
errors = {
invalid_phone_number = "Enter a valid in-game phone number.",
invalid_request = "Check the Medical ID fields.",
rate_limited = "Please wait before trying again.",
request_failed = "Health could not complete the request.",
},
},
customApps = {
loading = "Opening app...",
unavailableTitle = "App unavailable",
+2
View File
@@ -37,6 +37,7 @@ client_scripts {
'source/client/skyride.lua',
'source/client/housing.lua',
'source/client/crewlink.lua',
'source/client/health.lua',
'source/bridge/client/radio.lua',
'source/client/payphones.lua',
'source/client/custom_apps.lua',
@@ -87,6 +88,7 @@ server_scripts {
'source/server/notes.lua',
'source/server/mail.lua',
'source/server/banking.lua',
'source/server/health.lua',
'source/server/billing.lua',
'source/server/garage.lua',
'source/server/housing.lua',
+111
View File
@@ -0,0 +1,111 @@
local pending_steps = 0
local pending_distance = 0.0
local pending_active_seconds = 0.0
local step_progress = 0.0
local last_coords = nil
local last_report_at = GetGameTimer()
local function health_snapshot()
local ped = PlayerPedId()
if not DoesEntityExist(ped) then
return { healthPercent = 0 }
end
local health = GetEntityHealth(ped)
local maximum = math.max(1, GetEntityMaxHealth(ped))
local base = maximum > 100 and 100 or 0
local percentage = math.floor(math.max(0, math.min(100, (health - base) / (maximum - base) * 100)) + 0.5)
return { healthPercent = percentage }
end
local function flush_activity()
last_report_at = GetGameTimer()
local distance_meters = math.floor(pending_distance + 0.5)
local active_seconds = math.floor(pending_active_seconds + 0.5)
if pending_steps == 0 and distance_meters == 0 and active_seconds == 0 then
return
end
TriggerServerEvent("sky_phone:health:record-activity", {
steps = pending_steps,
distanceMeters = distance_meters,
activeSeconds = active_seconds,
})
pending_steps = 0
pending_distance = 0.0
pending_active_seconds = 0.0
end
CreateThread(function()
while true do
Wait(Config.Health.SampleIntervalMs)
local ped = PlayerPedId()
if DoesEntityExist(ped) then
local coords = GetEntityCoords(ped)
local is_moving_on_foot = IsPedOnFoot(ped)
and not IsPedDeadOrDying(ped, true)
and not IsPedFalling(ped)
and not IsPedRagdoll(ped)
and (IsPedWalking(ped) or IsPedRunning(ped) or IsPedSprinting(ped))
if last_coords and is_moving_on_foot then
local delta_x = coords.x - last_coords.x
local delta_y = coords.y - last_coords.y
local distance = math.sqrt(delta_x * delta_x + delta_y * delta_y)
local maximum_sample_distance = Config.Health.MaximumSpeedMetersPerSecond
* Config.Health.SampleIntervalMs / 1000.0
if distance >= 0.04 and distance <= maximum_sample_distance then
local stride_length = 0.75
if IsPedSprinting(ped) then
stride_length = 1.15
elseif IsPedRunning(ped) then
stride_length = 1.0
end
pending_distance = pending_distance + distance
pending_active_seconds = pending_active_seconds + Config.Health.SampleIntervalMs / 1000.0
step_progress = step_progress + distance
while step_progress >= stride_length do
step_progress = step_progress - stride_length
pending_steps = pending_steps + 1
end
end
end
last_coords = coords
else
last_coords = nil
end
if GetGameTimer() - last_report_at >= Config.Health.ReportIntervalSeconds * 1000 then
flush_activity()
end
end
end)
AddEventHandler("playerSpawned", function()
last_coords = nil
end)
AddEventHandler("onResourceStop", function(resource_name)
if resource_name == GetCurrentResourceName() then
flush_activity()
end
end)
RegisterNUICallback("health:overview", function(data, cb)
local result = Bridge.Callbacks.Trigger("sky_phone:health:overview", data or {})
if result and result.success and result.data then
result.data.snapshot = health_snapshot()
end
cb(result or { success = false, error = "request_failed" })
end)
RegisterNUICallback("health:save-profile", function(data, cb)
local result = Bridge.Callbacks.Trigger("sky_phone:health:save-profile", data or {})
cb(result or { success = false, error = "request_failed" })
end)
RegisterNetEvent("sky_phone:health:changed", function()
SendNUIMessage({ type = "health:changed" })
end)
+37
View File
@@ -532,6 +532,43 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_health_daily",
columns = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{ name = "owner_identifier", type = "VARCHAR(80) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "activity_date", type = "DATE NOT NULL" },
{ name = "steps", type = "INT UNSIGNED NOT NULL DEFAULT 0" },
{ name = "distance_meters", type = "INT UNSIGNED NOT NULL DEFAULT 0" },
{ name = "active_seconds", type = "INT UNSIGNED NOT NULL DEFAULT 0" },
{ name = "energy_kcal", type = "INT UNSIGNED NOT NULL DEFAULT 0" },
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_health_daily", columns = "(`owner_identifier`, `activity_date`)" },
},
indexes = {
{ name = "idx_sky_phone_health_history", columns = "(`owner_identifier`, `activity_date`)" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_health_profiles",
columns = {
{ name = "owner_identifier", type = "VARCHAR(80) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "blood_type", type = "VARCHAR(3) NOT NULL DEFAULT ''", characterSet = "ascii", collation = "ascii_general_ci" },
{ name = "allergies", type = "VARCHAR(500) NOT NULL DEFAULT ''" },
{ name = "conditions", type = "VARCHAR(500) NOT NULL DEFAULT ''" },
{ name = "medication", type = "VARCHAR(500) NOT NULL DEFAULT ''" },
{ name = "emergency_name", type = "VARCHAR(80) NOT NULL DEFAULT ''" },
{ name = "emergency_relation", type = "VARCHAR(40) NOT NULL DEFAULT ''" },
{ name = "emergency_phone", type = "VARCHAR(24) NOT NULL DEFAULT ''", characterSet = "ascii", collation = "ascii_bin" },
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
},
primaryKey = "owner_identifier",
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_billing_invoices",
columns = {
+268
View File
@@ -0,0 +1,268 @@
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)
+27
View File
@@ -267,6 +267,33 @@ CREATE TABLE IF NOT EXISTS `sky_phone_bank_transactions` (
KEY `idx_sky_phone_bank_reference` (`reference`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_health_daily` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`owner_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`activity_date` DATE NOT NULL,
`steps` INT UNSIGNED NOT NULL DEFAULT 0,
`distance_meters` INT UNSIGNED NOT NULL DEFAULT 0,
`active_seconds` INT UNSIGNED NOT NULL DEFAULT 0,
`energy_kcal` INT UNSIGNED NOT NULL DEFAULT 0,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sky_phone_health_daily` (`owner_identifier`, `activity_date`),
KEY `idx_sky_phone_health_history` (`owner_identifier`, `activity_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_health_profiles` (
`owner_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`blood_type` VARCHAR(3) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL DEFAULT '',
`allergies` VARCHAR(500) NOT NULL DEFAULT '',
`conditions` VARCHAR(500) NOT NULL DEFAULT '',
`medication` VARCHAR(500) NOT NULL DEFAULT '',
`emergency_name` VARCHAR(80) NOT NULL DEFAULT '',
`emergency_relation` VARCHAR(40) NOT NULL DEFAULT '',
`emergency_phone` VARCHAR(24) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`owner_identifier`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_billing_invoices` (
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`recipient_identifier` VARCHAR(80) NOT NULL,