From 1a2189ceb83520a627095378fc137d9113c150fe Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Tue, 18 Aug 2026 15:36:08 +0200 Subject: [PATCH] ADD - implement authoritative CityWarn service Add server-owned warning persistence, immutable update history, publishing limits, and CityWarn callbacks. Enforce framework job, grade, duty, category, severity, session, and rate-limit checks before every state change. --- sky_phone/config/config.lua | 50 ++ sky_phone/fxmanifest.lua | 1 + .../source/bridge/server/frameworks/esx.lua | 3 +- .../source/bridge/server/frameworks/qb.lua | 1 + .../source/bridge/server/frameworks/qbox.lua | 1 + sky_phone/source/server/citywarn.lua | 501 ++++++++++++++++++ sky_phone/source/server/db_migrate.lua | 54 ++ sky_phone/source/server/phone.lua | 1 + 8 files changed, 611 insertions(+), 1 deletion(-) create mode 100644 sky_phone/source/server/citywarn.lua diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index cba9bae..f7eaa06 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -1349,6 +1349,56 @@ if IsDuplicityVersion() then }, } + -- ------------------------------------------------------------------------- + -- CityWarn population warnings + -- ------------------------------------------------------------------------- + + Config.CityWarn = { + Enabled = true, + PageSize = 30, + MaximumActiveAlerts = 20, + TitleMaxLength = 120, + BodyMaxLength = 2000, + InstructionsMaxLength = 2000, + UpdateMaxLength = 2000, + AreaLabelMaxLength = 120, + MinimumRadius = 100, + MaximumRadius = 10000, + DefaultDurationMinutes = 60, + MaximumDurationMinutes = 1440, + RequireDuty = true, + RateLimits = { + Read = 180, + Write = 20, + }, + Publishers = { + police = { + MinimumGrade = 2, + MaximumSeverity = "extreme", + CityWide = true, + Categories = { "public_safety", "police", "infrastructure", "evacuation" }, + }, + fire = { + MinimumGrade = 2, + MaximumSeverity = "extreme", + CityWide = true, + Categories = { "public_safety", "fire", "infrastructure", "evacuation" }, + }, + ambulance = { + MinimumGrade = 2, + MaximumSeverity = "danger", + CityWide = false, + Categories = { "public_safety", "medical", "evacuation" }, + }, + government = { + MinimumGrade = 2, + MaximumSeverity = "extreme", + CityWide = true, + Categories = { "public_safety", "police", "fire", "medical", "infrastructure", "evacuation" }, + }, + }, + } + -- ------------------------------------------------------------------------- -- Database migrations -- ------------------------------------------------------------------------- diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index a5283e1..8cb8e0e 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -81,6 +81,7 @@ server_scripts { 'source/server/media_import/manifest.lua', 'source/server/media.lua', 'source/server/weazel_news.lua', + 'source/server/citywarn.lua', 'source/server/messages.lua', 'source/server/memos.lua', 'source/server/easyshare.lua', diff --git a/sky_phone/source/bridge/server/frameworks/esx.lua b/sky_phone/source/bridge/server/frameworks/esx.lua index 794a9d8..1d2f3b4 100644 --- a/sky_phone/source/bridge/server/frameworks/esx.lua +++ b/sky_phone/source/bridge/server/frameworks/esx.lua @@ -127,13 +127,14 @@ function Bridge.Framework.GetJob(source) local player = get_player(source) local job = player and player.getJob() if not job then - return { name = "", label = "", grade = 0, gradeLabel = "" } + return { name = "", label = "", grade = 0, gradeLabel = "", onDuty = false } end return { name = job.name or "", label = job.label or "", grade = tonumber(job.grade) or 0, gradeLabel = job.grade_label or job.label or "", + onDuty = true, } end diff --git a/sky_phone/source/bridge/server/frameworks/qb.lua b/sky_phone/source/bridge/server/frameworks/qb.lua index d98e300..c7e4bbf 100644 --- a/sky_phone/source/bridge/server/frameworks/qb.lua +++ b/sky_phone/source/bridge/server/frameworks/qb.lua @@ -87,6 +87,7 @@ function Bridge.Framework.GetJob(source) label = job and job.label or "", grade = type(grade) == "table" and tonumber(grade.level) or tonumber(grade) or 0, gradeLabel = type(grade) == "table" and (grade.name or job.label) or (job and job.label or ""), + onDuty = job ~= nil and job.onduty ~= false, } end diff --git a/sky_phone/source/bridge/server/frameworks/qbox.lua b/sky_phone/source/bridge/server/frameworks/qbox.lua index f9816b8..819b284 100644 --- a/sky_phone/source/bridge/server/frameworks/qbox.lua +++ b/sky_phone/source/bridge/server/frameworks/qbox.lua @@ -76,6 +76,7 @@ function Bridge.Framework.GetJob(source) label = job and job.label or "", grade = type(grade) == "table" and tonumber(grade.level) or tonumber(grade) or 0, gradeLabel = type(grade) == "table" and (grade.name or job.label) or (job and job.label or ""), + onDuty = job ~= nil and job.onduty ~= false, } end diff --git a/sky_phone/source/server/citywarn.lua b/sky_phone/source/server/citywarn.lua new file mode 100644 index 0000000..fa14a4d --- /dev/null +++ b/sky_phone/source/server/citywarn.lua @@ -0,0 +1,501 @@ +Bridge.Database.AfterMigration("sky_phone", function() +local config = Config.CityWarn + +if type(config) ~= "table" then + error("[sky_phone] Config.CityWarn must be configured.") +end + +local categories = { + public_safety = true, + police = true, + fire = true, + medical = true, + infrastructure = true, + evacuation = true, +} +local severity_rank = { + information = 1, + warning = 2, + danger = 3, + extreme = 4, +} + +local function require_integer_config(name, minimum, maximum) + local value = config[name] + if type(value) ~= "number" or value ~= math.floor(value) or value < minimum or value > maximum then + error(("[sky_phone] Config.CityWarn.%s must be an integer between %d and %d."):format(name, minimum, maximum)) + end +end + +if type(config.Enabled) ~= "boolean" or type(config.RequireDuty) ~= "boolean" then + error("[sky_phone] CityWarn Enabled and RequireDuty must be booleans.") +end +require_integer_config("PageSize", 1, 100) +require_integer_config("MaximumActiveAlerts", 1, 1000) +require_integer_config("TitleMaxLength", 1, 120) +require_integer_config("BodyMaxLength", 1, 2000) +require_integer_config("InstructionsMaxLength", 1, 2000) +require_integer_config("UpdateMaxLength", 1, 2000) +require_integer_config("AreaLabelMaxLength", 1, 120) +require_integer_config("MinimumRadius", 1, 10000) +require_integer_config("MaximumRadius", config.MinimumRadius, 50000) +require_integer_config("DefaultDurationMinutes", 1, 1440) +require_integer_config("MaximumDurationMinutes", config.DefaultDurationMinutes, 10080) + +if type(config.RateLimits) ~= "table" or type(config.Publishers) ~= "table" then + error("[sky_phone] CityWarn RateLimits and Publishers must be tables.") +end +for _, name in ipairs({ "Read", "Write" }) do + local value = config.RateLimits[name] + if type(value) ~= "number" or value ~= math.floor(value) or value < 1 or value > 10000 then + error(("[sky_phone] Invalid CityWarn rate limit '%s'."):format(name)) + end +end +for job_name, publisher in pairs(config.Publishers) do + if type(job_name) ~= "string" or not job_name:match("^[%w_-]+$") or type(publisher) ~= "table" + or type(publisher.MinimumGrade) ~= "number" or publisher.MinimumGrade < 0 + or publisher.MinimumGrade ~= math.floor(publisher.MinimumGrade) + or not severity_rank[publisher.MaximumSeverity] or type(publisher.CityWide) ~= "boolean" + or type(publisher.Categories) ~= "table" + then + error(("[sky_phone] Invalid CityWarn publisher '%s'."):format(tostring(job_name))) + end + local seen = {} + for _, category in ipairs(publisher.Categories) do + if not categories[category] or seen[category] then + error(("[sky_phone] Invalid CityWarn category '%s' for publisher '%s'."):format(tostring(category), job_name)) + end + seen[category] = true + end +end + +local alert_columns = [[ + alert.`id`, alert.`title`, alert.`body`, alert.`instructions`, alert.`category`, + alert.`severity`, alert.`status`, alert.`area_type`, alert.`area_label`, + alert.`center_x`, alert.`center_y`, alert.`radius`, alert.`source_label`, + alert.`author_name`, alert.`revision`, + UNIX_TIMESTAMP(alert.`starts_at`) AS `starts_at_unix`, + UNIX_TIMESTAMP(alert.`expires_at`) AS `expires_at_unix`, + UNIX_TIMESTAMP(alert.`created_at`) AS `created_at_unix`, + UNIX_TIMESTAMP(alert.`updated_at`) AS `updated_at_unix` +]] + +local function trim(value) + if type(value) ~= "string" then + return nil + end + return value:match("^%s*(.-)%s*$") +end + +local function text_length(value) + if type(value) ~= "string" then + return nil + end + local success, length = pcall(utf8.len, value) + return success and length or nil +end + +local function valid_text(value, maximum, allow_empty) + local normalized = trim(value) + local length = text_length(normalized) + if not length or normalized:find("%z") or length > maximum or (not allow_empty and length < 1) then + return nil + end + return normalized +end + +local function valid_number(value, minimum, maximum) + local number = tonumber(value) + if not number or number ~= number or number < minimum or number > maximum then + return nil + end + return number +end + +local function valid_integer(value, minimum, maximum) + local number = valid_number(value, minimum, maximum) + return number and number == math.floor(number) and number or nil +end + +local function valid_uuid(value) + return type(value) == "string" + and value:match("^%x%x%x%x%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$") ~= nil +end + +local function affected_rows(result) + if type(result) == "number" then + return result + end + return type(result) == "table" and tonumber(result.affectedRows) or 0 +end + +local function new_uuid() + local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {}) + local id = rows[1] and rows[1].id + if not valid_uuid(id) then + error("[sky_phone] Database did not generate a valid CityWarn UUID.") + end + return id +end + +local function require_phone(source, operation, maximum) + if not config.Enabled then + return nil, { success = false, error = "feature_disabled" } + end + local session, error_response = SkyPhone.RequireSession(source) + if not session then + return nil, error_response + end + if not SkyPhone.AllowOperation(source, "citywarn_" .. operation, maximum, 60) then + return nil, { success = false, error = "rate_limited" } + end + return session +end + +local function publisher_access(source) + local job = Bridge.Framework.GetJob(source) + local publisher = config.Publishers[job.name] + local grade = tonumber(job.grade) or 0 + local on_duty = job.onDuty == true + if not publisher or grade < publisher.MinimumGrade or (config.RequireDuty and not on_duty) then + return nil, job, publisher + end + local allowed_categories = {} + local allowed_lookup = {} + for _, category in ipairs(publisher.Categories) do + allowed_categories[#allowed_categories + 1] = category + allowed_lookup[category] = true + end + return { + allowed_categories = allowed_categories, + allowed_lookup = allowed_lookup, + can_city_wide = publisher.CityWide, + job_name = job.name, + job_label = type(job.label) == "string" and job.label or job.name, + grade_label = type(job.gradeLabel) == "string" and job.gradeLabel or "", + maximum_severity = publisher.MaximumSeverity, + on_duty = on_duty, + }, job, publisher +end + +local function context_dto(source) + local access, job, publisher = publisher_access(source) + local allowed_categories = {} + if publisher and type(publisher.Categories) == "table" then + for _, category in ipairs(publisher.Categories) do + allowed_categories[#allowed_categories + 1] = category + end + end + return { + canPublish = access ~= nil, + jobLabel = publisher and (job.label or job.name) or nil, + gradeLabel = publisher and (job.gradeLabel or "") or nil, + onDuty = job.onDuty == true, + requiresDuty = config.RequireDuty, + allowedCategories = allowed_categories, + maximumSeverity = publisher and publisher.MaximumSeverity or nil, + canCityWide = publisher and publisher.CityWide == true or false, + } +end + +local function require_publisher(source) + local access = publisher_access(source) + if not access then + return nil, { success = false, error = "not_authorized" } + end + return access +end + +local function actor_identity(source) + local identifier = Bridge.Framework.GetIdentifier(source) + if type(identifier) ~= "string" or #identifier < 1 or #identifier > 80 then + return nil + end + local first_name = trim(Bridge.Framework.GetFirstname(source)) or "" + local last_name = trim(Bridge.Framework.GetLastname(source)) or "" + local name = trim(first_name .. " " .. last_name) + if not name or name == "" then + name = trim(GetPlayerName(source)) or "CityWarn" + end + if #name > 120 then + name = name:sub(1, 120) + end + return { identifier = identifier, name = name } +end + +local function alert_dto(row) + return { + id = row.id, + title = row.title, + body = row.body, + instructions = row.instructions, + category = row.category, + severity = row.severity, + status = row.status, + area = { + type = row.area_type, + label = row.area_label, + centerX = row.center_x and tonumber(row.center_x) or nil, + centerY = row.center_y and tonumber(row.center_y) or nil, + radius = row.radius and tonumber(row.radius) or nil, + }, + sourceLabel = row.source_label, + authorName = row.author_name, + revision = tonumber(row.revision) or 1, + startsAt = tonumber(row.starts_at_unix) * 1000, + expiresAt = tonumber(row.expires_at_unix) * 1000, + createdAt = tonumber(row.created_at_unix) * 1000, + updatedAt = tonumber(row.updated_at_unix) * 1000, + updates = {}, + } +end + +local function attach_updates(alerts) + if #alerts < 1 then + return alerts + end + local ids, placeholders, by_id = {}, {}, {} + for index, alert in ipairs(alerts) do + ids[index] = alert.id + placeholders[index] = "?" + by_id[alert.id] = alert + end + local rows = Bridge.Database.Query(([[ + SELECT `id`, `alert_id`, `kind`, `message`, `actor_name`, + UNIX_TIMESTAMP(`created_at`) AS `created_at_unix` + FROM `sky_phone_citywarn_updates` + WHERE `alert_id` IN (%s) + ORDER BY `created_at` DESC, `id` DESC + ]]):format(table.concat(placeholders, ",")), ids) + for _, row in ipairs(rows) do + local alert = by_id[row.alert_id] + if alert then + alert.updates[#alert.updates + 1] = { + id = row.id, + kind = row.kind, + message = row.message, + actorName = row.actor_name, + createdAt = tonumber(row.created_at_unix) * 1000, + } + end + end + return alerts +end + +local function expire_alerts() + Bridge.Database.Query([[ + UPDATE `sky_phone_citywarn_alerts` + SET `status` = 'expired', `revision` = `revision` + 1 + WHERE `status` = 'active' AND `expires_at` <= NOW() + ]], {}) +end + +local function query_alerts(status) + local where = status == "active" + and "alert.`status` = 'active' AND alert.`expires_at` > NOW()" + or "(alert.`status` <> 'active' OR alert.`expires_at` <= NOW())" + local rows = Bridge.Database.Query(([[ + SELECT %s + FROM `sky_phone_citywarn_alerts` alert + WHERE %s + ORDER BY alert.`created_at` DESC, alert.`id` DESC + LIMIT ? + ]]):format(alert_columns, where), { config.PageSize }) + local alerts = {} + for _, row in ipairs(rows) do + alerts[#alerts + 1] = alert_dto(row) + end + return attach_updates(alerts) +end + +local function load_alert(id) + local rows = Bridge.Database.Query(([[ + SELECT %s FROM `sky_phone_citywarn_alerts` alert WHERE alert.`id` = ? LIMIT 1 + ]]):format(alert_columns), { id }) + local alert = rows[1] and alert_dto(rows[1]) or nil + return alert and attach_updates({ alert })[1] or nil +end + +local function validate_area(data, access) + if type(data) ~= "table" or (data.type ~= "radius" and data.type ~= "district" and data.type ~= "city") then + return nil + end + local label = valid_text(data.label, config.AreaLabelMaxLength, false) + if not label or (data.type == "city" and not access.can_city_wide) then + return nil + end + local center_x, center_y, radius + if data.type == "radius" then + center_x = valid_number(data.centerX, -10000, 10000) + center_y = valid_number(data.centerY, -10000, 10000) + radius = valid_integer(data.radius, config.MinimumRadius, config.MaximumRadius) + if not center_x or not center_y or not radius then + return nil + end + elseif data.centerX ~= nil or data.centerY ~= nil then + center_x = valid_number(data.centerX, -10000, 10000) + center_y = valid_number(data.centerY, -10000, 10000) + if not center_x or not center_y then + return nil + end + end + return { + type = data.type, + label = label, + center_x = center_x, + center_y = center_y, + radius = radius, + } +end + +local function broadcast(kind, alert) + TriggerClientEvent("sky_phone:citywarn:changed", -1, { + alert = alert, + alertId = alert.id, + kind = kind, + severity = alert.severity, + sourceLabel = alert.sourceLabel, + }) +end + +Bridge.Callbacks.Register("sky_phone:citywarn:bootstrap", function(source) + local _, error_response = require_phone(source, "read", config.RateLimits.Read) + if error_response then + return error_response + end + expire_alerts() + return { + success = true, + data = { + active = query_alerts("active"), + archive = query_alerts("archive"), + context = context_dto(source), + onlinePlayers = #Bridge.Framework.GetPlayers(), + }, + } +end) + +Bridge.Callbacks.Register("sky_phone:citywarn:publish", function(source, data) + local _, error_response = require_phone(source, "write", config.RateLimits.Write) + if error_response then + return error_response + end + local access, access_error = require_publisher(source) + if access_error then + return access_error + end + local actor = actor_identity(source) + local title = type(data) == "table" and valid_text(data.title, config.TitleMaxLength, false) or nil + local body = type(data) == "table" and valid_text(data.body, config.BodyMaxLength, false) or nil + local instructions = type(data) == "table" and valid_text(data.instructions, config.InstructionsMaxLength, true) or nil + local duration = type(data) == "table" and valid_integer(data.durationMinutes, 1, config.MaximumDurationMinutes) or nil + local area = type(data) == "table" and validate_area(data.area, access) or nil + if not actor or not title or not body or instructions == nil or not duration or not area + or not access.allowed_lookup[data.category] + or not severity_rank[data.severity] + or severity_rank[data.severity] > severity_rank[access.maximum_severity] + then + return { success = false, error = "invalid_warning" } + end + local counts = Bridge.Database.Query([[ + SELECT COUNT(*) AS `count` FROM `sky_phone_citywarn_alerts` + WHERE `status` = 'active' AND `expires_at` > NOW() + ]], {}) + if tonumber(counts[1] and counts[1].count) >= config.MaximumActiveAlerts then + return { success = false, error = "active_limit" } + end + + local id = new_uuid() + local result = Bridge.Database.Query([[ + INSERT INTO `sky_phone_citywarn_alerts` + (`id`, `title`, `body`, `instructions`, `category`, `severity`, `area_type`, `area_label`, + `center_x`, `center_y`, `radius`, `source_job`, `source_label`, `author_identifier`, + `author_name`, `expires_at`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL ? MINUTE)) + ]], { + id, title, body, instructions, data.category, data.severity, area.type, area.label, + area.center_x, area.center_y, area.radius, access.job_name, access.job_label, + actor.identifier, actor.name, duration, + }) + if affected_rows(result) ~= 1 then + return { success = false, error = "request_failed" } + end + Bridge.Database.Query([[ + INSERT INTO `sky_phone_citywarn_updates` + (`id`, `alert_id`, `kind`, `message`, `actor_identifier`, `actor_name`) + VALUES (?, ?, 'published', ?, ?, ?) + ]], { new_uuid(), id, title, actor.identifier, actor.name }) + local alert = load_alert(id) + broadcast("published", alert) + return { + success = true, + data = { alert = alert, recipients = #Bridge.Framework.GetPlayers() }, + } +end) + +Bridge.Callbacks.Register("sky_phone:citywarn:update", function(source, data) + local _, error_response = require_phone(source, "write", config.RateLimits.Write) + if error_response then + return error_response + end + local _, access_error = require_publisher(source) + if access_error then + return access_error + end + local actor = actor_identity(source) + local message = type(data) == "table" and valid_text(data.message, config.UpdateMaxLength, false) or nil + local revision = type(data) == "table" and valid_integer(data.revision, 1, 4294967295) or nil + if not actor or not message or not revision or not valid_uuid(data.id) then + return { success = false, error = "invalid_update" } + end + local result = Bridge.Database.Query([[ + UPDATE `sky_phone_citywarn_alerts` + SET `revision` = `revision` + 1 + WHERE `id` = ? AND `status` = 'active' AND `expires_at` > NOW() AND `revision` = ? + ]], { data.id, revision }) + if affected_rows(result) ~= 1 then + return { success = false, error = "revision_conflict" } + end + Bridge.Database.Query([[ + INSERT INTO `sky_phone_citywarn_updates` + (`id`, `alert_id`, `kind`, `message`, `actor_identifier`, `actor_name`) + VALUES (?, ?, 'update', ?, ?, ?) + ]], { new_uuid(), data.id, message, actor.identifier, actor.name }) + local alert = load_alert(data.id) + broadcast("update", alert) + return { success = true, data = { alert = alert } } +end) + +Bridge.Callbacks.Register("sky_phone:citywarn:resolve", function(source, data) + local _, error_response = require_phone(source, "write", config.RateLimits.Write) + if error_response then + return error_response + end + local _, access_error = require_publisher(source) + if access_error then + return access_error + end + local actor = actor_identity(source) + local message = type(data) == "table" and valid_text(data.message, config.UpdateMaxLength, false) or nil + local revision = type(data) == "table" and valid_integer(data.revision, 1, 4294967295) or nil + if not actor or not message or not revision or not valid_uuid(data.id) then + return { success = false, error = "invalid_update" } + end + local result = Bridge.Database.Query([[ + UPDATE `sky_phone_citywarn_alerts` + SET `status` = 'resolved', `resolved_at` = NOW(), `revision` = `revision` + 1 + WHERE `id` = ? AND `status` = 'active' AND `revision` = ? + ]], { data.id, revision }) + if affected_rows(result) ~= 1 then + return { success = false, error = "revision_conflict" } + end + Bridge.Database.Query([[ + INSERT INTO `sky_phone_citywarn_updates` + (`id`, `alert_id`, `kind`, `message`, `actor_identifier`, `actor_name`) + VALUES (?, ?, 'resolved', ?, ?, ?) + ]], { new_uuid(), data.id, message, actor.identifier, actor.name }) + local alert = load_alert(data.id) + broadcast("resolved", alert) + return { success = true, data = { alert = alert } } +end) +end) diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index 69802c7..5b93561 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -2853,6 +2853,60 @@ local schema = { }, tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", }, + { + name = "sky_phone_citywarn_alerts", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "title", type = "VARCHAR(120) NOT NULL" }, + { name = "body", type = "VARCHAR(2000) NOT NULL" }, + { name = "instructions", type = "VARCHAR(2000) NOT NULL" }, + { name = "category", type = "ENUM('public_safety','police','fire','medical','infrastructure','evacuation') NOT NULL" }, + { name = "severity", type = "ENUM('information','warning','danger','extreme') NOT NULL" }, + { name = "status", type = "ENUM('active','resolved','expired') NOT NULL DEFAULT 'active'" }, + { name = "area_type", type = "ENUM('radius','district','city') NOT NULL" }, + { name = "area_label", type = "VARCHAR(120) NOT NULL" }, + { name = "center_x", type = "DECIMAL(12,4) NULL" }, + { name = "center_y", type = "DECIMAL(12,4) NULL" }, + { name = "radius", type = "INT UNSIGNED NULL" }, + { name = "source_job", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "source_label", type = "VARCHAR(120) NOT NULL" }, + { name = "author_identifier", type = "VARCHAR(80) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "author_name", type = "VARCHAR(120) NOT NULL" }, + { name = "revision", type = "INT UNSIGNED NOT NULL DEFAULT 1" }, + { name = "starts_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + { name = "expires_at", type = "DATETIME NOT NULL" }, + { name = "resolved_at", type = "DATETIME NULL" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + indexes = { + { name = "idx_sky_phone_citywarn_active", columns = "(`status`, `expires_at`, `created_at`, `id`)" }, + { name = "idx_sky_phone_citywarn_area", columns = "(`area_type`, `status`, `created_at`, `id`)" }, + { name = "idx_sky_phone_citywarn_source", columns = "(`source_job`, `created_at`, `id`)" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_citywarn_updates", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "alert_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "kind", type = "ENUM('published','update','resolved') NOT NULL" }, + { name = "message", type = "VARCHAR(2000) NOT NULL" }, + { name = "actor_identifier", type = "VARCHAR(80) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "actor_name", type = "VARCHAR(120) NOT NULL" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + indexes = { + { name = "idx_sky_phone_citywarn_timeline", columns = "(`alert_id`, `created_at`, `id`)" }, + }, + foreignKeys = { + { column = "alert_id", references = "`sky_phone_citywarn_alerts` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, } Bridge.Database.Migrate("sky_phone", schema) diff --git a/sky_phone/source/server/phone.lua b/sky_phone/source/server/phone.lua index 99df74a..b74c58b 100644 --- a/sky_phone/source/server/phone.lua +++ b/sky_phone/source/server/phone.lua @@ -77,6 +77,7 @@ local allowed_device_namespaces = { apps = true, games = true, widgets = true, + citywarn = true, } local function trim(value)