Files
sky_phone-sky-systems/sky_phone/source/server/companies.lua
T
2026-08-24 01:14:08 +02:00

3351 lines
132 KiB
Lua

Bridge.Database.AfterMigration("sky_phone", function()
SkyPhoneCompanies = {}
local definitions = {}
local definition_ids = {}
local definitions_by_job = {}
local service_lines_by_number = {}
local call_availability = {}
local round_robin_positions = {}
local member_tokens = {}
local terminal_statuses = {
completed = true,
cancelled = true,
}
local status_transitions = {
new = { cancelled = true },
assigned = { in_progress = true, cancelled = true },
in_progress = { waiting_customer = true, completed = true, cancelled = true },
waiting_customer = { in_progress = true, cancelled = true },
}
local function uuid()
local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
if not rows[1] or type(rows[1].id) ~= "string" then
error("[sky_phone] Database did not generate a Companies UUID.")
end
return rows[1].id
end
local function trim(value)
if type(value) ~= "string" then
return nil
end
return value:match("^%s*(.-)%s*$")
end
local function valid_text(value, maximum, allow_empty)
local text = trim(value)
if not text or text:find("%z") then
return nil
end
local length = utf8.len(text)
if not length or length > maximum or (not allow_empty and length == 0) then
return nil
end
return text
end
local function valid_integer(value, minimum, maximum)
local number = tonumber(value)
if not number or number ~= number or number < minimum or number > maximum
or number ~= math.floor(number)
then
return nil
end
return number
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 valid_service_id(value)
return type(value) == "string"
and #value >= 1
and #value <= 64
and value:match("^[%w_-]+$") ~= nil
end
local function valid_array(value, maximum)
if type(value) ~= "table" or #value > maximum then
return false
end
local count = 0
for key in pairs(value) do
if type(key) ~= "number" or key < 1 or key ~= math.floor(key) or key > #value then
return false
end
count = count + 1
end
return count == #value
end
local function iso_time(value)
local seconds = tonumber(value)
if not seconds or seconds <= 0 then
return nil
end
return os.date("!%Y-%m-%dT%H:%M:%SZ", seconds)
end
local function days_from_civil(year, month, day)
year = year - (month <= 2 and 1 or 0)
local era = math.floor(year / 400)
local year_of_era = year - era * 400
local adjusted_month = month + (month > 2 and -3 or 9)
local day_of_year = math.floor((153 * adjusted_month + 2) / 5) + day - 1
local day_of_era = year_of_era * 365 + math.floor(year_of_era / 4)
- math.floor(year_of_era / 100) + day_of_year
return era * 146097 + day_of_era - 719468
end
local function utc_epoch(year, month, day, hour, minute, second)
if year < 1970 or year > 2100 or month < 1 or month > 12
or day < 1 or hour < 0 or hour > 23 or minute < 0 or minute > 59
or second < 0 or second > 59
then
return nil
end
local month_lengths = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }
if year % 400 == 0 or (year % 4 == 0 and year % 100 ~= 0) then
month_lengths[2] = 29
end
if day > month_lengths[month] then
return nil
end
return days_from_civil(year, month, day) * 86400 + hour * 3600 + minute * 60 + second
end
local function parse_expiry(value, maximum_seconds)
if value == nil or value == "" then
return nil
end
if type(value) ~= "string" or #value > 40 then
return false
end
local year, month, day, hour, minute, second = value:match(
"^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)Z$"
)
if not year then
year, month, day, hour, minute, second = value:match(
"^(%d%d%d%d)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)%.%d+Z$"
)
end
if not year then
return false
end
local epoch = utc_epoch(
tonumber(year),
tonumber(month),
tonumber(day),
tonumber(hour),
tonumber(minute),
tonumber(second)
)
local now = os.time()
if not epoch or epoch <= now or epoch - now > maximum_seconds then
return false
end
return epoch
end
local function permission_grade(definition, permission)
local grade = definition.Permissions and tonumber(definition.Permissions[permission])
return grade and math.max(0, math.floor(grade)) or nil
end
local function validate_configuration(configuration)
local company_config = configuration.Companies
local validated_definitions = {}
local validated_definition_ids = {}
local validated_definitions_by_job = {}
local validated_service_lines_by_number = {}
if type(company_config) ~= "table" or type(company_config.Definitions) ~= "table" then
return nil, "[sky_phone] Config.Companies.Definitions must be configured."
end
if type(company_config.Enabled) ~= "boolean" then
return nil, "[sky_phone] Config.Companies.Enabled must be a boolean."
end
for _, field in ipairs({
{ "PageSize", 1000 },
{ "MaximumPageSize", 1000 },
{ "MaximumOpenRequestsPerSim", 1000 },
{ "MaximumServices", 255 },
{ "MaximumRequestMedia", 255 },
{ "SubjectMaxLength", 120 },
{ "RequestBodyMaxLength", 2000 },
{ "MessageMaxLength", 2000 },
{ "ProfileDescriptionMaxLength", 1000 },
{ "DistrictMaxLength", 80 },
{ "AddressMaxLength", 160 },
{ "ServiceTitleMaxLength", 80 },
{ "ServiceDescriptionMaxLength", 500 },
{ "ServicePriceMaxLength", 80 },
{ "AnnouncementTitleMaxLength", 120 },
{ "AnnouncementBodyMaxLength", 1000 },
{ "AvailabilityMaximumSeconds", 31536000 },
{ "AnnouncementMaximumSeconds", 31536000 },
{ "RetentionDays", 36500 },
}) do
if not valid_integer(company_config[field[1]], 1, field[2]) then
return nil, ("[sky_phone] Config.Companies.%s is outside its supported range."):format(field[1])
end
end
if company_config.PageSize > company_config.MaximumPageSize then
return nil, "[sky_phone] Companies PageSize cannot exceed MaximumPageSize."
end
if type(company_config.RateLimits) ~= "table" then
return nil, "[sky_phone] Config.Companies.RateLimits must be configured."
end
for _, name in ipairs({ "Read", "Search", "CreateRequest", "Message", "RequestAction", "Profile", "CallAvailability" }) do
if not valid_integer(company_config.RateLimits[name], 1, 100000) then
return nil, ("[sky_phone] Companies rate limit '%s' is invalid."):format(name)
end
end
if type(company_config.CallRouting) ~= "table"
or not valid_integer(company_config.CallRouting.MaxAttempts, 1, 20)
or not valid_integer(company_config.CallRouting.RingSeconds, 1, 120)
then
return nil, "[sky_phone] Config.Companies.CallRouting is invalid."
end
local configured_statuses = {
new = true,
assigned = true,
in_progress = true,
waiting_customer = true,
completed = true,
cancelled = true,
}
if type(company_config.Statuses) ~= "table" then
return nil, "[sky_phone] Config.Companies.Statuses must be configured."
end
for status in pairs(configured_statuses) do
if company_config.Statuses[status] ~= true then
return nil, ("[sky_phone] Companies status '%s' must be enabled."):format(status)
end
end
for status, enabled in pairs(company_config.Statuses) do
if not configured_statuses[status] or enabled ~= true then
return nil, ("[sky_phone] Companies status '%s' is unsupported."):format(tostring(status))
end
end
if type(company_config.AvailabilityStatuses) ~= "table" then
return nil, "[sky_phone] Config.Companies.AvailabilityStatuses must be configured."
end
local configured_availability = { available = true, busy = true, closed = true }
for _, status in ipairs({ "available", "busy", "closed" }) do
if company_config.AvailabilityStatuses[status] ~= true then
return nil, ("[sky_phone] Companies availability status '%s' must be enabled."):format(status)
end
end
for status, enabled in pairs(company_config.AvailabilityStatuses) do
if not configured_availability[status] or enabled ~= true then
return nil, ("[sky_phone] Companies availability status '%s' is unsupported."):format(tostring(status))
end
end
if not valid_array(company_config.Categories, 100) then
return nil, "[sky_phone] Config.Companies.Categories must be a bounded array."
end
local category_ids = {}
local configured_service_ids = {}
for _, category_id in ipairs(company_config.Categories) do
if type(category_id) ~= "string" or #category_id > 64
or not category_id:match("^[a-z0-9_-]+$") or category_ids[category_id]
then
return nil, "[sky_phone] Companies contains an invalid category ID."
end
category_ids[category_id] = true
end
for company_id, definition in pairs(company_config.Definitions) do
if type(company_id) ~= "string" or #company_id > 64 or not company_id:match("^[a-z0-9_-]+$")
or type(definition) ~= "table"
or type(definition.Job) ~= "string" or #definition.Job > 64
or not definition.Job:match("^[%w_-]+$")
or not valid_text(definition.Name, 120, false)
or not category_ids[definition.Category]
then
return nil, ("[sky_phone] Company definition '%s' is invalid."):format(tostring(company_id))
end
local description = valid_text(definition.Description or "", company_config.ProfileDescriptionMaxLength, true)
local district = valid_text(definition.District or "", company_config.DistrictMaxLength, true)
local location_label = valid_text(
definition.LocationLabel or definition.Address or "",
company_config.DistrictMaxLength,
true
)
local address = valid_text(definition.Address or "", company_config.AddressMaxLength, true)
local logo_url = valid_text(definition.LogoUrl, 2048, false)
if not description or not district or not location_label or not address
or type(definition.Public) ~= "boolean" or type(definition.Emergency) ~= "boolean"
or type(definition.Verified) ~= "boolean" or type(definition.AcceptsRequests) ~= "boolean"
or not company_config.AvailabilityStatuses[definition.DefaultAvailability]
or not valid_text(definition.Icon, 64, false)
or not logo_url or not logo_url:match("^https://[^%s]+$")
then
return nil, ("[sky_phone] Company definition '%s' has invalid public profile defaults."):format(company_id)
end
definition.Name = trim(definition.Name)
definition.Description = description
definition.District = district
definition.LocationLabel = location_label
definition.Address = address
definition.LogoUrl = logo_url
if definition.Location ~= nil then
local location_type = type(definition.Location)
if location_type ~= "table" and location_type ~= "vector3" then
return nil, ("[sky_phone] Company definition '%s' has invalid location coordinates."):format(company_id)
end
local x = tonumber(definition.Location.x)
local y = tonumber(definition.Location.y)
local z = tonumber(definition.Location.z)
if not x or not y or not z or x ~= x or y ~= y or z ~= z
or math.abs(x) > 10000 or math.abs(y) > 10000 or math.abs(z) > 2000
then
return nil, ("[sky_phone] Company definition '%s' has invalid location coordinates."):format(company_id)
end
end
if validated_definitions_by_job[definition.Job] then
return nil, ("[sky_phone] Framework job '%s' is assigned to more than one company."):format(definition.Job)
end
local line = definition.ServiceLine
if type(line) ~= "table" then
return nil, ("[sky_phone] Company '%s' has no service line configuration."):format(company_id)
end
local number = SkyPhoneSimNumber.NormalizeService(line.Number, configuration.Sim.NumberLength)
if not number then
return nil, ("[sky_phone] Company '%s' has an invalid service number."):format(company_id)
end
if validated_service_lines_by_number[number] then
return nil, ("[sky_phone] Service number '%s' is assigned more than once."):format(number)
end
if type(line.AutoContact) ~= "boolean" or type(line.CanCall) ~= "boolean"
or type(line.CanMessage) ~= "boolean"
or not valid_integer(line.MinimumGrade, 0, 10000)
then
return nil, ("[sky_phone] Company '%s' has invalid service line flags or grade."):format(company_id)
end
if line.AutoContact and not definition.Public then
return nil, ("[sky_phone] Private company '%s' cannot create a public system contact."):format(company_id)
end
if line.Routing ~= "round_robin" then
return nil, ("[sky_phone] Company '%s' uses unsupported call routing '%s'."):format(
company_id,
tostring(line.Routing)
)
end
line.Number = number
validated_definitions[company_id] = definition
validated_definition_ids[#validated_definition_ids + 1] = company_id
validated_definitions_by_job[definition.Job] = company_id
validated_service_lines_by_number[number] = company_id
for _, permission in ipairs({ "WorkQueue", "Availability", "Assign", "Profile", "Hours", "Services", "Announcement" }) do
if not definition.Permissions or not valid_integer(definition.Permissions[permission], 0, 10000) then
return nil, ("[sky_phone] Company '%s' has no valid '%s' grade."):format(company_id, permission)
end
end
local default_services = definition.Services
if default_services == nil then
default_services = {}
definition.Services = default_services
end
if not valid_array(default_services, company_config.MaximumServices) then
return nil, ("[sky_phone] Company '%s' has an invalid default service list."):format(company_id)
end
for _, service in ipairs(default_services) do
if type(service) ~= "table" then
return nil, ("[sky_phone] Company '%s' has an invalid default service."):format(company_id)
end
local title = valid_text(service.Title, company_config.ServiceTitleMaxLength, false)
local service_description = valid_text(
service.Description or "",
company_config.ServiceDescriptionMaxLength,
true
)
local price = valid_text(service.Price or "", company_config.ServicePriceMaxLength, true)
if not valid_service_id(service.Id) or not title or not service_description or not price
or type(service.RequestsEnabled) ~= "boolean"
then
return nil, ("[sky_phone] Company '%s' has an invalid default service."):format(company_id)
end
service.Title = title
service.Description = service_description
service.Price = price
if configured_service_ids[service.Id] then
return nil, ("[sky_phone] Default company service ID '%s' is configured more than once."):format(service.Id)
end
configured_service_ids[service.Id] = true
end
end
table.sort(validated_definition_ids, function(left, right)
local left_name = validated_definitions[left].Name:lower()
local right_name = validated_definitions[right].Name:lower()
return left_name == right_name and left < right or left_name < right_name
end)
return {
definition_ids = validated_definition_ids,
definitions = validated_definitions,
definitions_by_job = validated_definitions_by_job,
service_lines_by_number = validated_service_lines_by_number,
}
end
function SkyPhoneCompanies.ValidateConfiguration(configuration)
local validated, validation_error = validate_configuration(configuration)
if not validated then
return false, validation_error
end
return true
end
local function seed_companies()
if #definition_ids > 0 then
local placeholders = {}
local numbers = {}
for index, company_id in ipairs(definition_ids) do
placeholders[index] = "?"
numbers[index] = definitions[company_id].ServiceLine.Number
end
local collisions = Bridge.Database.Query(([[
SELECT `phone_number` FROM `sky_phone_sims`
WHERE `phone_number` IN (%s)
LIMIT 1
]]):format(table.concat(placeholders, ", ")), numbers)
if collisions[1] then
error(("[sky_phone] Company service number '%s' collides with an existing SIM."):format(
tostring(collisions[1].phone_number)
))
end
end
for _, company_id in ipairs(definition_ids) do
local definition = definitions[company_id]
local location = definition.Location
if location then
Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_company_profiles`
(`company_id`, `description`, `district`, `location_label`, `address`,
`location_x`, `location_y`, `location_z`, `availability`, `accepts_requests`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
]], {
company_id,
definition.Description or "",
definition.District or "",
definition.LocationLabel or definition.Address or "",
definition.Address or "",
location.x,
location.y,
location.z,
definition.DefaultAvailability or "closed",
definition.AcceptsRequests and 1 or 0,
})
else
Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_company_profiles`
(`company_id`, `description`, `district`, `location_label`, `address`,
`availability`, `accepts_requests`)
VALUES (?, ?, ?, ?, ?, ?, ?)
]], {
company_id,
definition.Description or "",
definition.District or "",
definition.LocationLabel or definition.Address or "",
definition.Address or "",
definition.DefaultAvailability or "closed",
definition.AcceptsRequests and 1 or 0,
})
end
for index, service in ipairs(definition.Services or {}) do
Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_company_services`
(`id`, `company_id`, `title`, `description`, `price_text`, `requests_enabled`, `active`, `sort_order`)
VALUES (?, ?, ?, ?, ?, ?, 1, ?)
]], {
service.Id,
company_id,
service.Title,
service.Description or "",
service.Price or "",
service.RequestsEnabled and 1 or 0,
index,
})
local seeded = Bridge.Database.Query(
"SELECT `company_id` FROM `sky_phone_company_services` WHERE `id` = ? LIMIT 1",
{ service.Id }
)
if not seeded[1] or seeded[1].company_id ~= company_id then
error(("[sky_phone] Default service ID '%s' collides with another company."):format(service.Id))
end
end
end
end
local function migrate_requestable_emergency_companies()
local migration_name = "sky-phone:companies:requestable-emergency:v1"
local completed = Bridge.Database.Query(
"SELECT 1 FROM `sky_phone_migrations` WHERE `name` = ? LIMIT 1",
{ migration_name }
)
if completed[1] then
return
end
local statements = {}
local migrated_companies = {}
for _, company_id in ipairs(definition_ids) do
local definition = definitions[company_id]
if definition.Emergency and definition.AcceptsRequests then
statements[#statements + 1] = {
query = [[
UPDATE `sky_phone_company_profiles`
SET `accepts_requests` = 1, `revision` = `revision` + 1
WHERE `company_id` = ? AND `accepts_requests` = 0
]],
params = { company_id },
}
migrated_companies[#migrated_companies + 1] = company_id
end
end
statements[#statements + 1] = {
query = [[
INSERT IGNORE INTO `sky_phone_migrations` (`name`, `source`, `stats`)
VALUES (?, ?, ?)
]],
params = {
migration_name,
"sky-phone",
json.encode({ companies = migrated_companies }),
},
}
if not Bridge.Database.Transaction(statements) then
error("[sky_phone] Could not migrate requestable emergency company profiles.")
end
end
local function tombstone_removed_companies()
local profiles = Bridge.Database.Query([[
SELECT DISTINCT profile.`company_id`
FROM `sky_phone_company_profiles` profile
INNER JOIN `sky_phone_company_requests` request ON request.`company_id` = profile.`company_id`
WHERE request.`status` NOT IN ('completed', 'cancelled')
]], {})
for _, profile in ipairs(profiles) do
if not definitions[profile.company_id] then
local mutation_token = uuid()
if not Bridge.Database.Transaction({
{
query = [[
UPDATE `sky_phone_company_requests`
SET `status` = 'cancelled', `revision` = `revision` + 1,
`customer_unread` = `customer_unread` + 1,
`cancelled_at` = CURRENT_TIMESTAMP, `mutation_token` = ?
WHERE `company_id` = ? AND `status` NOT IN ('completed', 'cancelled')
]],
params = { mutation_token, profile.company_id },
},
{
query = [[
INSERT INTO `sky_phone_company_request_events`
(`id`, `request_id`, `event_type`, `actor_type`, `to_status`, `detail`)
SELECT UUID(), `id`, 'cancelled', 'system', 'cancelled', 'company_removed'
FROM `sky_phone_company_requests`
WHERE `company_id` = ? AND `mutation_token` = ?
]],
params = { profile.company_id, mutation_token },
},
}) then
error(("[sky_phone] Could not tombstone removed company '%s'."):format(
tostring(profile.company_id)
))
end
end
end
end
local function service_line_payload(company_id)
local definition = definitions[company_id]
if not Config.Companies.Enabled or not definition then
return nil
end
local line = definition.ServiceLine
return {
companyId = company_id,
number = line.Number,
name = definition.Name,
canCall = line.CanCall == true,
canMessage = line.CanMessage == true,
autoContact = line.AutoContact == true,
routing = line.Routing,
icon = definition.Icon,
}
end
function SkyPhoneCompanies.GetServiceLine(number)
local normalized = SkyPhoneSimNumber.NormalizeService(number, Config.Sim.NumberLength)
return normalized and service_line_payload(service_lines_by_number[normalized]) or nil
end
function SkyPhoneCompanies.GetServiceLineForCompany(company_id)
return type(company_id) == "string" and service_line_payload(company_id) or nil
end
function SkyPhoneCompanies.IsServiceNumber(number)
local normalized = SkyPhoneSimNumber.NormalizeService(number, Config.Sim.NumberLength)
return normalized ~= nil and service_lines_by_number[normalized] ~= nil
end
function SkyPhoneCompanies.IsSystemContactNumber(number)
if not Config.Companies.Enabled then
return false
end
local normalized = SkyPhoneSimNumber.NormalizeService(number, Config.Sim.NumberLength)
local company_id = normalized and service_lines_by_number[normalized] or nil
local definition = company_id and definitions[company_id] or nil
return definition ~= nil and definition.Public == true and definition.ServiceLine.AutoContact == true
end
function SkyPhoneCompanies.GetSystemContacts()
local contacts = {}
if not Config.Companies.Enabled then
return contacts
end
for _, company_id in ipairs(definition_ids) do
local definition = definitions[company_id]
local line = definition.ServiceLine
if definition.Public and line.AutoContact then
contacts[#contacts + 1] = {
id = "company:" .. company_id,
companyId = company_id,
name = definition.Name,
organization = definition.Name,
phone_number = line.Number,
avatar_url = definition.LogoUrl,
source = "company",
readonly = true,
canCall = line.CanCall == true,
canMessage = line.CanMessage == true,
verified = definition.Verified == true,
}
end
end
return contacts
end
function SkyPhoneCompanies.ClearCallAvailability(source)
call_availability[tonumber(source) or source] = nil
end
local function current_device(source, registered_required)
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return nil, error_response
end
local device = SkyPhone.LoadDevice(session.imei)
if not device then
return nil, { success = false, error = "request_failed" }
end
if not device.sim_id then
return nil, { success = false, error = "no_sim" }
end
if registered_required and (device.sim_type ~= "registered" or not device.registered_at) then
return nil, { success = false, error = "anonymous_sim" }
end
return device
end
local function membership(source)
local job = Bridge.Framework.GetJob(source)
local company_id = definitions_by_job[job.name]
if not company_id then
return nil
end
return {
company_id = company_id,
definition = definitions[company_id],
grade = tonumber(job.grade) or 0,
grade_label = job.gradeLabel or job.label or "",
}
end
local function require_permission(source, permission)
local member = membership(source)
if not member then
return nil, { success = false, error = "not_authorized" }
end
local minimum = permission_grade(member.definition, permission)
if not minimum or member.grade < minimum then
return nil, { success = false, error = "not_authorized" }
end
local identifier = Bridge.Framework.GetIdentifier(source)
if type(identifier) ~= "string" or identifier == "" then
return nil, { success = false, error = "not_authorized" }
end
member.identifier = identifier
return member
end
local function call_member(source)
local member = membership(source)
if not member then
return nil
end
local minimum = tonumber(member.definition.ServiceLine.MinimumGrade) or 0
if member.grade < minimum then
return nil
end
return member
end
function SkyPhoneCompanies.CanAnswerCompanyCall(source, company_id, imei, sim_id)
source = tonumber(source)
if not Config.Companies.Enabled or not source or type(company_id) ~= "string"
or type(imei) ~= "string" or type(sim_id) ~= "string"
then
return false
end
local readiness = call_availability[source]
local member = readiness and call_member(source) or nil
return member ~= nil
and member.company_id == company_id
and member.definition.ServiceLine.CanCall == true
and readiness.company_id == company_id
and readiness.imei == imei
and readiness.sim_id == sim_id
end
function SkyPhoneCompanies.CanPlaceCompanyCall(source, company_id)
source = tonumber(source)
if not Config.Companies.Enabled or not source or type(company_id) ~= "string" then
return false
end
local member = call_member(source)
return member ~= nil and member.company_id == company_id
and member.definition.ServiceLine.CanCall == true
end
function SkyPhoneCompanies.GetCallTargets(company_id)
local targets = {}
local definition = definitions[company_id]
if not Config.Companies.Enabled or not definition or not definition.ServiceLine.CanCall then
return targets
end
local online = {}
for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do
online[tonumber(player_source) or player_source] = true
end
for source, readiness in pairs(call_availability) do
local member = online[source] and call_member(source) or nil
local device = member and SkyPhone.LoadDevice(readiness.imei) or nil
local device_slots = device and SkyPhone.FindDeviceSlots(source, readiness.imei) or {}
local readiness_valid = member and device and device_slots[1]
and member.company_id == readiness.company_id
and device.sim_id == readiness.sim_id
and device.sim_type == "registered"
and device.registered_at ~= nil
if not readiness_valid then
call_availability[source] = nil
elseif readiness.company_id == company_id then
targets[#targets + 1] = {
source = source,
simId = device.sim_id,
phoneNumber = device.phone_number,
imei = device.imei,
deviceName = device.device_name,
}
end
end
table.sort(targets, function(left, right)
return left.source < right.source
end)
if #targets < 2 then
return targets
end
local start = (round_robin_positions[company_id] or 0) % #targets + 1
local ordered = {}
for offset = 0, #targets - 1 do
ordered[#ordered + 1] = targets[(start + offset - 1) % #targets + 1]
end
round_robin_positions[company_id] = start
return ordered
end
local function profile_row(company_id)
local rows = Bridge.Database.Query([[
SELECT p.`company_id`, p.`description`, p.`district`, p.`location_label`, p.`address`,
p.`location_x`, p.`location_y`, p.`location_z`, p.`availability`,
UNIX_TIMESTAMP(p.`availability_updated_at`) AS `availability_updated_at_unix`,
UNIX_TIMESTAMP(p.`availability_expires_at`) AS `availability_expires_at_unix`,
p.`logo_media_id`, p.`cover_media_id`, p.`accepts_requests`, p.`revision`,
UNIX_TIMESTAMP(p.`updated_at`) AS `updated_at_unix`,
logo.`url` AS `logo_url`, cover.`url` AS `cover_url`
FROM `sky_phone_company_profiles` p
LEFT JOIN `sky_phone_media` logo ON logo.`id` = p.`logo_media_id`
LEFT JOIN `sky_phone_media` cover ON cover.`id` = p.`cover_media_id`
WHERE p.`company_id` = ?
LIMIT 1
]], { company_id })
return rows[1]
end
local function company_services(company_id, include_inactive)
local condition = include_inactive and " AND `archived` = 0"
or " AND `archived` = 0 AND `active` = 1"
local rows = Bridge.Database.Query(([[
SELECT `id`, `title`, `description`, `price_text`, `requests_enabled`, `active`
FROM `sky_phone_company_services`
WHERE `company_id` = ?%s
ORDER BY `sort_order`, `title`, `id`
]]):format(condition), { company_id })
local services = {}
for _, row in ipairs(rows) do
services[#services + 1] = {
id = row.id,
title = row.title,
description = row.description,
priceText = row.price_text ~= "" and row.price_text or nil,
acceptsRequests = tonumber(row.requests_enabled) == 1,
active = tonumber(row.active) == 1,
}
end
return services
end
local function company_hours(company_id)
local rows = Bridge.Database.Query([[
SELECT `weekday`, `is_closed`, `opens_at`, `closes_at`
FROM `sky_phone_company_hours`
WHERE `company_id` = ?
ORDER BY `weekday`
]], { company_id })
local hours = {}
for _, row in ipairs(rows) do
hours[#hours + 1] = {
day = tonumber(row.weekday),
isClosed = tonumber(row.is_closed) == 1,
opensAt = row.opens_at,
closesAt = row.closes_at,
}
end
return hours
end
local function current_announcement(company_id)
local rows = Bridge.Database.Query([[
SELECT `body`, UNIX_TIMESTAMP(`expires_at`) AS `expires_at_unix`,
UNIX_TIMESTAMP(`created_at`) AS `created_at_unix`
FROM `sky_phone_company_announcements`
WHERE `company_id` = ? AND `active` = 1
AND (`expires_at` IS NULL OR `expires_at` > CURRENT_TIMESTAMP)
ORDER BY `created_at` DESC
LIMIT 1
]], { company_id })
local row = rows[1]
return row and {
body = row.body,
expiresAt = iso_time(row.expires_at_unix),
publishedAt = iso_time(row.created_at_unix),
} or nil
end
local function company_payload(company_id, include_inactive_services)
local definition = definitions[company_id]
local row = definition and profile_row(company_id) or nil
if not definition or not row then
return nil
end
local services = company_services(company_id, include_inactive_services)
local availability = row.availability
local availability_expires = tonumber(row.availability_expires_at_unix)
if availability_expires and availability_expires <= os.time() then
availability = definition.DefaultAvailability or "closed"
end
local location = nil
local x = tonumber(row.location_x)
local y = tonumber(row.location_y)
local z = tonumber(row.location_z)
if x and y and z then
location = {
address = row.address,
district = row.district,
label = row.location_label,
coords = { x = x, y = y, z = z },
}
end
local line = definition.ServiceLine
return {
id = company_id,
name = definition.Name,
categoryId = definition.Category,
categoryName = definition.Category,
verified = definition.Verified == true,
description = row.description,
availability = availability,
availabilityUpdatedAt = iso_time(row.availability_updated_at_unix)
or iso_time(row.updated_at_unix),
acceptsRequests = tonumber(row.accepts_requests) == 1,
phoneNumber = line and line.Number or nil,
canCall = line and line.CanCall == true or false,
canMessage = line and line.CanMessage == true or false,
location = location,
logoUrl = row.logo_url or definition.LogoUrl,
coverUrl = row.cover_url,
serviceSummary = services[1] and services[1].title or "",
announcement = current_announcement(company_id),
services = services,
hours = company_hours(company_id),
revision = tonumber(row.revision) or 1,
updatedAtUnix = tonumber(row.updated_at_unix) or 0,
}
end
local function public_company(company_id)
local definition = definitions[company_id]
if not definition or not definition.Public then
return nil
end
return company_payload(company_id, false)
end
local function category_payloads()
local categories = {}
for _, category_id in ipairs(Config.Companies.Categories or {}) do
categories[#categories + 1] = { id = category_id, name = category_id }
end
return categories
end
local function allow_read(source, operation)
if not Config.Companies.Enabled then
return nil, { success = false, error = "service_unavailable" }
end
local limit = Config.Companies.RateLimits[operation] or Config.Companies.RateLimits.Read
if not SkyPhone.AllowOperation(source, "companies_" .. operation:lower(), limit, 60) then
return nil, { success = false, error = "rate_limited" }
end
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return nil, error_response
end
return session
end
local function cleanup_retained_data()
local cutoff = os.time() - Config.Companies.RetentionDays * 86400
local success = Bridge.Database.Transaction({
{
query = [[
DELETE FROM `sky_phone_company_requests`
WHERE `status` IN ('completed', 'cancelled')
AND COALESCE(`completed_at`, `cancelled_at`, `updated_at`) < FROM_UNIXTIME(?)
]],
params = { cutoff },
},
{
query = [[
DELETE FROM `sky_phone_company_audit`
WHERE `created_at` < FROM_UNIXTIME(?)
]],
params = { cutoff },
},
{
query = [[
DELETE FROM `sky_phone_company_announcements`
WHERE (`active` = 0 OR `expires_at` <= CURRENT_TIMESTAMP)
AND `created_at` < FROM_UNIXTIME(?)
]],
params = { cutoff },
},
})
if not success then
Bridge.Debug("error", "[sky_phone] Companies retention cleanup failed.")
end
end
local function refresh_runtime_configuration()
local validated, validation_error = validate_configuration(Config)
if not validated then
error(validation_error)
end
definitions = validated.definitions
definition_ids = validated.definition_ids
definitions_by_job = validated.definitions_by_job
service_lines_by_number = validated.service_lines_by_number
seed_companies()
migrate_requestable_emergency_companies()
tombstone_removed_companies()
end
refresh_runtime_configuration()
AddEventHandler("sky_phone:configurator:serverUpdated", function()
refresh_runtime_configuration()
end)
cleanup_retained_data()
CreateThread(function()
while true do
Wait(24 * 60 * 60 * 1000)
cleanup_retained_data()
end
end)
CreateThread(function()
while true do
Wait(1000)
local online = {}
for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do
online[tonumber(player_source) or player_source] = true
end
for source, readiness in pairs(call_availability) do
local member = online[source] and call_member(source) or nil
if not member or member.company_id ~= readiness.company_id
or member.definition.ServiceLine.CanCall ~= true
then
call_availability[source] = nil
end
end
end
end)
local function company_summary(company)
return {
id = company.id,
name = company.name,
categoryId = company.categoryId,
categoryName = company.categoryName,
verified = company.verified,
description = company.description,
availability = company.availability,
availabilityUpdatedAt = company.availabilityUpdatedAt,
acceptsRequests = company.acceptsRequests,
phoneNumber = company.phoneNumber,
canCall = company.canCall,
canMessage = company.canMessage,
location = company.location,
logoUrl = company.logoUrl,
serviceSummary = company.serviceSummary,
announcement = company.announcement,
}
end
local function search_score(company, search)
if search == "" then
return 0
end
local name = company.name:lower()
if name:sub(1, #search) == search then
return 4
end
if name:find(search, 1, true) then
return 3
end
local values = {
company.categoryId,
company.description,
company.location and company.location.district or "",
company.location and company.location.address or "",
company.serviceSummary,
}
for _, value in ipairs(values) do
if tostring(value):lower():find(search, 1, true) then
return 1
end
end
for _, service in ipairs(company.services) do
if service.title:lower():find(search, 1, true)
or service.description:lower():find(search, 1, true)
then
return 1
end
end
return -1
end
Bridge.Callbacks.Register("sky_phone:companies:list", function(source, data)
local session, error_response = allow_read(source, "Search")
if not session then
return error_response
end
data = type(data) == "table" and data or {}
local search = valid_text(data.search or "", 80, true)
if not search then
return { success = false, error = "invalid_request" }
end
search = search:lower()
local category_id = data.categoryId
if category_id ~= nil then
local category_valid = false
for _, configured in ipairs(Config.Companies.Categories or {}) do
if category_id == configured then
category_valid = true
break
end
end
if not category_valid then
return { success = false, error = "invalid_request" }
end
end
local availability = data.availability
if availability ~= nil and not Config.Companies.AvailabilityStatuses[availability] then
return { success = false, error = "invalid_request" }
end
if data.acceptsRequests ~= nil and type(data.acceptsRequests) ~= "boolean" then
return { success = false, error = "invalid_request" }
end
if data.hasLocation ~= nil and type(data.hasLocation) ~= "boolean" then
return { success = false, error = "invalid_request" }
end
local sort = data.sort or "relevance"
if sort ~= "relevance" and sort ~= "name" and sort ~= "updated" then
return { success = false, error = "invalid_request" }
end
local cursor = data.cursor == nil and 0 or valid_integer(data.cursor, 0, 100000)
if not cursor then
return { success = false, error = "invalid_request" }
end
local matches = {}
for _, company_id in ipairs(definition_ids) do
local company = public_company(company_id)
if company then
local score = search_score(company, search)
if score >= 0
and (not category_id or company.categoryId == category_id)
and (not availability or company.availability == availability)
and (data.acceptsRequests ~= true or company.acceptsRequests)
and (data.hasLocation ~= true or company.location ~= nil)
then
matches[#matches + 1] = { company = company, score = score }
end
end
end
table.sort(matches, function(left, right)
if sort == "updated" and left.company.updatedAtUnix ~= right.company.updatedAtUnix then
return left.company.updatedAtUnix > right.company.updatedAtUnix
end
if sort == "relevance" and left.score ~= right.score then
return left.score > right.score
end
local left_name = left.company.name:lower()
local right_name = right.company.name:lower()
return left_name == right_name and left.company.id < right.company.id or left_name < right_name
end)
local companies = {}
local page_size = math.min(Config.Companies.PageSize, Config.Companies.MaximumPageSize)
local last_index = math.min(#matches, cursor + page_size)
for index = cursor + 1, last_index do
companies[#companies + 1] = company_summary(matches[index].company)
end
return {
success = true,
data = {
companies = companies,
categories = category_payloads(),
nextCursor = last_index < #matches and tostring(last_index) or nil,
},
}
end)
Bridge.Callbacks.Register("sky_phone:companies:get", function(source, data)
local session, error_response = allow_read(source, "Read")
if not session then
return error_response
end
local company_id = type(data) == "table" and data.companyId or nil
local company = type(company_id) == "string" and public_company(company_id) or nil
if not company then
return { success = false, error = "company_not_found" }
end
company.updatedAtUnix = nil
return { success = true, data = { company = company } }
end)
local function decode_request_cursor(value)
if value == nil then
return nil, nil
end
if type(value) ~= "string" or #value > 64 then
return false, false
end
local timestamp, request_id = value:match("^(%d+):(.+)$")
timestamp = valid_integer(timestamp, 1, 4102444800)
if not timestamp or not valid_uuid(request_id) then
return false, false
end
return timestamp, request_id
end
local function encode_request_cursor(row)
return ("%s:%s"):format(tostring(math.floor(tonumber(row.updated_at_unix) or 0)), row.id)
end
local function request_row(request_id)
local rows = Bridge.Database.Query([[
SELECT r.`id`, r.`company_id`, r.`service_id`, r.`channel`, r.`customer_sim_id`, r.`subject`, r.`description`,
r.`status`, r.`assigned_identifier`, r.`customer_unread`,
r.`company_activity_revision`, r.`revision`,
UNIX_TIMESTAMP(r.`created_at`) AS `created_at_unix`,
UNIX_TIMESTAMP(r.`updated_at`) AS `updated_at_unix`, service.`title` AS `service_title`,
logo.`url` AS `company_logo_url`
FROM `sky_phone_company_requests` r
LEFT JOIN `sky_phone_company_services` service ON service.`id` = r.`service_id`
LEFT JOIN `sky_phone_company_profiles` profile ON profile.`company_id` = r.`company_id`
LEFT JOIN `sky_phone_media` logo ON logo.`id` = profile.`logo_media_id`
WHERE r.`id` = ?
LIMIT 1
]], { request_id })
return rows[1]
end
local function request_summary(row, audience, identifier)
local definition = definitions[row.company_id]
local assigned_label = nil
if row.assigned_identifier then
assigned_label = identifier and row.assigned_identifier == identifier and "you" or "assigned"
end
local unread_count
if audience == "customer" then
unread_count = tonumber(row.customer_unread) or 0
else
local relevant = not terminal_statuses[row.status]
and (row.status == "new" or row.assigned_identifier == identifier)
unread_count = relevant and math.max(
0,
(tonumber(row.company_activity_revision) or 1)
- (tonumber(row.company_read_revision) or 0)
) or 0
end
return {
id = row.id,
companyId = row.company_id,
companyName = definition and definition.Name or row.company_id,
companyLogoUrl = row.company_logo_url,
serviceId = row.service_id,
serviceName = row.service_title,
subject = row.subject,
status = row.status,
assignedLabel = assigned_label,
unreadCount = unread_count,
createdAt = iso_time(row.created_at_unix),
updatedAt = iso_time(row.updated_at_unix),
}
end
local function request_access(source, request_id)
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return nil, error_response
end
local row = request_row(request_id)
if not row then
return nil, { success = false, error = "request_not_found" }
end
local device = SkyPhone.LoadDevice(session.imei)
if not device then
return nil, { success = false, error = "request_failed" }
end
if device.sim_id and device.sim_id == row.customer_sim_id
and device.sim_type == "registered" and device.registered_at
then
return { audience = "customer", row = row, device = device }
end
local member = membership(source)
if not member or member.company_id ~= row.company_id
or member.grade < permission_grade(member.definition, "WorkQueue")
then
return nil, { success = false, error = "request_not_found" }
end
local identifier = Bridge.Framework.GetIdentifier(source)
if type(identifier) ~= "string" or identifier == "" then
return nil, { success = false, error = "not_authorized" }
end
member.identifier = identifier
return { audience = "company", row = row, device = device, member = member }
end
local function can_handle_request(access)
if access.audience ~= "company" then
return false
end
return access.row.assigned_identifier == access.member.identifier
or access.member.grade >= permission_grade(access.member.definition, "Assign")
end
local function request_actions(access)
local row = access.row
local definition = definitions[row.company_id]
local active = not terminal_statuses[row.status]
if access.audience == "customer" then
return {
allowedStatuses = {},
canAssign = false,
canCall = definition ~= nil and definition.ServiceLine.CanCall == true,
canCancel = active,
canClaim = false,
canReply = active,
}
end
local handler = can_handle_request(access)
local allowed = {}
if handler then
for _, status in ipairs({ "in_progress", "waiting_customer", "completed", "cancelled" }) do
if status_transitions[row.status] and status_transitions[row.status][status] then
allowed[#allowed + 1] = status
end
end
end
return {
allowedStatuses = allowed,
canAssign = active and access.member.grade >= permission_grade(access.member.definition, "Assign"),
canCall = active
and access.member.definition.ServiceLine.CanCall == true
and access.member.grade >= (tonumber(access.member.definition.ServiceLine.MinimumGrade) or 0),
canCancel = active and handler,
canClaim = row.status == "new" and not row.assigned_identifier,
canReply = active and handler,
}
end
local function request_detail(access)
local row = request_row(access.row.id)
if not row then
return nil
end
access.row = row
if access.audience == "customer" then
Bridge.Database.Query(
"UPDATE `sky_phone_company_requests` SET `customer_unread` = 0, `updated_at` = `updated_at` WHERE `id` = ?",
{ row.id }
)
row.customer_unread = 0
else
Bridge.Database.Query([[
INSERT INTO `sky_phone_company_request_reads`
(`request_id`, `reader_identifier`, `read_revision`)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE
`read_revision` = GREATEST(`read_revision`, VALUES(`read_revision`)),
`updated_at` = CURRENT_TIMESTAMP
]], { row.id, access.member.identifier, row.company_activity_revision })
row.company_read_revision = row.company_activity_revision
end
local messages = Bridge.Database.Query([[
SELECT `id`, `sender_type`, `sender_identifier`, `sender_sim_id`, `body`,
UNIX_TIMESTAMP(`created_at`) AS `created_at_unix`
FROM `sky_phone_company_request_messages`
WHERE `request_id` = ?
ORDER BY `created_at`, `id`
]], { row.id })
local message_payloads = {}
for _, message in ipairs(messages) do
local mine = access.audience == "customer"
and message.sender_type == "customer" and message.sender_sim_id == row.customer_sim_id
or access.audience == "company"
and message.sender_type == "company" and message.sender_identifier == access.member.identifier
message_payloads[#message_payloads + 1] = {
id = message.id,
author = message.sender_type,
authorLabel = mine and "you" or message.sender_type,
body = message.body,
createdAt = iso_time(message.created_at_unix),
isMine = mine,
}
end
local events = Bridge.Database.Query([[
SELECT `id`, `event_type`, `to_status`, UNIX_TIMESTAMP(`created_at`) AS `created_at_unix`
FROM `sky_phone_company_request_events`
WHERE `request_id` = ?
ORDER BY `created_at`, `id`
]], { row.id })
local event_payloads = {}
for _, event in ipairs(events) do
local event_type = event.event_type
if event_type == "status" then
event_type = event.to_status == "completed" and "completed" or "status_changed"
end
event_payloads[#event_payloads + 1] = {
id = event.id,
type = event_type,
status = event.to_status,
createdAt = iso_time(event.created_at_unix),
}
end
local media_rows = Bridge.Database.Query([[
SELECT media.`id`, media.`url`
FROM `sky_phone_company_request_media` request_media
INNER JOIN `sky_phone_media` media ON media.`id` = request_media.`media_id`
WHERE request_media.`request_id` = ?
ORDER BY request_media.`sort_order`
]], { row.id })
local payload = request_summary(
row,
access.audience,
access.member and access.member.identifier or nil
)
payload.description = row.description
payload.revision = tonumber(row.revision) or 1
local definition = definitions[row.company_id]
payload.phoneNumber = definition and definition.ServiceLine.Number or nil
payload.messages = message_payloads
payload.events = event_payloads
payload.media = media_rows
payload.actions = request_actions(access)
return payload
end
local function list_request_rows(query, parameters, audience, identifier)
local rows = Bridge.Database.Query(query, parameters)
local has_more = #rows > Config.Companies.PageSize
if has_more then
rows[#rows] = nil
end
local requests = {}
for _, row in ipairs(rows) do
requests[#requests + 1] = request_summary(row, audience, identifier)
end
return requests, has_more and encode_request_cursor(rows[#rows]) or nil
end
local request_list_select = [[
SELECT r.`id`, r.`company_id`, r.`service_id`, r.`customer_sim_id`, r.`subject`, r.`status`,
r.`assigned_identifier`, r.`customer_unread`, r.`company_activity_revision`, r.`revision`,
UNIX_TIMESTAMP(r.`created_at`) AS `created_at_unix`,
UNIX_TIMESTAMP(r.`updated_at`) AS `updated_at_unix`, service.`title` AS `service_title`,
logo.`url` AS `company_logo_url`, NULL AS `company_read_revision`
FROM `sky_phone_company_requests` r
LEFT JOIN `sky_phone_company_services` service ON service.`id` = r.`service_id`
LEFT JOIN `sky_phone_company_profiles` profile ON profile.`company_id` = r.`company_id`
LEFT JOIN `sky_phone_media` logo ON logo.`id` = profile.`logo_media_id`
]]
local company_request_list_select = [[
SELECT r.`id`, r.`company_id`, r.`service_id`, r.`customer_sim_id`, r.`subject`, r.`status`,
r.`assigned_identifier`, r.`customer_unread`, r.`company_activity_revision`, r.`revision`,
UNIX_TIMESTAMP(r.`created_at`) AS `created_at_unix`,
UNIX_TIMESTAMP(r.`updated_at`) AS `updated_at_unix`, service.`title` AS `service_title`,
logo.`url` AS `company_logo_url`, company_read.`read_revision` AS `company_read_revision`
FROM `sky_phone_company_requests` r
LEFT JOIN `sky_phone_company_services` service ON service.`id` = r.`service_id`
LEFT JOIN `sky_phone_company_profiles` profile ON profile.`company_id` = r.`company_id`
LEFT JOIN `sky_phone_media` logo ON logo.`id` = profile.`logo_media_id`
LEFT JOIN `sky_phone_company_request_reads` company_read
ON company_read.`request_id` = r.`id` AND company_read.`reader_identifier` = ?
]]
local function context_request_rows(company_id, identifier, own)
local condition = own
and " AND r.`assigned_identifier` = ? AND r.`status` NOT IN ('completed', 'cancelled')"
or ""
local parameters = { identifier, company_id }
if own then
parameters[#parameters + 1] = identifier
end
parameters[#parameters + 1] = 4
local rows = Bridge.Database.Query(company_request_list_select .. ([=[
WHERE r.`company_id` = ?%s
ORDER BY r.`updated_at` DESC, r.`id` DESC
LIMIT ?
]=]):format(condition), parameters)
local requests = {}
for _, row in ipairs(rows) do
requests[#requests + 1] = request_summary(row, "company", identifier)
end
return requests
end
local function work_context(source)
local member = membership(source)
if not member or member.grade < permission_grade(member.definition, "WorkQueue") then
return {
authorized = false,
callAvailable = false,
company = nil,
metrics = { assigned = 0, completedToday = 0, new = 0, waiting = 0 },
ownRequests = {},
recentRequests = {},
permissions = {
canAssign = false,
canManageAnnouncement = false,
canManageHours = false,
canManageProfile = false,
canManageServices = false,
canSetAvailability = false,
canTakeCalls = false,
},
role = nil,
unreadCount = 0,
}
end
local identifier = Bridge.Framework.GetIdentifier(source)
if type(identifier) ~= "string" or identifier == "" then
return nil
end
member.identifier = identifier
local rows = Bridge.Database.Query([[
SELECT
COALESCE(SUM(request.`status` = 'new'), 0) AS `new_count`,
COALESCE(SUM(request.`assigned_identifier` = ?
AND request.`status` NOT IN ('completed', 'cancelled')), 0) AS `assigned_count`,
COALESCE(SUM(request.`status` = 'waiting_customer'), 0) AS `waiting_count`,
COALESCE(SUM(request.`status` = 'completed'
AND DATE(request.`completed_at`) = CURRENT_DATE), 0) AS `completed_today`,
COALESCE(SUM(CASE
WHEN request.`status` NOT IN ('completed', 'cancelled')
AND (request.`status` = 'new' OR request.`assigned_identifier` = ?)
AND COALESCE(company_read.`read_revision`, 0) < request.`company_activity_revision`
THEN request.`company_activity_revision` - COALESCE(company_read.`read_revision`, 0)
ELSE 0
END), 0) AS `unread_count`
FROM `sky_phone_company_requests` request
LEFT JOIN `sky_phone_company_request_reads` company_read
ON company_read.`request_id` = request.`id` AND company_read.`reader_identifier` = ?
WHERE request.`company_id` = ?
]], { identifier, identifier, identifier, member.company_id })
local metrics = rows[1] or {}
local permissions = {
canAssign = member.grade >= permission_grade(member.definition, "Assign"),
canManageAnnouncement = member.grade >= permission_grade(member.definition, "Announcement"),
canManageHours = member.grade >= permission_grade(member.definition, "Hours"),
canManageProfile = member.grade >= permission_grade(member.definition, "Profile"),
canManageServices = member.grade >= permission_grade(member.definition, "Services"),
canSetAvailability = member.grade >= permission_grade(member.definition, "Availability"),
canTakeCalls = member.definition.ServiceLine.CanCall == true
and member.grade >= (tonumber(member.definition.ServiceLine.MinimumGrade) or 0),
}
local manager = permissions.canAssign or permissions.canManageAnnouncement
or permissions.canManageHours or permissions.canManageProfile or permissions.canManageServices
local readiness = call_availability[source]
local company = company_payload(member.company_id, true)
company.updatedAtUnix = nil
return {
authorized = true,
callAvailable = permissions.canTakeCalls and readiness ~= nil
and readiness.company_id == member.company_id,
company = company,
metrics = {
new = tonumber(metrics.new_count) or 0,
assigned = tonumber(metrics.assigned_count) or 0,
waiting = tonumber(metrics.waiting_count) or 0,
completedToday = tonumber(metrics.completed_today) or 0,
},
ownRequests = context_request_rows(member.company_id, identifier, true),
recentRequests = context_request_rows(member.company_id, identifier, false),
permissions = permissions,
role = manager and "manager" or "employee",
unreadCount = tonumber(metrics.unread_count) or 0,
}
end
local function notify_sim(sim_id, event_name, payload)
local devices = Bridge.Database.Query([[
SELECT d.`imei`, d.`device_name`, settings.`payload` AS `settings`
FROM `sky_phone_devices` d
LEFT JOIN `sky_phone_device_data` settings
ON settings.`device_imei` = d.`imei` AND settings.`namespace` = 'settings'
WHERE d.`sim_id` = ?
]], { sim_id })
for _, device in ipairs(devices) do
for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do
local target = tonumber(player_source) or player_source
if SkyPhone.FindDeviceSlots(target, device.imei)[1] then
local event_payload = {}
for key, value in pairs(payload) do
event_payload[key] = value
end
event_payload.device = {
imei = device.imei,
name = device.device_name,
settings = device.settings,
}
TriggerClientEvent(event_name, target, event_payload)
end
end
end
end
local function notify_source(source, event_name, payload)
local session = SkyPhone.RequireSession(source)
if not session then
return
end
local rows = Bridge.Database.Query([[
SELECT d.`device_name`, settings.`payload` AS `settings`
FROM `sky_phone_devices` d
LEFT JOIN `sky_phone_device_data` settings
ON settings.`device_imei` = d.`imei` AND settings.`namespace` = 'settings'
WHERE d.`imei` = ?
LIMIT 1
]], { session.imei })
if not rows[1] then
return
end
local event_payload = {}
for key, value in pairs(payload) do
event_payload[key] = value
end
event_payload.device = {
imei = session.imei,
name = rows[1].device_name,
settings = rows[1].settings,
}
TriggerClientEvent(event_name, source, event_payload)
end
local function notify_company(company_id, event_name, payload, excluded_source)
local definition = definitions[company_id]
if not definition then
return
end
for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do
local target = tonumber(player_source) or player_source
if target ~= excluded_source then
local job = Bridge.Framework.GetJob(target)
if job.name == definition.Job and (tonumber(job.grade) or 0) >= permission_grade(definition, "WorkQueue") then
notify_source(target, event_name, payload)
end
end
end
end
local function notify_identifier(identifier, company_id, event_name, payload)
for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do
local target = tonumber(player_source) or player_source
local member = membership(target)
if member and member.company_id == company_id
and member.grade >= permission_grade(member.definition, "WorkQueue")
and Bridge.Framework.GetIdentifier(target) == identifier
then
notify_source(target, event_name, payload)
end
end
end
local function emit_public_change(company_id)
local definition = definitions[company_id]
if not definition then
return
end
notify_company(company_id, "sky_phone:companies:changed", {
area = "work",
companyId = company_id,
})
if not definition.Public then
return
end
for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do
notify_source(tonumber(player_source) or player_source, "sky_phone:companies:changed", {
area = "directory",
companyId = company_id,
})
end
end
local function emit_request_change(row, customer, company, excluded_source)
local payload = {
area = customer and "customer" or "work",
companyId = row.company_id,
requestId = row.id,
}
if customer then
notify_sim(row.customer_sim_id, "sky_phone:companies:changed", payload)
end
if company then
payload = {
area = "work",
companyId = row.company_id,
requestId = row.id,
}
notify_company(row.company_id, "sky_phone:companies:changed", payload, excluded_source)
end
end
Bridge.Callbacks.Register("sky_phone:companies:my-requests", function(source, data)
local session, error_response = allow_read(source, "Read")
if not session then
return error_response
end
local device, device_error = current_device(source, true)
if not device then
return device_error
end
data = type(data) == "table" and data or {}
local list = data.list or "open"
if list ~= "open" and list ~= "closed" then
return { success = false, error = "invalid_request" }
end
local cursor_time, cursor_id = decode_request_cursor(data.cursor)
if cursor_time == false then
return { success = false, error = "invalid_request" }
end
local status_condition = list == "open"
and "r.`status` NOT IN ('completed', 'cancelled')"
or "r.`status` IN ('completed', 'cancelled')"
local cursor_condition = ""
local parameters = { device.sim_id }
if cursor_time then
cursor_condition = [[
AND (r.`updated_at` < FROM_UNIXTIME(?)
OR (r.`updated_at` = FROM_UNIXTIME(?) AND r.`id` < ?))
]]
parameters[#parameters + 1] = cursor_time
parameters[#parameters + 1] = cursor_time
parameters[#parameters + 1] = cursor_id
end
parameters[#parameters + 1] = Config.Companies.PageSize + 1
local requests, next_cursor = list_request_rows(request_list_select .. ([=[
WHERE r.`customer_sim_id` = ? AND %s %s
ORDER BY r.`updated_at` DESC, r.`id` DESC
LIMIT ?
]=]):format(status_condition, cursor_condition), parameters, "customer")
local unread_rows = Bridge.Database.Query([[
SELECT COALESCE(SUM(`customer_unread`), 0) AS `unread_count`
FROM `sky_phone_company_requests`
WHERE `customer_sim_id` = ?
]], { device.sim_id })
return {
success = true,
data = {
requests = requests,
nextCursor = next_cursor,
unreadCount = tonumber(unread_rows[1] and unread_rows[1].unread_count) or 0,
},
}
end)
Bridge.Callbacks.Register("sky_phone:companies:get-request", function(source, data)
local session, error_response = allow_read(source, "Read")
if not session then
return error_response
end
local request_id = type(data) == "table" and data.requestId or nil
if not valid_uuid(request_id) then
return { success = false, error = "invalid_request" }
end
local access, access_error = request_access(source, request_id)
if not access then
return access_error
end
local request = request_detail(access)
if not request then
return { success = false, error = "request_not_found" }
end
return { success = true, data = { request = request } }
end)
Bridge.Callbacks.Register("sky_phone:companies:work-context", function(source)
local session, error_response = allow_read(source, "Read")
if not session then
return error_response
end
local context = work_context(source)
if not context then
return { success = false, error = "not_authorized" }
end
return { success = true, data = { context = context } }
end)
Bridge.Callbacks.Register("sky_phone:companies:work-queue", function(source, data)
local session, error_response = allow_read(source, "Read")
if not session then
return error_response
end
local member, member_error = require_permission(source, "WorkQueue")
if not member then
return member_error
end
data = type(data) == "table" and data or {}
local filter = data.filter or "new"
local filters = {
new = "r.`status` = 'new'",
assigned = "r.`assigned_identifier` = ? AND r.`status` NOT IN ('completed', 'cancelled')",
in_progress = "r.`status` = 'in_progress'",
waiting_customer = "r.`status` = 'waiting_customer'",
completed = "r.`status` = 'completed'",
}
if not filters[filter] then
return { success = false, error = "invalid_request" }
end
local cursor_time, cursor_id = decode_request_cursor(data.cursor)
if cursor_time == false then
return { success = false, error = "invalid_request" }
end
local parameters = { member.identifier, member.company_id }
if filter == "assigned" then
parameters[#parameters + 1] = member.identifier
end
local cursor_condition = ""
if cursor_time then
cursor_condition = [[
AND (r.`updated_at` < FROM_UNIXTIME(?)
OR (r.`updated_at` = FROM_UNIXTIME(?) AND r.`id` < ?))
]]
parameters[#parameters + 1] = cursor_time
parameters[#parameters + 1] = cursor_time
parameters[#parameters + 1] = cursor_id
end
parameters[#parameters + 1] = Config.Companies.PageSize + 1
local requests, next_cursor = list_request_rows(company_request_list_select .. ([=[
WHERE r.`company_id` = ? AND %s %s
ORDER BY r.`updated_at` DESC, r.`id` DESC
LIMIT ?
]=]):format(filters[filter], cursor_condition), parameters, "company", member.identifier)
return { success = true, data = { requests = requests, nextCursor = next_cursor } }
end)
local function member_token(target, company_id, identifier)
local entry = member_tokens[target]
if not entry or entry.company_id ~= company_id or entry.identifier ~= identifier
or os.time() - entry.created_at > 300
then
entry = {
token = uuid(),
company_id = company_id,
identifier = identifier,
created_at = os.time(),
}
member_tokens[target] = entry
end
return entry.token
end
Bridge.Callbacks.Register("sky_phone:companies:list-members", function(source)
local session, error_response = allow_read(source, "Read")
if not session then
return error_response
end
local member, member_error = require_permission(source, "Assign")
if not member then
return member_error
end
local members = {}
for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do
local target = tonumber(player_source) or player_source
local target_member = membership(target)
local target_identifier = target_member and Bridge.Framework.GetIdentifier(target) or nil
if target_member and target_member.company_id == member.company_id
and target_member.grade >= permission_grade(target_member.definition, "WorkQueue")
and type(target_identifier) == "string" and target_identifier ~= ""
then
local first_name = trim(Bridge.Framework.GetFirstname(target) or "") or ""
local last_name = trim(Bridge.Framework.GetLastname(target) or "") or ""
local name = trim(first_name .. " " .. last_name)
members[#members + 1] = {
id = member_token(target, member.company_id, target_identifier),
name = name,
online = true,
role = target_member.grade_label,
}
end
end
table.sort(members, function(left, right)
return left.name:lower() < right.name:lower()
end)
return { success = true, data = { members = members } }
end)
local function allow_mutation(source, operation, limit_name)
if not Config.Companies.Enabled then
return false, { success = false, error = "service_unavailable" }
end
local limit = Config.Companies.RateLimits[limit_name]
if not SkyPhone.AllowOperation(source, "companies_" .. operation, limit, 60) then
return false, { success = false, error = "rate_limited" }
end
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return false, error_response
end
return true
end
local function mutation_request_payload(source, request_id)
local access, access_error = request_access(source, request_id)
if not access then
return nil, access_error
end
local request = request_detail(access)
if not request then
return nil, { success = false, error = "request_not_found" }
end
local data = { request = request }
if access.audience == "company" then
data.context = work_context(source)
end
return data
end
local function notification_payload(kind, area, row)
return {
kind = kind,
area = area,
companyId = row.company_id,
requestId = row.id,
}
end
function SkyPhoneCompanies.RouteServiceLineMessage(source, data)
if not Config.Companies.Enabled or type(data) ~= "table"
or data.messageType ~= "text" or not valid_uuid(data.id)
then
return { success = false, error = "invalid_request" }
end
local device, device_error = current_device(source, false)
if not device then
return device_error
end
local service_line = SkyPhoneCompanies.GetServiceLine(data.phoneNumber)
if not service_line or not service_line.canMessage then
return { success = false, error = "messaging_unavailable" }
end
local body = valid_text(
data.body,
math.min(Config.Messages.BodyMaxLength, Config.Companies.MessageMaxLength),
false
)
if not body then
return { success = false, error = "invalid_message" }
end
local request_id = uuid()
local created_event_id = uuid()
local status_event_id = uuid()
local mutation_token = uuid()
local statements = {
{
query = "UPDATE `sky_phone_sims` SET `updated_at` = `updated_at` WHERE `id` = ?",
params = { device.sim_id },
},
{
query = [[
INSERT INTO `sky_phone_company_requests`
(`id`, `company_id`, `channel`, `customer_sim_id`, `subject`, `description`,
`company_activity_revision`)
SELECT ?, profile.`company_id`, 'service_line', sim.`id`, sim.`phone_number`, ?, 0
FROM `sky_phone_sims` sim
INNER JOIN `sky_phone_company_profiles` profile ON profile.`company_id` = ?
WHERE sim.`id` = ? AND NOT EXISTS (
SELECT 1 FROM `sky_phone_company_requests` existing
WHERE existing.`company_id` = profile.`company_id`
AND existing.`customer_sim_id` = sim.`id`
AND existing.`channel` = 'service_line'
AND existing.`status` NOT IN ('completed', 'cancelled')
)
]],
params = {
request_id,
body,
service_line.companyId,
device.sim_id,
},
},
{
query = [[
INSERT INTO `sky_phone_company_request_events`
(`id`, `request_id`, `event_type`, `actor_type`, `to_status`, `detail`)
SELECT ?, `id`, 'created', 'customer', 'new', 'service_line'
FROM `sky_phone_company_requests` WHERE `id` = ?
]],
params = { created_event_id, request_id },
},
{
query = [[
INSERT INTO `sky_phone_company_request_events`
(`id`, `request_id`, `event_type`, `actor_type`, `from_status`, `to_status`, `detail`)
SELECT ?, `id`, 'status', 'customer', 'waiting_customer', 'in_progress', 'service_line_message'
FROM `sky_phone_company_requests`
WHERE `company_id` = ? AND `customer_sim_id` = ?
AND `channel` = 'service_line' AND `status` = 'waiting_customer'
ORDER BY `updated_at` DESC, `id` DESC LIMIT 1
]],
params = { status_event_id, service_line.companyId, device.sim_id },
},
{
query = [[
UPDATE `sky_phone_company_requests`
SET `status` = IF(`status` = 'waiting_customer', 'in_progress', `status`),
`company_activity_revision` = `company_activity_revision` + 1,
`revision` = `revision` + 1, `mutation_token` = ?
WHERE `company_id` = ? AND `customer_sim_id` = ?
AND `channel` = 'service_line'
AND `status` NOT IN ('completed', 'cancelled')
ORDER BY `updated_at` DESC, `id` DESC LIMIT 1
]],
params = { mutation_token, service_line.companyId, device.sim_id },
},
{
query = [[
INSERT INTO `sky_phone_company_request_messages`
(`id`, `request_id`, `sender_type`, `sender_sim_id`, `body`)
SELECT ?, `id`, 'customer', `customer_sim_id`, ?
FROM `sky_phone_company_requests`
WHERE `mutation_token` = ? LIMIT 1
]],
params = { data.id, body, mutation_token },
},
{
query = [[
INSERT INTO `sky_phone_sms_messages`
(`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`,
`message_type`, `body`)
SELECT ?, sim.`id`, NULL, sim.`phone_number`, ?, 'text', ?
FROM `sky_phone_sims` sim
INNER JOIN `sky_phone_company_request_messages` message ON message.`id` = ?
WHERE sim.`id` = ?
]],
params = {
data.id,
service_line.number,
body,
data.id,
device.sim_id,
},
},
}
if not Bridge.Database.Transaction(statements) then
return { success = false, error = "request_failed" }
end
local routed = Bridge.Database.Query([[
SELECT request.`id` AS `request_id`, created.`id` AS `created_event_id`
FROM `sky_phone_sms_messages` sms
INNER JOIN `sky_phone_company_request_messages` message ON message.`id` = sms.`id`
INNER JOIN `sky_phone_company_requests` request ON request.`id` = message.`request_id`
LEFT JOIN `sky_phone_company_request_events` created
ON created.`id` = ? AND created.`request_id` = request.`id`
WHERE sms.`id` = ? LIMIT 1
]], { created_event_id, data.id })
if not routed[1] then
Bridge.Debug(
"error",
"[sky_phone] Service-line message %s committed without a complete route.",
tostring(data.id),
{ always = true }
)
return { success = false, error = "request_failed" }
end
local row = request_row(routed[1].request_id)
if not row then
return { success = false, error = "request_failed" }
end
emit_request_change(row, true, true, source)
if routed[1].created_event_id then
notify_company(row.company_id, "sky_phone:companies:notification", notification_payload(
"newRequest",
"work",
row
), source)
elseif row.assigned_identifier then
notify_identifier(
row.assigned_identifier,
row.company_id,
"sky_phone:companies:notification",
notification_payload("newMessage", "work", row)
)
end
return {
success = true,
data = {
messageId = data.id,
requestId = row.id,
},
}
end
Bridge.Callbacks.Register("sky_phone:companies:create-request", function(source, data)
local allowed, rate_error = allow_mutation(source, "create_request", "CreateRequest")
if not allowed then
return rate_error
end
local device, device_error = current_device(source, true)
if not device then
return device_error
end
if type(data) ~= "table" then
return { success = false, error = "invalid_request" }
end
local company_id = data.companyId
local definition = type(company_id) == "string" and definitions[company_id] or nil
if not definition or not definition.Public then
return { success = false, error = "company_not_found" }
end
local subject = valid_text(data.subject, Config.Companies.SubjectMaxLength, false)
local description = valid_text(data.description, Config.Companies.RequestBodyMaxLength, false)
if not subject or not description or not valid_service_id(data.serviceId) then
return { success = false, error = "invalid_request" }
end
local profiles = Bridge.Database.Query(
"SELECT `accepts_requests` FROM `sky_phone_company_profiles` WHERE `company_id` = ? LIMIT 1",
{ company_id }
)
if not profiles[1] or tonumber(profiles[1].accepts_requests) ~= 1 then
return { success = false, error = "invalid_service" }
end
local services = Bridge.Database.Query([[
SELECT `id` FROM `sky_phone_company_services`
WHERE `id` = ? AND `company_id` = ? AND `archived` = 0
AND `active` = 1 AND `requests_enabled` = 1
LIMIT 1
]], { data.serviceId, company_id })
if not services[1] then
return { success = false, error = "invalid_service" }
end
local media_ids = data.mediaIds
if media_ids == nil then
media_ids = {}
end
if not valid_array(media_ids, Config.Companies.MaximumRequestMedia) then
return { success = false, error = "invalid_media" }
end
local media_seen = {}
local normalized_media = {}
for _, media_id in ipairs(media_ids) do
local normalized = valid_integer(media_id, 1, 9007199254740991)
if not normalized or media_seen[normalized]
or not SkyPhoneMedia.ResolveOwnedMedia(source, normalized, "photo")
then
return { success = false, error = "invalid_media" }
end
media_seen[normalized] = true
normalized_media[#normalized_media + 1] = normalized
end
local counts = Bridge.Database.Query([[
SELECT COUNT(*) AS `count`
FROM `sky_phone_company_requests`
WHERE `customer_sim_id` = ? AND `status` NOT IN ('completed', 'cancelled')
]], { device.sim_id })
if (tonumber(counts[1] and counts[1].count) or 0) >= Config.Companies.MaximumOpenRequestsPerSim then
return { success = false, error = "too_many_open_requests" }
end
local request_id = uuid()
local event_id = uuid()
local statements = {
{
query = "UPDATE `sky_phone_sims` SET `updated_at` = `updated_at` WHERE `id` = ?",
params = { device.sim_id },
},
{
query = [[
INSERT INTO `sky_phone_company_requests`
(`id`, `company_id`, `service_id`, `customer_sim_id`, `subject`, `description`)
SELECT ?, profile.`company_id`, service.`id`, sim.`id`, ?, ?
FROM `sky_phone_sims` sim
INNER JOIN `sky_phone_company_profiles` profile ON profile.`company_id` = ?
INNER JOIN `sky_phone_company_services` service
ON service.`id` = ? AND service.`company_id` = profile.`company_id`
WHERE sim.`id` = ? AND profile.`accepts_requests` = 1
AND service.`archived` = 0 AND service.`active` = 1
AND service.`requests_enabled` = 1 AND (
SELECT COUNT(*) FROM `sky_phone_company_requests`
WHERE `customer_sim_id` = ? AND `status` NOT IN ('completed', 'cancelled')
) < ?
]],
params = {
request_id,
subject,
description,
company_id,
data.serviceId,
device.sim_id,
device.sim_id,
Config.Companies.MaximumOpenRequestsPerSim,
},
},
{
query = [[
INSERT INTO `sky_phone_company_request_events`
(`id`, `request_id`, `event_type`, `actor_type`, `to_status`)
SELECT ?, `id`, 'created', 'customer', 'new'
FROM `sky_phone_company_requests` WHERE `id` = ?
]],
params = { event_id, request_id },
},
}
for index, media_id in ipairs(normalized_media) do
statements[#statements + 1] = {
query = [[
INSERT INTO `sky_phone_company_request_media` (`request_id`, `media_id`, `sort_order`)
SELECT `id`, ?, ? FROM `sky_phone_company_requests` WHERE `id` = ?
]],
params = { media_id, index, request_id },
}
end
if not Bridge.Database.Transaction(statements) then
return { success = false, error = "request_failed" }
end
local row = request_row(request_id)
if not row then
local available = Bridge.Database.Query([[
SELECT service.`id`
FROM `sky_phone_company_profiles` profile
INNER JOIN `sky_phone_company_services` service ON service.`company_id` = profile.`company_id`
WHERE profile.`company_id` = ? AND profile.`accepts_requests` = 1
AND service.`id` = ? AND service.`archived` = 0
AND service.`active` = 1 AND service.`requests_enabled` = 1
LIMIT 1
]], { company_id, data.serviceId })
if not available[1] then
return { success = false, error = "invalid_service" }
end
return { success = false, error = "too_many_open_requests" }
end
emit_request_change(row, true, true, source)
notify_company(company_id, "sky_phone:companies:notification", notification_payload(
"newRequest",
"work",
row
), source)
local payload, payload_error = mutation_request_payload(source, request_id)
if not payload then
return payload_error
end
return { success = true, data = payload }
end)
Bridge.Callbacks.Register("sky_phone:companies:cancel-request", function(source, data)
local allowed, rate_error = allow_mutation(source, "cancel_request", "RequestAction")
if not allowed then
return rate_error
end
local request_id = type(data) == "table" and data.requestId or nil
local revision = type(data) == "table" and valid_integer(data.revision, 1, 4294967295) or nil
if not valid_uuid(request_id) or not revision then
return { success = false, error = "invalid_request" }
end
local access, access_error = request_access(source, request_id)
if not access then
return access_error
end
if terminal_statuses[access.row.status]
or (access.audience == "company" and not can_handle_request(access))
then
return { success = false, error = "invalid_status" }
end
local actor_type = access.audience
local customer_unread_update = actor_type == "company"
and ", `customer_unread` = `customer_unread` + 1" or ""
local mutation_token = uuid()
local event_id = uuid()
local event_query
local event_params
if actor_type == "customer" then
event_query = [[
INSERT INTO `sky_phone_company_request_events`
(`id`, `request_id`, `event_type`, `actor_type`, `from_status`, `to_status`)
SELECT ?, `id`, 'cancelled', 'customer', ?, 'cancelled'
FROM `sky_phone_company_requests`
WHERE `id` = ? AND `revision` = ? AND `mutation_token` = ?
]]
event_params = { event_id, access.row.status, request_id, revision + 1, mutation_token }
else
event_query = [[
INSERT INTO `sky_phone_company_request_events`
(`id`, `request_id`, `event_type`, `actor_type`, `actor_identifier`, `from_status`, `to_status`)
SELECT ?, `id`, 'cancelled', 'company', ?, ?, 'cancelled'
FROM `sky_phone_company_requests`
WHERE `id` = ? AND `revision` = ? AND `mutation_token` = ?
]]
event_params = {
event_id,
access.member.identifier,
access.row.status,
request_id,
revision + 1,
mutation_token,
}
end
if not Bridge.Database.Transaction({
{
query = (([[
UPDATE `sky_phone_company_requests`
SET `status` = 'cancelled', `revision` = `revision` + 1,
`cancelled_at` = CURRENT_TIMESTAMP, `mutation_token` = ?%s
WHERE `id` = ? AND `revision` = ? AND `status` = ?
]])):format(customer_unread_update),
params = { mutation_token, request_id, revision, access.row.status },
},
{ query = event_query, params = event_params },
}) then
return { success = false, error = "request_failed" }
end
local committed = Bridge.Database.Query(
"SELECT `id` FROM `sky_phone_company_request_events` WHERE `id` = ? LIMIT 1",
{ event_id }
)
if not committed[1] then
return { success = false, error = "revision_conflict" }
end
local row = request_row(request_id)
emit_request_change(row, true, true, source)
if actor_type == "customer" then
notify_company(row.company_id, "sky_phone:companies:notification", notification_payload(
"requestUpdated",
"work",
row
), source)
else
notify_sim(row.customer_sim_id, "sky_phone:companies:notification", notification_payload(
"requestUpdated",
"customer",
row
))
end
local payload, payload_error = mutation_request_payload(source, request_id)
if not payload then
return payload_error
end
return { success = true, data = payload }
end)
Bridge.Callbacks.Register("sky_phone:companies:send-message", function(source, data)
local allowed, rate_error = allow_mutation(source, "send_message", "Message")
if not allowed then
return rate_error
end
local request_id = type(data) == "table" and data.requestId or nil
local revision = type(data) == "table" and valid_integer(data.revision, 1, 4294967295) or nil
local body = type(data) == "table" and valid_text(data.body, Config.Companies.MessageMaxLength, false) or nil
if not valid_uuid(request_id) or not revision or not body then
return { success = false, error = "invalid_request" }
end
local access, access_error = request_access(source, request_id)
if not access then
return access_error
end
if terminal_statuses[access.row.status]
or (access.audience == "company" and not can_handle_request(access))
then
return { success = false, error = "invalid_status" }
end
local service_line = access.row.channel == "service_line"
and SkyPhoneCompanies.GetServiceLineForCompany(access.row.company_id) or nil
if access.row.channel == "service_line" and (not service_line or not service_line.canMessage) then
return { success = false, error = "messaging_unavailable" }
end
local message_id = uuid()
local mutation_token = uuid()
local new_status = access.audience == "customer" and access.row.status == "waiting_customer"
and "in_progress" or access.row.status
local audience_unread_update = access.audience == "customer"
and ", `company_activity_revision` = `company_activity_revision` + 1"
or ", `customer_unread` = `customer_unread` + 1"
local statements = {
{
query = ([[
UPDATE `sky_phone_company_requests`
SET `status` = ?, `revision` = `revision` + 1, `mutation_token` = ?%s
WHERE `id` = ? AND `revision` = ? AND `status` = ?
AND `status` NOT IN ('completed', 'cancelled')
]]):format(audience_unread_update),
params = { new_status, mutation_token, request_id, revision, access.row.status },
},
}
if access.audience == "customer" then
statements[#statements + 1] = {
query = [[
INSERT INTO `sky_phone_company_request_messages`
(`id`, `request_id`, `sender_type`, `sender_sim_id`, `body`)
SELECT ?, `id`, 'customer', ?, ? FROM `sky_phone_company_requests`
WHERE `id` = ? AND `revision` = ? AND `mutation_token` = ?
]],
params = { message_id, access.row.customer_sim_id, body, request_id, revision + 1, mutation_token },
}
else
statements[#statements + 1] = {
query = [[
INSERT INTO `sky_phone_company_request_messages`
(`id`, `request_id`, `sender_type`, `sender_identifier`, `body`)
SELECT ?, `id`, 'company', ?, ? FROM `sky_phone_company_requests`
WHERE `id` = ? AND `revision` = ? AND `mutation_token` = ?
]],
params = { message_id, access.member.identifier, body, request_id, revision + 1, mutation_token },
}
end
if service_line then
if access.audience == "customer" then
statements[#statements + 1] = {
query = [[
INSERT INTO `sky_phone_sms_messages`
(`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`,
`message_type`, `body`)
SELECT message.`id`, request.`customer_sim_id`, NULL, sim.`phone_number`, ?, 'text', message.`body`
FROM `sky_phone_company_request_messages` message
INNER JOIN `sky_phone_company_requests` request ON request.`id` = message.`request_id`
INNER JOIN `sky_phone_sims` sim ON sim.`id` = request.`customer_sim_id`
WHERE message.`id` = ? AND message.`sender_type` = 'customer'
]],
params = { service_line.number, message_id },
}
else
statements[#statements + 1] = {
query = [[
INSERT INTO `sky_phone_sms_messages`
(`id`, `sender_sim_id`, `recipient_sim_id`, `sender_number`, `recipient_number`,
`message_type`, `body`)
SELECT message.`id`, NULL, request.`customer_sim_id`, ?, sim.`phone_number`, 'text', message.`body`
FROM `sky_phone_company_request_messages` message
INNER JOIN `sky_phone_company_requests` request ON request.`id` = message.`request_id`
INNER JOIN `sky_phone_sims` sim ON sim.`id` = request.`customer_sim_id`
WHERE message.`id` = ? AND message.`sender_type` = 'company'
]],
params = { service_line.number, message_id },
}
end
end
if new_status ~= access.row.status then
statements[#statements + 1] = {
query = [[
INSERT INTO `sky_phone_company_request_events`
(`id`, `request_id`, `event_type`, `actor_type`, `from_status`, `to_status`)
SELECT ?, `id`, 'status', 'customer', ?, ? FROM `sky_phone_company_requests`
WHERE `id` = ? AND `revision` = ? AND `mutation_token` = ?
]],
params = { uuid(), access.row.status, new_status, request_id, revision + 1, mutation_token },
}
end
if not Bridge.Database.Transaction(statements) then
return { success = false, error = "request_failed" }
end
local inserted = Bridge.Database.Query([[
SELECT message.`id`, sms.`id` AS `sms_id`
FROM `sky_phone_company_request_messages` message
LEFT JOIN `sky_phone_sms_messages` sms ON sms.`id` = message.`id`
WHERE message.`id` = ? LIMIT 1
]], { message_id })
if not inserted[1] then
return { success = false, error = "revision_conflict" }
end
if service_line and not inserted[1].sms_id then
Bridge.Debug(
"error",
"[sky_phone] Company request message %s committed without its service-line SMS.",
tostring(message_id),
{ always = true }
)
return { success = false, error = "request_failed" }
end
local row = request_row(request_id)
emit_request_change(row, true, true, source)
if access.audience == "customer" then
if service_line then
TriggerClientEvent("sky_phone:messages:changed", source, {
phoneNumber = service_line.number,
})
end
local notification = notification_payload("newMessage", "work", row)
if row.assigned_identifier then
notify_identifier(
row.assigned_identifier,
row.company_id,
"sky_phone:companies:notification",
notification
)
end
elseif service_line then
notify_sim(row.customer_sim_id, "sky_phone:messages:new", {
phoneNumber = service_line.number,
sender = service_line.name,
voice = false,
})
else
notify_sim(row.customer_sim_id, "sky_phone:companies:notification", notification_payload(
"newMessage",
"customer",
row
))
end
local payload, payload_error = mutation_request_payload(source, request_id)
if not payload then
return payload_error
end
return { success = true, data = payload }
end)
Bridge.Callbacks.Register("sky_phone:companies:claim-request", function(source, data)
local allowed, rate_error = allow_mutation(source, "claim_request", "RequestAction")
if not allowed then
return rate_error
end
local member, member_error = require_permission(source, "WorkQueue")
if not member then
return member_error
end
local request_id = type(data) == "table" and data.requestId or nil
local revision = type(data) == "table" and valid_integer(data.revision, 1, 4294967295) or nil
if not valid_uuid(request_id) or not revision then
return { success = false, error = "invalid_request" }
end
local mutation_token = uuid()
local event_id = uuid()
if not Bridge.Database.Transaction({
{
query = [[
UPDATE `sky_phone_company_requests`
SET `assigned_identifier` = ?, `status` = 'assigned', `revision` = `revision` + 1,
`customer_unread` = `customer_unread` + 1, `mutation_token` = ?
WHERE `id` = ? AND `company_id` = ? AND `revision` = ?
AND `status` = 'new' AND `assigned_identifier` IS NULL
]],
params = { member.identifier, mutation_token, request_id, member.company_id, revision },
},
{
query = [[
INSERT INTO `sky_phone_company_request_events`
(`id`, `request_id`, `event_type`, `actor_type`, `actor_identifier`, `from_status`, `to_status`)
SELECT ?, `id`, 'assigned', 'company', ?, 'new', 'assigned'
FROM `sky_phone_company_requests`
WHERE `id` = ? AND `revision` = ? AND `mutation_token` = ?
]],
params = { event_id, member.identifier, request_id, revision + 1, mutation_token },
},
}) then
return { success = false, error = "request_failed" }
end
local committed = Bridge.Database.Query(
"SELECT `id` FROM `sky_phone_company_request_events` WHERE `id` = ? LIMIT 1",
{ event_id }
)
if not committed[1] then
local current = Bridge.Database.Query([[
SELECT `id` FROM `sky_phone_company_requests`
WHERE `id` = ? AND `company_id` = ?
LIMIT 1
]], { request_id, member.company_id })
return { success = false, error = current[1] and "revision_conflict" or "request_not_found" }
end
local row = request_row(request_id)
emit_request_change(row, true, true, source)
notify_sim(row.customer_sim_id, "sky_phone:companies:notification", notification_payload(
"assigned",
"customer",
row
))
local payload, payload_error = mutation_request_payload(source, request_id)
if not payload then
return payload_error
end
return { success = true, data = payload }
end)
local function resolve_member(member_id, company_id)
if not valid_uuid(member_id) then
return nil
end
local online = {}
for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do
online[tonumber(player_source) or player_source] = true
end
for target, token in pairs(member_tokens) do
if token.token == member_id and token.company_id == company_id and online[target]
and os.time() - token.created_at <= 300
then
local target_member = membership(target)
if not target_member or target_member.company_id ~= company_id
or target_member.grade < permission_grade(target_member.definition, "WorkQueue")
then
return nil
end
local identifier = Bridge.Framework.GetIdentifier(target)
if type(identifier) ~= "string" or identifier == "" or identifier ~= token.identifier then
return nil
end
return { source = target, identifier = identifier }
end
end
return nil
end
Bridge.Callbacks.Register("sky_phone:companies:assign-request", function(source, data)
local allowed, rate_error = allow_mutation(source, "assign_request", "RequestAction")
if not allowed then
return rate_error
end
local member, member_error = require_permission(source, "Assign")
if not member then
return member_error
end
local request_id = type(data) == "table" and data.requestId or nil
local revision = type(data) == "table" and valid_integer(data.revision, 1, 4294967295) or nil
local target = type(data) == "table" and resolve_member(data.memberId, member.company_id) or nil
if not valid_uuid(request_id) or not revision or not target then
return { success = false, error = "invalid_request" }
end
local row = request_row(request_id)
if not row or row.company_id ~= member.company_id then
return { success = false, error = "request_not_found" }
end
if terminal_statuses[row.status] then
return { success = false, error = "invalid_status" }
end
if row.assigned_identifier == target.identifier then
return { success = false, error = "invalid_status" }
end
local next_status = row.status == "new" and "assigned" or row.status
local mutation_token = uuid()
local event_id = uuid()
if not Bridge.Database.Transaction({
{
query = [[
UPDATE `sky_phone_company_requests`
SET `assigned_identifier` = ?, `status` = ?, `revision` = `revision` + 1,
`customer_unread` = `customer_unread` + 1,
`company_activity_revision` = `company_activity_revision` + 1,
`mutation_token` = ?
WHERE `id` = ? AND `company_id` = ? AND `revision` = ? AND `status` = ?
AND `status` NOT IN ('completed', 'cancelled')
]],
params = {
target.identifier,
next_status,
mutation_token,
request_id,
member.company_id,
revision,
row.status,
},
},
{
query = [[
INSERT INTO `sky_phone_company_request_events`
(`id`, `request_id`, `event_type`, `actor_type`, `actor_identifier`, `from_status`, `to_status`)
SELECT ?, `id`, 'assigned', 'company', ?, ?, ?
FROM `sky_phone_company_requests`
WHERE `id` = ? AND `revision` = ? AND `mutation_token` = ?
]],
params = {
event_id,
member.identifier,
row.status,
next_status,
request_id,
revision + 1,
mutation_token,
},
},
}) then
return { success = false, error = "request_failed" }
end
local committed = Bridge.Database.Query(
"SELECT `id` FROM `sky_phone_company_request_events` WHERE `id` = ? LIMIT 1",
{ event_id }
)
if not committed[1] then
return { success = false, error = "revision_conflict" }
end
row = request_row(request_id)
emit_request_change(row, true, true, source)
notify_identifier(
target.identifier,
row.company_id,
"sky_phone:companies:notification",
notification_payload("assigned", "work", row)
)
notify_sim(row.customer_sim_id, "sky_phone:companies:notification", notification_payload(
"assigned",
"customer",
row
))
local payload, payload_error = mutation_request_payload(source, request_id)
if not payload then
return payload_error
end
return { success = true, data = payload }
end)
Bridge.Callbacks.Register("sky_phone:companies:update-request-status", function(source, data)
local allowed, rate_error = allow_mutation(source, "update_request_status", "RequestAction")
if not allowed then
return rate_error
end
local request_id = type(data) == "table" and data.requestId or nil
local revision = type(data) == "table" and valid_integer(data.revision, 1, 4294967295) or nil
local status = type(data) == "table" and data.status or nil
if not valid_uuid(request_id) or not revision or not Config.Companies.Statuses[status] then
return { success = false, error = "invalid_request" }
end
local access, access_error = request_access(source, request_id)
if not access then
return access_error
end
if access.audience ~= "company" or not can_handle_request(access) then
return { success = false, error = "not_authorized" }
end
if not status_transitions[access.row.status] or not status_transitions[access.row.status][status] then
return { success = false, error = "invalid_status" }
end
local mutation_token = uuid()
local event_id = uuid()
if not Bridge.Database.Transaction({
{
query = [[
UPDATE `sky_phone_company_requests`
SET `status` = ?, `revision` = `revision` + 1, `customer_unread` = `customer_unread` + 1,
`completed_at` = CASE WHEN ? = 'completed' THEN CURRENT_TIMESTAMP ELSE `completed_at` END,
`cancelled_at` = CASE WHEN ? = 'cancelled' THEN CURRENT_TIMESTAMP ELSE `cancelled_at` END,
`mutation_token` = ?
WHERE `id` = ? AND `company_id` = ? AND `revision` = ? AND `status` = ?
]],
params = {
status,
status,
status,
mutation_token,
request_id,
access.row.company_id,
revision,
access.row.status,
},
},
{
query = [[
INSERT INTO `sky_phone_company_request_events`
(`id`, `request_id`, `event_type`, `actor_type`, `actor_identifier`, `from_status`, `to_status`)
SELECT ?, `id`, ?, 'company', ?, ?, ?
FROM `sky_phone_company_requests`
WHERE `id` = ? AND `revision` = ? AND `mutation_token` = ?
]],
params = {
event_id,
status == "cancelled" and "cancelled" or "status",
access.member.identifier,
access.row.status,
status,
request_id,
revision + 1,
mutation_token,
},
},
}) then
return { success = false, error = "request_failed" }
end
local committed = Bridge.Database.Query(
"SELECT `id` FROM `sky_phone_company_request_events` WHERE `id` = ? LIMIT 1",
{ event_id }
)
if not committed[1] then
return { success = false, error = "revision_conflict" }
end
local row = request_row(request_id)
emit_request_change(row, true, true, source)
notify_sim(row.customer_sim_id, "sky_phone:companies:notification", notification_payload(
"requestUpdated",
"customer",
row
))
local payload, payload_error = mutation_request_payload(source, request_id)
if not payload then
return payload_error
end
return { success = true, data = payload }
end)
Bridge.Callbacks.Register("sky_phone:companies:call-customer", function(source, data)
local allowed, rate_error = allow_mutation(source, "call_customer", "RequestAction")
if not allowed then
return rate_error
end
local request_id = type(data) == "table" and data.requestId or nil
if not valid_uuid(request_id) then
return { success = false, error = "invalid_request" }
end
local access, access_error = request_access(source, request_id)
if not access then
return access_error
end
if access.audience ~= "company" or not can_handle_request(access) or terminal_statuses[access.row.status] then
return { success = false, error = "not_authorized" }
end
if not SkyPhoneCompanies.CanPlaceCompanyCall(source, access.row.company_id) then
return { success = false, error = "not_authorized" }
end
local rows = Bridge.Database.Query([[
SELECT sim.`phone_number`
FROM `sky_phone_company_requests` request
INNER JOIN `sky_phone_sims` sim ON sim.`id` = request.`customer_sim_id`
WHERE request.`id` = ? AND request.`company_id` = ?
LIMIT 1
]], { request_id, access.row.company_id })
if not rows[1] then
return { success = false, error = "call_unavailable" }
end
local result = SkyPhoneCalls.StartCompanyCall(source, access.row.company_id, rows[1].phone_number)
if not result.success and (result.error == "recipient_unavailable" or result.error == "company_unavailable") then
return { success = false, error = "call_unavailable" }
end
return result
end)
Bridge.Callbacks.Register("sky_phone:companies:dial-service-line", function(source, data)
local allowed, rate_error = allow_mutation(source, "dial_service_line", "RequestAction")
if not allowed then
return rate_error
end
local phone_number = type(data) == "table" and data.phoneNumber or nil
if type(phone_number) ~= "string" or phone_number == "" then
return { success = false, error = "invalid_number" }
end
local member = call_member(source)
if not member or member.definition.ServiceLine.CanCall ~= true then
return { success = false, error = "not_authorized" }
end
return SkyPhoneCalls.StartCompanyCall(source, member.company_id, phone_number)
end)
local function company_mutation_payload(source, company_id)
local company = company_payload(company_id, true)
if not company then
return nil
end
company.updatedAtUnix = nil
return { company = company, context = work_context(source) }
end
local function audit_statement(company_id, identifier, action, target_type, target_id, mutation_token, audit_id)
return {
query = [[
INSERT INTO `sky_phone_company_audit`
(`id`, `company_id`, `actor_identifier`, `action`, `target_type`, `target_id`, `metadata`)
SELECT ?, `company_id`, ?, ?, ?, ?, ?
FROM `sky_phone_company_profiles`
WHERE `company_id` = ? AND `mutation_token` = ?
]],
params = {
audit_id,
identifier,
action,
target_type,
target_id,
json.encode({ revisionChecked = true }),
company_id,
mutation_token,
},
}
end
local function audit_committed(audit_id)
local rows = Bridge.Database.Query([[
SELECT `id` FROM `sky_phone_company_audit`
WHERE `id` = ?
LIMIT 1
]], { audit_id })
return rows[1] ~= nil
end
Bridge.Callbacks.Register("sky_phone:companies:update-availability", function(source, data)
local allowed, rate_error = allow_mutation(source, "update_availability", "Profile")
if not allowed then
return rate_error
end
local member, member_error = require_permission(source, "Availability")
if not member then
return member_error
end
local availability = type(data) == "table" and data.availability or nil
local revision = type(data) == "table" and valid_integer(data.revision, 1, 4294967295) or nil
if not Config.Companies.AvailabilityStatuses[availability] or not revision then
return { success = false, error = "invalid_request" }
end
local expiry = parse_expiry(data.expiresAt, Config.Companies.AvailabilityMaximumSeconds)
if expiry == false then
return { success = false, error = "invalid_expiration" }
end
local mutation_token = uuid()
local audit_id = uuid()
local update_query
local update_params
if expiry then
update_query = [[
UPDATE `sky_phone_company_profiles`
SET `availability` = ?, `availability_updated_by` = ?,
`availability_updated_at` = CURRENT_TIMESTAMP,
`availability_expires_at` = FROM_UNIXTIME(?),
`revision` = `revision` + 1, `mutation_token` = ?
WHERE `company_id` = ? AND `revision` = ?
]]
update_params = { availability, member.identifier, expiry, mutation_token, member.company_id, revision }
else
update_query = [[
UPDATE `sky_phone_company_profiles`
SET `availability` = ?, `availability_updated_by` = ?,
`availability_updated_at` = CURRENT_TIMESTAMP, `availability_expires_at` = NULL,
`revision` = `revision` + 1, `mutation_token` = ?
WHERE `company_id` = ? AND `revision` = ?
]]
update_params = { availability, member.identifier, mutation_token, member.company_id, revision }
end
if not Bridge.Database.Transaction({
{ query = update_query, params = update_params },
audit_statement(
member.company_id,
member.identifier,
"update_availability",
"profile",
member.company_id,
mutation_token,
audit_id
),
}) then
return { success = false, error = "request_failed" }
end
if not audit_committed(audit_id) then
return { success = false, error = "revision_conflict" }
end
emit_public_change(member.company_id)
return { success = true, data = company_mutation_payload(source, member.company_id) }
end)
local function media_id_for_profile(source, value)
local media_id = valid_integer(value, 1, 9007199254740991)
if not media_id or not SkyPhoneMedia.ResolveOwnedMedia(source, media_id, "photo") then
return nil
end
return media_id
end
Bridge.Callbacks.Register("sky_phone:companies:update-profile", function(source, data)
local allowed, rate_error = allow_mutation(source, "update_profile", "Profile")
if not allowed then
return rate_error
end
local member, member_error = require_permission(source, "Profile")
if not member then
return member_error
end
if type(data) ~= "table" then
return { success = false, error = "invalid_request" }
end
local revision = valid_integer(data.revision, 1, 4294967295)
local description = valid_text(data.description, Config.Companies.ProfileDescriptionMaxLength, true)
local district = valid_text(data.district, Config.Companies.DistrictMaxLength, true)
local location_label = valid_text(data.locationLabel or "", Config.Companies.DistrictMaxLength, true)
local address = valid_text(data.address, Config.Companies.AddressMaxLength, true)
if not revision or not description or not district or not location_label or not address
or type(data.acceptsRequests) ~= "boolean"
then
return { success = false, error = "invalid_profile" }
end
if data.phoneNumber ~= nil then
local number = SkyPhoneSimNumber.NormalizeService(data.phoneNumber, Config.Sim.NumberLength)
if number ~= member.definition.ServiceLine.Number then
return { success = false, error = "invalid_profile" }
end
end
local set_parts = {
"`description` = ?",
"`district` = ?",
"`location_label` = ?",
"`address` = ?",
"`accepts_requests` = ?",
}
local parameters = { description, district, location_label, address, data.acceptsRequests and 1 or 0 }
if data.coords ~= nil then
if type(data.coords) ~= "table" then
return { success = false, error = "invalid_profile" }
end
local x = tonumber(data.coords.x)
local y = tonumber(data.coords.y)
local z = tonumber(data.coords.z)
if not x or not y or not z or x ~= x or y ~= y or z ~= z
or math.abs(x) > 10000 or math.abs(y) > 10000 or math.abs(z) > 2000
then
return { success = false, error = "invalid_profile" }
end
set_parts[#set_parts + 1] = "`location_x` = ?"
set_parts[#set_parts + 1] = "`location_y` = ?"
set_parts[#set_parts + 1] = "`location_z` = ?"
parameters[#parameters + 1] = x
parameters[#parameters + 1] = y
parameters[#parameters + 1] = z
end
for _, media_field in ipairs({
{ input = "logoMediaId", column = "logo_media_id" },
{ input = "coverMediaId", column = "cover_media_id" },
}) do
if data[media_field.input] ~= nil then
local media_id = media_id_for_profile(source, data[media_field.input])
if not media_id then
return { success = false, error = "invalid_media" }
end
set_parts[#set_parts + 1] = ("`%s` = ?"):format(media_field.column)
parameters[#parameters + 1] = media_id
end
end
local mutation_token = uuid()
local audit_id = uuid()
set_parts[#set_parts + 1] = "`revision` = `revision` + 1"
set_parts[#set_parts + 1] = "`mutation_token` = ?"
parameters[#parameters + 1] = mutation_token
parameters[#parameters + 1] = member.company_id
parameters[#parameters + 1] = revision
if not Bridge.Database.Transaction({
{
query = ("UPDATE `sky_phone_company_profiles` SET %s WHERE `company_id` = ? AND `revision` = ?")
:format(table.concat(set_parts, ", ")),
params = parameters,
},
audit_statement(
member.company_id,
member.identifier,
"update_profile",
"profile",
member.company_id,
mutation_token,
audit_id
),
}) then
return { success = false, error = "request_failed" }
end
if not audit_committed(audit_id) then
return { success = false, error = "revision_conflict" }
end
emit_public_change(member.company_id)
return { success = true, data = company_mutation_payload(source, member.company_id) }
end)
local function valid_clock(value)
if type(value) ~= "string" then
return false
end
local hour, minute = value:match("^(%d%d):(%d%d)$")
hour = tonumber(hour)
minute = tonumber(minute)
return hour and minute and hour <= 23 and minute <= 59
end
Bridge.Callbacks.Register("sky_phone:companies:update-hours", function(source, data)
local allowed, rate_error = allow_mutation(source, "update_hours", "Profile")
if not allowed then
return rate_error
end
local member, member_error = require_permission(source, "Hours")
if not member then
return member_error
end
local revision = type(data) == "table" and valid_integer(data.revision, 1, 4294967295) or nil
local hours = type(data) == "table" and data.hours or nil
if not revision or not valid_array(hours, 7) then
return { success = false, error = "invalid_profile" }
end
local seen_days = {}
for _, entry in ipairs(hours) do
local day = type(entry) == "table" and valid_integer(entry.day, 0, 6) or nil
if not day or seen_days[day] or type(entry.isClosed) ~= "boolean"
or (not entry.isClosed and (not valid_clock(entry.opensAt) or not valid_clock(entry.closesAt)
or entry.opensAt == entry.closesAt))
then
return { success = false, error = "invalid_profile" }
end
seen_days[day] = true
end
local mutation_token = uuid()
local audit_id = uuid()
local statements = {
{
query = [[
UPDATE `sky_phone_company_profiles`
SET `revision` = `revision` + 1, `mutation_token` = ?
WHERE `company_id` = ? AND `revision` = ?
]],
params = { mutation_token, member.company_id, revision },
},
{
query = [[
DELETE hours FROM `sky_phone_company_hours` hours
INNER JOIN `sky_phone_company_profiles` profile ON profile.`company_id` = hours.`company_id`
WHERE hours.`company_id` = ? AND profile.`mutation_token` = ?
]],
params = { member.company_id, mutation_token },
},
}
for _, entry in ipairs(hours) do
local query
local params
if entry.isClosed then
query = [[
INSERT INTO `sky_phone_company_hours` (`company_id`, `weekday`, `is_closed`)
SELECT `company_id`, ?, 1 FROM `sky_phone_company_profiles`
WHERE `company_id` = ? AND `mutation_token` = ?
]]
params = { entry.day, member.company_id, mutation_token }
else
query = [[
INSERT INTO `sky_phone_company_hours`
(`company_id`, `weekday`, `is_closed`, `opens_at`, `closes_at`)
SELECT `company_id`, ?, 0, ?, ? FROM `sky_phone_company_profiles`
WHERE `company_id` = ? AND `mutation_token` = ?
]]
params = { entry.day, entry.opensAt, entry.closesAt, member.company_id, mutation_token }
end
statements[#statements + 1] = { query = query, params = params }
end
statements[#statements + 1] = audit_statement(
member.company_id,
member.identifier,
"update_hours",
"hours",
member.company_id,
mutation_token,
audit_id
)
if not Bridge.Database.Transaction(statements) then
return { success = false, error = "request_failed" }
end
if not audit_committed(audit_id) then
return { success = false, error = "revision_conflict" }
end
emit_public_change(member.company_id)
return { success = true, data = company_mutation_payload(source, member.company_id) }
end)
Bridge.Callbacks.Register("sky_phone:companies:update-services", function(source, data)
local allowed, rate_error = allow_mutation(source, "update_services", "Profile")
if not allowed then
return rate_error
end
local member, member_error = require_permission(source, "Services")
if not member then
return member_error
end
local revision = type(data) == "table" and valid_integer(data.revision, 1, 4294967295) or nil
local services = type(data) == "table" and data.services or nil
if not revision or not valid_array(services, Config.Companies.MaximumServices) then
return { success = false, error = "invalid_profile" }
end
local existing_rows = Bridge.Database.Query(
"SELECT `id` FROM `sky_phone_company_services` WHERE `company_id` = ? AND `archived` = 0",
{ member.company_id }
)
local existing = {}
for _, row in ipairs(existing_rows) do
existing[row.id] = true
end
local normalized = {}
local seen = {}
for index, service in ipairs(services) do
if type(service) ~= "table" or type(service.active) ~= "boolean"
or type(service.acceptsRequests) ~= "boolean"
then
return { success = false, error = "invalid_profile" }
end
local title = valid_text(service.title, Config.Companies.ServiceTitleMaxLength, false)
local description = valid_text(
service.description or "",
Config.Companies.ServiceDescriptionMaxLength,
true
)
local price = service.priceText == nil and ""
or valid_text(service.priceText, Config.Companies.ServicePriceMaxLength, true)
if not title or not description or not price then
return { success = false, error = "invalid_profile" }
end
local service_id = valid_service_id(service.id) and existing[service.id] and service.id or uuid()
if seen[service_id] then
return { success = false, error = "invalid_profile" }
end
seen[service_id] = true
normalized[#normalized + 1] = {
id = service_id,
title = title,
description = description,
price = price,
accepts_requests = service.acceptsRequests,
active = service.active,
sort_order = index,
}
end
local mutation_token = uuid()
local audit_id = uuid()
local statements = {
{
query = [[
UPDATE `sky_phone_company_profiles`
SET `revision` = `revision` + 1, `mutation_token` = ?
WHERE `company_id` = ? AND `revision` = ?
]],
params = { mutation_token, member.company_id, revision },
},
{
query = [[
UPDATE `sky_phone_company_services` service
INNER JOIN `sky_phone_company_profiles` profile ON profile.`company_id` = service.`company_id`
SET service.`active` = 0, service.`requests_enabled` = 0, service.`archived` = 1
WHERE service.`company_id` = ? AND profile.`mutation_token` = ?
]],
params = { member.company_id, mutation_token },
},
}
for _, service in ipairs(normalized) do
statements[#statements + 1] = {
query = [[
INSERT INTO `sky_phone_company_services`
(`id`, `company_id`, `title`, `description`, `price_text`, `requests_enabled`, `active`, `sort_order`)
SELECT ?, `company_id`, ?, ?, ?, ?, ?, ? FROM `sky_phone_company_profiles`
WHERE `company_id` = ? AND `mutation_token` = ?
ON DUPLICATE KEY UPDATE
`title` = VALUES(`title`), `description` = VALUES(`description`),
`price_text` = VALUES(`price_text`), `requests_enabled` = VALUES(`requests_enabled`),
`active` = VALUES(`active`), `archived` = 0, `sort_order` = VALUES(`sort_order`)
]],
params = {
service.id,
service.title,
service.description,
service.price,
service.accepts_requests and 1 or 0,
service.active and 1 or 0,
service.sort_order,
member.company_id,
mutation_token,
},
}
end
statements[#statements + 1] = audit_statement(
member.company_id,
member.identifier,
"update_services",
"services",
member.company_id,
mutation_token,
audit_id
)
if not Bridge.Database.Transaction(statements) then
return { success = false, error = "request_failed" }
end
if not audit_committed(audit_id) then
return { success = false, error = "revision_conflict" }
end
emit_public_change(member.company_id)
return { success = true, data = company_mutation_payload(source, member.company_id) }
end)
Bridge.Callbacks.Register("sky_phone:companies:publish-announcement", function(source, data)
local allowed, rate_error = allow_mutation(source, "publish_announcement", "Profile")
if not allowed then
return rate_error
end
local member, member_error = require_permission(source, "Announcement")
if not member then
return member_error
end
local revision = type(data) == "table" and valid_integer(data.revision, 1, 4294967295) or nil
local body = type(data) == "table"
and valid_text(data.body or "", Config.Companies.AnnouncementBodyMaxLength, true) or nil
if not revision or body == nil then
return { success = false, error = "invalid_profile" }
end
local expiry = parse_expiry(data.expiresAt, Config.Companies.AnnouncementMaximumSeconds)
if expiry == false then
return { success = false, error = "invalid_expiration" }
end
local mutation_token = uuid()
local audit_id = uuid()
local statements = {
{
query = [[
UPDATE `sky_phone_company_profiles`
SET `revision` = `revision` + 1, `mutation_token` = ?
WHERE `company_id` = ? AND `revision` = ?
]],
params = { mutation_token, member.company_id, revision },
},
{
query = [[
UPDATE `sky_phone_company_announcements` announcement
INNER JOIN `sky_phone_company_profiles` profile ON profile.`company_id` = announcement.`company_id`
SET announcement.`active` = 0
WHERE announcement.`company_id` = ? AND profile.`mutation_token` = ?
]],
params = { member.company_id, mutation_token },
},
}
if body ~= "" then
local insert_query
local insert_params
if expiry then
insert_query = [[
INSERT INTO `sky_phone_company_announcements`
(`id`, `company_id`, `title`, `body`, `created_by`, `expires_at`)
SELECT ?, `company_id`, '', ?, ?, FROM_UNIXTIME(?) FROM `sky_phone_company_profiles`
WHERE `company_id` = ? AND `mutation_token` = ?
]]
insert_params = { uuid(), body, member.identifier, expiry, member.company_id, mutation_token }
else
insert_query = [[
INSERT INTO `sky_phone_company_announcements`
(`id`, `company_id`, `title`, `body`, `created_by`)
SELECT ?, `company_id`, '', ?, ? FROM `sky_phone_company_profiles`
WHERE `company_id` = ? AND `mutation_token` = ?
]]
insert_params = { uuid(), body, member.identifier, member.company_id, mutation_token }
end
statements[#statements + 1] = { query = insert_query, params = insert_params }
end
statements[#statements + 1] = audit_statement(
member.company_id,
member.identifier,
"publish_announcement",
"announcement",
member.company_id,
mutation_token,
audit_id
)
if not Bridge.Database.Transaction(statements) then
return { success = false, error = "request_failed" }
end
if not audit_committed(audit_id) then
return { success = false, error = "revision_conflict" }
end
emit_public_change(member.company_id)
return { success = true, data = company_mutation_payload(source, member.company_id) }
end)
Bridge.Callbacks.Register("sky_phone:companies:set-call-availability", function(source, data)
local allowed, rate_error = allow_mutation(source, "set_call_availability", "CallAvailability")
if not allowed then
return rate_error
end
if type(data) ~= "table" or type(data.available) ~= "boolean" then
return { success = false, error = "invalid_request" }
end
if not data.available then
SkyPhoneCompanies.ClearCallAvailability(source)
return { success = true, data = { context = work_context(source) } }
end
local member = call_member(source)
if not member or not member.definition.ServiceLine.CanCall then
return { success = false, error = "not_authorized" }
end
local device, device_error = current_device(source, true)
if not device then
return device_error
end
call_availability[source] = {
company_id = member.company_id,
sim_id = device.sim_id,
imei = device.imei,
}
return { success = true, data = { context = work_context(source) } }
end)
AddEventHandler("playerDropped", function()
SkyPhoneCompanies.ClearCallAvailability(source)
member_tokens[source] = nil
end)
AddEventHandler("onResourceStop", function(resource_name)
if resource_name ~= GetCurrentResourceName() then
return
end
call_availability = {}
round_robin_positions = {}
member_tokens = {}
end)
end)