mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-31 18:28:55 +00:00
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:
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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+')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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...',
|
||||
|
||||
@@ -9,6 +9,7 @@ export type BuiltinPhoneAppId =
|
||||
| 'clock'
|
||||
| 'calendar'
|
||||
| 'weather'
|
||||
| 'health'
|
||||
| 'banking'
|
||||
| 'billing'
|
||||
| 'garage'
|
||||
|
||||
@@ -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'>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = "$",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user