mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
Merge branch 'weather-app' into dev
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