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 if line.CanMessage then return nil, ("[sky_phone] Company '%s' enables messaging without a virtual service-line message router."):format( company_id ) 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.`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 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 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 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 `id` FROM `sky_phone_company_request_messages` WHERE `id` = ? LIMIT 1", { message_id } ) if not inserted[1] then return { success = false, error = "revision_conflict" } end local row = request_row(request_id) emit_request_change(row, true, true, source) if access.audience == "customer" then 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 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)