Files
sky_phone-sky-systems/sky_phone/source/server/marketplace.lua
T
DerEchteAlec a9143b0fba ADD - add in-game phone administration and configuration (#25)
* ADD - build in-game admin command center

* ADD - open admin panel by command

* ENH - rebuild admin panel as standalone editor

* ENH - compact admin workspace navigation

* ENH - expand admin device controls

* ENH - refine admin panel navigation style

* ADD - add SQL phone configurator

* FIX - expose complete phone configuration

* FIX - make company jobs freely configurable

* FIX - align configurator tables to left

* ENH - organize configurator collection controls

* ENH - organize nested configurator sections

* ENH - add configurator field descriptions

* FIX - hide fixed configurator add controls

* ENH - refine configurator editing experience

* ENH - refine admin configurator controls

* FIX - apply configurator settings instantly

* FIX - close live activity command registration

---------

Co-authored-by: Leon.Schmidt <leonmauricewill15@gmail.com>
2026-08-22 03:26:52 +02:00

1120 lines
49 KiB
Lua

Bridge.Database.AfterMigration("sky_phone", function()
local categories = {}
local districts = {}
local item_conditions = { new = true, very_good = true, used = true, defective = true }
local price_types = { fixed = true, negotiable = true, free = true }
local report_reasons = { prohibited = true, fraud = true, spam = true, offensive = true, other = true }
local offer_responses = { accepted = true, rejected = true }
local public_statuses = { active = true, reserved = true }
local seller_statuses = { active = true, reserved = true, sold = true, removed = true }
local function refresh_runtime_configuration()
categories = {}
districts = {}
for _, category in ipairs(Config.Marketplace.Categories) do
categories[category] = true
end
for _, district in ipairs(Config.Marketplace.Districts) do
districts[district] = true
end
end
refresh_runtime_configuration()
AddEventHandler("sky_phone:configurator:serverUpdated", function()
refresh_runtime_configuration()
end)
local function trim(value)
if type(value) ~= "string" then
return nil
end
return value:match("^%s*(.-)%s*$")
end
local function valid_text(value, minimum, maximum)
local length = type(value) == "string" and utf8.len(value) or nil
return length and length >= minimum and length <= maximum
end
local function affected_rows(result)
if type(result) == "number" then
return result
end
return type(result) == "table" and tonumber(result.affectedRows) or 0
end
local function insert_id(result)
if type(result) == "number" then
return result
end
return type(result) == "table" and tonumber(result.insertId) or nil
end
local function new_id()
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 marketplace id.")
end
return rows[1].id
end
local function require_payload(source, operation, data)
if type(data) == "table" then
return data
end
Bridge.Debug(
"warn",
"[sky_phone] Invalid marketplace payload for %s from source %s.",
operation,
tostring(source)
)
return nil
end
local function optional_account(source)
local session, error_response = SkyPhone.RequireSession(source)
if not session then
return nil, nil, error_response
end
local device = SkyPhone.LoadDevice(session.imei)
return device and tonumber(device.account_id) or nil, device, nil
end
local function require_account(source)
return SkyPhone.RequireAccount(source)
end
local function default_display_name(account)
return account.email:match("^([^@]+)") or account.email
end
local function profile_dto(account)
local rows = Bridge.Database.Query([[
SELECT profile.`display_name`, profile.`bio`, profile.`avatar_media_id`,
avatar.`url` AS `avatar_url`,
(SELECT COUNT(*) FROM `sky_phone_marketplace_listings` listing
WHERE listing.`seller_account_id` = ?) AS `listing_count`
FROM `sky_phone_accounts` account
LEFT JOIN `sky_phone_marketplace_profiles` profile ON profile.`account_id` = account.`id`
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = profile.`avatar_media_id`
WHERE account.`id` = ?
LIMIT 1
]], { account.id, account.id })
local row = rows[1]
if not row then
error(("[sky_phone] Could not load CityMarkt profile account %s."):format(account.id))
end
return {
avatar_media_id = tonumber(row.avatar_media_id),
avatar_url = row.avatar_url,
bio = row.bio or "",
display_name = row.display_name or default_display_name(account),
email = account.email,
exists = row.display_name ~= nil,
listing_count = tonumber(row.listing_count) or 0,
}
end
local function require_profile(account_id)
local rows = Bridge.Database.Query([[
SELECT `account_id` FROM `sky_phone_marketplace_profiles`
WHERE `account_id` = ? LIMIT 1
]], { account_id })
if not rows[1] then
return { success = false, error = "profile_required" }
end
return nil
end
Bridge.Callbacks.Register("sky_phone:marketplace:profile", function(source)
local account, error_response = require_account(source)
if not account then return error_response end
return { success = true, data = profile_dto(account) }
end)
Bridge.Callbacks.Register("sky_phone:marketplace:auth", function(source, data)
local account, error_response = require_account(source)
if not account then return error_response end
if not SkyPhone.AllowOperation(source, "marketplace:auth", 8, 60) then
return { success = false, error = "rate_limited" }
end
if type(data) ~= "table" or (data.mode ~= "login" and data.mode ~= "register") then
return { success = false, error = "invalid_request" }
end
if not valid_text(data.password, Config.Mail.PasswordMinLength, Config.Mail.PasswordMaxLength) then
return {
success = false,
error = data.mode == "register" and "invalid_password" or "invalid_credentials",
}
end
local credentials = Bridge.Database.Query([[
SELECT `id` FROM `sky_phone_accounts`
WHERE `id` = ? AND `email` = ? AND `password` = ?
LIMIT 1
]], { account.id, account.email, data.password })
if not credentials[1] then
return { success = false, error = "invalid_credentials" }
end
local profiles = Bridge.Database.Query([[
SELECT `account_id` FROM `sky_phone_marketplace_profiles`
WHERE `account_id` = ? LIMIT 1
]], { account.id })
if data.mode == "login" then
if not profiles[1] then
return { success = false, error = "profile_not_found" }
end
return { success = true, data = profile_dto(account) }
end
if profiles[1] then
return { success = false, error = "profile_exists" }
end
local avatar_media_id = tonumber(data.avatarMediaId) or 0
if avatar_media_id < 0 or avatar_media_id ~= math.floor(avatar_media_id) then
return { success = false, error = "invalid_profile_image" }
end
if avatar_media_id > 0
and not SkyPhoneMedia.ResolveOwnedMedia(source, tostring(avatar_media_id), "photo")
then
Bridge.Debug("warn", "[sky_phone] Rejected unowned CityMarkt registration image from source %s.", tostring(source))
return { success = false, error = "invalid_profile_image" }
end
local result = Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_marketplace_profiles` (`account_id`, `display_name`, `bio`, `avatar_media_id`)
VALUES (?, ?, '', NULLIF(?, 0))
]], { account.id, default_display_name(account), avatar_media_id })
if affected_rows(result) ~= 1 then
return { success = false, error = "profile_exists" }
end
return { success = true, data = profile_dto(account) }
end)
Bridge.Callbacks.Register("sky_phone:marketplace:profile-save", function(source, data)
local account, error_response = require_account(source)
if not account then return error_response end
if not SkyPhone.AllowOperation(source, "marketplace:profile-save", 12, 60) then
return { success = false, error = "rate_limited" }
end
if type(data) ~= "table" or type(data.displayName) ~= "string" or type(data.bio) ~= "string" then
return { success = false, error = "invalid_profile" }
end
local display_name = trim(data.displayName)
local bio = trim(data.bio)
local avatar_media_id = tonumber(data.avatarMediaId)
if not valid_text(display_name, 2, 40) or not valid_text(bio, 0, 160)
or not avatar_media_id or avatar_media_id < 0 or avatar_media_id ~= math.floor(avatar_media_id)
then
return { success = false, error = "invalid_profile" }
end
if avatar_media_id > 0 and not SkyPhoneMedia.ResolveOwnedMedia(source, tostring(avatar_media_id), "photo") then
Bridge.Debug("warn", "[sky_phone] Rejected unowned CityMarkt profile image from source %s.", tostring(source))
return { success = false, error = "invalid_profile_image" }
end
Bridge.Database.Query([[
INSERT INTO `sky_phone_marketplace_profiles` (`account_id`, `display_name`, `bio`, `avatar_media_id`)
VALUES (?, ?, ?, NULLIF(?, 0))
ON DUPLICATE KEY UPDATE `display_name` = VALUES(`display_name`), `bio` = VALUES(`bio`),
`avatar_media_id` = VALUES(`avatar_media_id`)
]], { account.id, display_name, bio, avatar_media_id })
return { success = true, data = profile_dto(account) }
end)
local function expire_listings()
Bridge.Database.Query([[
UPDATE `sky_phone_marketplace_listings`
SET `status` = 'expired', `reserved_account_id` = NULL, `revision` = `revision` + 1
WHERE `status` IN ('active', 'reserved') AND `expires_at` <= CURRENT_TIMESTAMP
]], {})
end
local function load_images(listing_id)
return Bridge.Database.Query([[
SELECT `media_id`, `gradient`, `sort_order`
FROM `sky_phone_marketplace_images`
WHERE `listing_id` = ?
ORDER BY `sort_order`
]], { listing_id })
end
local function validate_images(source, images)
if type(images) ~= "table" or #images > Config.Marketplace.MaxImages then
return nil
end
if #images == 0 then
return {}
end
local normalized = {}
local seen = {}
for index, image in ipairs(images) do
local media_id = type(image) == "table" and image.id or nil
local numeric_id = tonumber(media_id)
local normalized_id = numeric_id and tostring(math.floor(numeric_id)) or nil
if not numeric_id or numeric_id < 1 or numeric_id ~= math.floor(numeric_id)
or seen[normalized_id]
then
Bridge.Debug(
"warn",
"[sky_phone] Rejected unowned marketplace image from source %s.",
tostring(source)
)
return nil
end
local url = SkyPhoneMedia.ResolveOwnedMedia(source, normalized_id, "photo")
if not url then
Bridge.Debug(
"warn",
"[sky_phone] Rejected unowned marketplace image from source %s.",
tostring(source)
)
return nil
end
seen[normalized_id] = true
normalized[index] = { id = normalized_id, gradient = ("url(%s)"):format(json.encode(url)) }
end
return normalized
end
local function validate_listing(source, account, data)
local title = trim(data.title)
local description = trim(data.description)
local price_type = data.priceType
local price = tonumber(data.price)
local district = data.district == "" and nil or data.district
local show_phone = data.showPhone == true
local device = SkyPhone.LoadDevice(account.imei)
if not valid_text(title, Config.Marketplace.TitleMinLength, Config.Marketplace.TitleMaxLength)
or not valid_text(description, Config.Marketplace.DescriptionMinLength, Config.Marketplace.DescriptionMaxLength)
or not categories[data.category]
or not item_conditions[data.condition]
or not price_types[price_type]
or (district and not districts[district])
then
return nil, "invalid_listing"
end
if price_type == "free" then
price = nil
elseif not price or price ~= math.floor(price) or price < 1 or price > Config.Marketplace.MaximumPrice then
return nil, "invalid_price"
end
if show_phone and (not device or not device.phone_number) then
return nil, "phone_unavailable"
end
local images = validate_images(source, data.images)
if not images then
return nil, "invalid_images"
end
return {
title = title,
description = description,
category = data.category,
condition = data.condition,
price_type = price_type,
price = price,
district = district,
show_phone = show_phone,
phone_number = show_phone and device.phone_number or nil,
images = images,
}
end
local function listing_summary_query(account_id, where_clause, order_clause, values, limit, offset)
local query_values = { account_id or 0 }
for _, value in ipairs(values) do
query_values[#query_values + 1] = value
end
query_values[#query_values + 1] = limit
query_values[#query_values + 1] = offset
return Bridge.Database.Query(([[
SELECT l.`id`, l.`title`, l.`category`, l.`item_condition`, l.`price_type`, l.`price`,
l.`district`, l.`status`, l.`created_at`, l.`updated_at`, l.`expires_at`,
COALESCE(profile.`display_name`, SUBSTRING_INDEX(a.`email`, '@', 1)) AS `seller_name`,
(SELECT i.`gradient` FROM `sky_phone_marketplace_images` i
WHERE i.`listing_id` = l.`id` ORDER BY i.`sort_order` LIMIT 1) AS `image`,
EXISTS(SELECT 1 FROM `sky_phone_marketplace_favorites` f
WHERE f.`listing_id` = l.`id` AND f.`account_id` = ?) AS `is_favorite`
FROM `sky_phone_marketplace_listings` l
JOIN `sky_phone_accounts` a ON a.`id` = l.`seller_account_id`
LEFT JOIN `sky_phone_marketplace_profiles` profile ON profile.`account_id` = l.`seller_account_id`
WHERE %s
ORDER BY %s
LIMIT ? OFFSET ?
]]):format(where_clause, order_clause), query_values)
end
local function marketplace_counts(account_id)
local rows = Bridge.Database.Query([[
SELECT
((SELECT COUNT(*) FROM `sky_phone_marketplace_messages` m
JOIN `sky_phone_marketplace_inquiries` q ON q.`id` = m.`inquiry_id`
WHERE (q.`seller_account_id` = ? OR q.`buyer_account_id` = ?)
AND m.`sender_account_id` <> ? AND m.`read_at` IS NULL)
+ (SELECT COUNT(*) FROM `sky_phone_marketplace_offers` o
JOIN `sky_phone_marketplace_inquiries` q ON q.`id` = o.`inquiry_id`
WHERE (q.`seller_account_id` = ? OR q.`buyer_account_id` = ?)
AND ((o.`proposer_account_id` <> ? AND o.`read_at` IS NULL)
OR (o.`proposer_account_id` = ? AND o.`status` IN ('accepted', 'rejected')
AND o.`response_read_at` IS NULL)))) AS `unread`,
(SELECT COUNT(*) FROM `sky_phone_marketplace_listings`
WHERE `seller_account_id` = ? AND `status` IN ('active', 'reserved')) AS `active`
]], {
account_id, account_id, account_id,
account_id, account_id, account_id, account_id,
account_id,
})
return {
unread = tonumber(rows[1] and rows[1].unread) or 0,
active = tonumber(rows[1] and rows[1].active) or 0,
}
end
local function notify_changed(account_id)
SkyPhone.NotifyAccount(account_id, "sky_phone:marketplace:changed", {
counts = marketplace_counts(account_id),
})
end
Bridge.Callbacks.Register("sky_phone:marketplace:list", function(source, data)
local account_id, _, error_response = optional_account(source)
if error_response then
return error_response
end
data = require_payload(source, "list", data)
if not data then
return { success = false, error = "invalid_request" }
end
expire_listings()
local values = {}
local conditions = { "l.`status` IN ('active', 'reserved')" }
local search = trim(data.search) or ""
if not valid_text(search, 0, 100) then
return { success = false, error = "invalid_search" }
end
if search ~= "" then
local pattern = "%" .. search .. "%"
conditions[#conditions + 1] = "(l.`title` LIKE ? OR l.`description` LIKE ?)"
values[#values + 1] = pattern
values[#values + 1] = pattern
end
if data.category and data.category ~= "all" then
if not categories[data.category] then
return { success = false, error = "invalid_filter" }
end
conditions[#conditions + 1] = "l.`category` = ?"
values[#values + 1] = data.category
end
if data.district and data.district ~= "all" then
if not districts[data.district] then
return { success = false, error = "invalid_filter" }
end
conditions[#conditions + 1] = "l.`district` = ?"
values[#values + 1] = data.district
end
if data.favorites == true then
if not account_id then
return { success = false, error = "not_authenticated" }
end
conditions[#conditions + 1] = [[EXISTS(SELECT 1 FROM `sky_phone_marketplace_favorites` favorite_filter
WHERE favorite_filter.`listing_id` = l.`id` AND favorite_filter.`account_id` = ?)]]
values[#values + 1] = account_id
end
if account_id then
conditions[#conditions + 1] = [[NOT EXISTS(SELECT 1 FROM `sky_phone_marketplace_blocks` b
WHERE (b.`blocker_account_id` = ? AND b.`blocked_account_id` = l.`seller_account_id`)
OR (b.`blocker_account_id` = l.`seller_account_id` AND b.`blocked_account_id` = ?))]]
values[#values + 1] = account_id
values[#values + 1] = account_id
end
local sort_orders = {
newest = "l.`created_at` DESC, l.`id` DESC",
price_asc = "l.`price` IS NULL DESC, l.`price` ASC, l.`created_at` DESC",
price_desc = "l.`price` IS NULL, l.`price` DESC, l.`created_at` DESC",
}
local order_clause = sort_orders[data.sort] or sort_orders.newest
local offset = math.max(0, math.min(100000, math.floor(tonumber(data.offset) or 0)))
local rows = listing_summary_query(
account_id,
table.concat(conditions, " AND "),
order_clause,
values,
Config.Marketplace.PageSize + 1,
offset
)
local has_more = #rows > Config.Marketplace.PageSize
if has_more then
rows[#rows] = nil
end
return { success = true, data = { items = rows, hasMore = has_more, offset = offset } }
end)
Bridge.Callbacks.Register("sky_phone:marketplace:get", function(source, data)
local account_id, _, error_response = optional_account(source)
if error_response then
return error_response
end
data = require_payload(source, "get", data)
local id = data and data.id
if type(id) ~= "string" or #id ~= 36 then
return { success = false, error = "invalid_listing" }
end
expire_listings()
local rows = Bridge.Database.Query([[
SELECT l.*, COALESCE(profile.`display_name`, SUBSTRING_INDEX(a.`email`, '@', 1)) AS `seller_name`,
profile_avatar.`url` AS `seller_avatar`, a.`created_at` AS `seller_since`,
(SELECT COUNT(*) FROM `sky_phone_marketplace_listings` own
WHERE own.`seller_account_id` = l.`seller_account_id` AND own.`status` IN ('active', 'reserved')) AS `seller_active`,
EXISTS(SELECT 1 FROM `sky_phone_marketplace_favorites` f
WHERE f.`listing_id` = l.`id` AND f.`account_id` = ?) AS `is_favorite`
FROM `sky_phone_marketplace_listings` l
JOIN `sky_phone_accounts` a ON a.`id` = l.`seller_account_id`
LEFT JOIN `sky_phone_marketplace_profiles` profile ON profile.`account_id` = l.`seller_account_id`
LEFT JOIN `sky_phone_media` profile_avatar ON profile_avatar.`id` = profile.`avatar_media_id`
WHERE l.`id` = ?
LIMIT 1
]], { account_id or 0, id })
local listing = rows[1]
if not listing or (not public_statuses[listing.status] and tonumber(listing.seller_account_id) ~= account_id) then
return { success = false, error = "listing_not_found" }
end
if account_id then
local blocks = Bridge.Database.Query([[
SELECT 1 FROM `sky_phone_marketplace_blocks`
WHERE (`blocker_account_id` = ? AND `blocked_account_id` = ?)
OR (`blocker_account_id` = ? AND `blocked_account_id` = ?)
LIMIT 1
]], { account_id, listing.seller_account_id, listing.seller_account_id, account_id })
if blocks[1] then
return { success = false, error = "listing_not_found" }
end
end
listing.images = load_images(id)
listing.is_owner = account_id and tonumber(listing.seller_account_id) == account_id or false
listing.show_phone = listing.show_phone == true or tonumber(listing.show_phone) == 1
listing.phone_number = listing.show_phone and listing.phone_number or nil
listing.reserved_account_id = listing.is_owner and listing.reserved_account_id or nil
return { success = true, data = listing }
end)
Bridge.Callbacks.Register("sky_phone:marketplace:list-own", function(source, data)
local account, error_response = require_account(source)
if not account then
return error_response
end
data = require_payload(source, "list-own", data) or {}
expire_listings()
local offset = math.max(0, math.min(100000, math.floor(tonumber(data.offset) or 0)))
local rows = listing_summary_query(
account.id,
"l.`seller_account_id` = ?",
"l.`updated_at` DESC, l.`id` DESC",
{ account.id },
Config.Marketplace.PageSize + 1,
offset
)
local has_more = #rows > Config.Marketplace.PageSize
if has_more then rows[#rows] = nil end
return { success = true, data = { items = rows, hasMore = has_more, offset = offset } }
end)
Bridge.Callbacks.Register("sky_phone:marketplace:create", function(source, data)
local account, error_response = require_account(source)
if not account then return error_response end
local profile_error = require_profile(account.id)
if profile_error then return profile_error end
if not SkyPhone.AllowOperation(source, "marketplace:create", 5, 60) then
return { success = false, error = "rate_limited" }
end
data = require_payload(source, "create", data)
if not data then return { success = false, error = "invalid_request" } end
local listing, validation_error = validate_listing(source, account, data)
if not listing then return { success = false, error = validation_error } end
local counts = marketplace_counts(account.id)
if counts.active >= Config.Marketplace.MaxActiveListings then
return { success = false, error = "listing_limit" }
end
local id = new_id()
local statements = {{
query = [[
INSERT INTO `sky_phone_marketplace_listings`
(`id`, `seller_account_id`, `title`, `description`, `category`, `item_condition`,
`price_type`, `price`, `district`, `show_phone`, `phone_number`, `expires_at`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, DATE_ADD(CURRENT_TIMESTAMP, INTERVAL ? DAY))
]],
params = {
id, account.id, listing.title, listing.description, listing.category, listing.condition,
listing.price_type, listing.price, listing.district, listing.show_phone and 1 or 0,
listing.phone_number, Config.Marketplace.ListingLifetimeDays,
},
}}
for index, image in ipairs(listing.images) do
statements[#statements + 1] = {
query = [[INSERT INTO `sky_phone_marketplace_images`
(`listing_id`, `media_id`, `gradient`, `sort_order`) VALUES (?, ?, ?, ?)]],
params = { id, image.id, image.gradient, index },
}
end
if not Bridge.Database.Transaction(statements) then
return { success = false, error = "request_failed" }
end
notify_changed(account.id)
return { success = true, data = { id = id } }
end)
Bridge.Callbacks.Register("sky_phone:marketplace:update", function(source, data)
local account, error_response = require_account(source)
if not account then return error_response end
data = require_payload(source, "update", data)
local id = data and data.id
local revision = data and tonumber(data.revision)
if type(id) ~= "string" or #id ~= 36 or not revision then
return { success = false, error = "invalid_listing" }
end
local listing, validation_error = validate_listing(source, account, data)
if not listing then return { success = false, error = validation_error } end
local current_rows = Bridge.Database.Query([[
SELECT `revision` FROM `sky_phone_marketplace_listings`
WHERE `id` = ? AND `seller_account_id` = ? AND `status` IN ('active', 'reserved', 'expired')
LIMIT 1
]], { id, account.id })
if not current_rows[1] then
return { success = false, error = "listing_not_found" }
end
if tonumber(current_rows[1].revision) ~= revision then
return { success = false, error = "conflict" }
end
local statements = {
{
query = [[
UPDATE `sky_phone_marketplace_listings`
SET `title` = ?, `description` = ?, `category` = ?, `item_condition` = ?,
`price_type` = ?, `price` = ?, `district` = ?, `show_phone` = ?, `phone_number` = ?,
`revision` = `revision` + 1
WHERE `id` = ? AND `seller_account_id` = ? AND `status` IN ('active', 'reserved', 'expired')
]],
params = {
listing.title, listing.description, listing.category, listing.condition,
listing.price_type, listing.price, listing.district, listing.show_phone and 1 or 0,
listing.phone_number, id, account.id,
},
},
{ query = "DELETE FROM `sky_phone_marketplace_images` WHERE `listing_id` = ?", params = { id } },
}
for index, image in ipairs(listing.images) do
statements[#statements + 1] = {
query = [[INSERT INTO `sky_phone_marketplace_images`
(`listing_id`, `media_id`, `gradient`, `sort_order`) VALUES (?, ?, ?, ?)]],
params = { id, image.id, image.gradient, index },
}
end
if not Bridge.Database.Transaction(statements) then
return { success = false, error = "request_failed" }
end
local rows = Bridge.Database.Query(
"SELECT `revision` FROM `sky_phone_marketplace_listings` WHERE `id` = ? AND `seller_account_id` = ?",
{ id, account.id }
)
if not rows[1] then return { success = false, error = "listing_not_found" } end
notify_changed(account.id)
return { success = true, data = { revision = tonumber(rows[1].revision) } }
end)
Bridge.Callbacks.Register("sky_phone:marketplace:set-status", function(source, data)
local account, error_response = require_account(source)
if not account then return error_response end
data = require_payload(source, "set-status", data)
if not data or type(data.id) ~= "string" or #data.id ~= 36 or not seller_statuses[data.status] then
return { success = false, error = "invalid_status" }
end
local listings = Bridge.Database.Query([[
SELECT `status`, `reserved_account_id` FROM `sky_phone_marketplace_listings`
WHERE `id` = ? AND `seller_account_id` = ? LIMIT 1
]], { data.id, account.id })
local current = listings[1]
if not current then return { success = false, error = "listing_not_found" } end
local reserved_account_id
if data.status == "reserved" then
if current.status ~= "active" or type(data.inquiryId) ~= "string" then
return { success = false, error = "invalid_status" }
end
local inquiries = Bridge.Database.Query([[
SELECT `buyer_account_id` FROM `sky_phone_marketplace_inquiries`
WHERE `id` = ? AND `listing_id` = ? AND `seller_account_id` = ? LIMIT 1
]], { data.inquiryId, data.id, account.id })
if not inquiries[1] then return { success = false, error = "inquiry_not_found" } end
reserved_account_id = inquiries[1].buyer_account_id
elseif data.status == "active" then
if current.status ~= "reserved" and current.status ~= "expired" and current.status ~= "sold" then
return { success = false, error = "invalid_status" }
end
elseif data.status == "sold" then
if current.status ~= "active" and current.status ~= "reserved" then
return { success = false, error = "invalid_status" }
end
reserved_account_id = current.reserved_account_id
elseif data.status == "removed" and current.status == "sold" then
return { success = false, error = "invalid_status" }
end
local expiry = data.status == "active" and ", `expires_at` = DATE_ADD(CURRENT_TIMESTAMP, INTERVAL ? DAY)" or ""
local params = { data.status, reserved_account_id }
if data.status == "active" then params[#params + 1] = Config.Marketplace.ListingLifetimeDays end
params[#params + 1] = data.id
params[#params + 1] = account.id
Bridge.Database.Query(([[
UPDATE `sky_phone_marketplace_listings`
SET `status` = ?, `reserved_account_id` = ?, `revision` = `revision` + 1%s
WHERE `id` = ? AND `seller_account_id` = ?
]]):format(expiry), params)
notify_changed(account.id)
if reserved_account_id then
notify_changed(reserved_account_id)
end
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:marketplace:favorite", function(source, data)
local account, error_response = require_account(source)
if not account then return error_response end
data = require_payload(source, "favorite", data)
if not data or type(data.id) ~= "string" or #data.id ~= 36 or type(data.favorite) ~= "boolean" then
return { success = false, error = "invalid_listing" }
end
if data.favorite then
Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_marketplace_favorites` (`account_id`, `listing_id`)
SELECT ?, `id` FROM `sky_phone_marketplace_listings`
WHERE `id` = ? AND `status` IN ('active', 'reserved')
]], { account.id, data.id })
else
Bridge.Database.Query(
"DELETE FROM `sky_phone_marketplace_favorites` WHERE `account_id` = ? AND `listing_id` = ?",
{ account.id, data.id }
)
end
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:marketplace:counts", function(source)
local account, error_response = require_account(source)
if not account then return error_response end
return { success = true, data = marketplace_counts(account.id) }
end)
Bridge.Callbacks.Register("sky_phone:marketplace:list-inquiries", function(source)
local account, error_response = require_account(source)
if not account then return error_response end
local rows = Bridge.Database.Query([[
SELECT q.`id`, q.`listing_id`, q.`seller_account_id`, q.`buyer_account_id`, q.`updated_at`,
l.`title`, l.`price`, l.`price_type`, l.`status`,
(SELECT image.`gradient` FROM `sky_phone_marketplace_images` image
WHERE image.`listing_id` = l.`id` ORDER BY image.`sort_order` LIMIT 1) AS `image`,
COALESCE(other_profile.`display_name`, SUBSTRING_INDEX(other_account.`email`, '@', 1)) AS `other_name`,
(SELECT message.`body` FROM `sky_phone_marketplace_messages` message
WHERE message.`inquiry_id` = q.`id` ORDER BY message.`id` DESC LIMIT 1) AS `last_message`,
((SELECT COUNT(*) FROM `sky_phone_marketplace_messages` unread
WHERE unread.`inquiry_id` = q.`id` AND unread.`sender_account_id` <> ?
AND unread.`read_at` IS NULL)
+ (SELECT COUNT(*) FROM `sky_phone_marketplace_offers` unread_offer
WHERE unread_offer.`inquiry_id` = q.`id`
AND ((unread_offer.`proposer_account_id` <> ? AND unread_offer.`read_at` IS NULL)
OR (unread_offer.`proposer_account_id` = ?
AND unread_offer.`status` IN ('accepted', 'rejected')
AND unread_offer.`response_read_at` IS NULL)))) AS `unread`
FROM `sky_phone_marketplace_inquiries` q
JOIN `sky_phone_marketplace_listings` l ON l.`id` = q.`listing_id`
JOIN `sky_phone_accounts` other_account ON other_account.`id` =
CASE WHEN q.`seller_account_id` = ? THEN q.`buyer_account_id` ELSE q.`seller_account_id` END
LEFT JOIN `sky_phone_marketplace_profiles` other_profile ON other_profile.`account_id` = other_account.`id`
WHERE q.`seller_account_id` = ? OR q.`buyer_account_id` = ?
ORDER BY q.`updated_at` DESC
LIMIT 100
]], { account.id, account.id, account.id, account.id, account.id, account.id })
return { success = true, data = rows }
end)
Bridge.Callbacks.Register("sky_phone:marketplace:get-inquiry", function(source, data)
local account, error_response = require_account(source)
if not account then return error_response end
data = require_payload(source, "get-inquiry", data)
if not data or type(data.id) ~= "string" or #data.id ~= 36 then
return { success = false, error = "invalid_inquiry" }
end
local inquiries = Bridge.Database.Query([[
SELECT q.*, l.`title`, l.`price`, l.`price_type`, l.`status`, l.`reserved_account_id`,
COALESCE(seller_profile.`display_name`, SUBSTRING_INDEX(seller.`email`, '@', 1)) AS `seller_name`,
COALESCE(buyer_profile.`display_name`, SUBSTRING_INDEX(buyer.`email`, '@', 1)) AS `buyer_name`
FROM `sky_phone_marketplace_inquiries` q
JOIN `sky_phone_marketplace_listings` l ON l.`id` = q.`listing_id`
JOIN `sky_phone_accounts` seller ON seller.`id` = q.`seller_account_id`
JOIN `sky_phone_accounts` buyer ON buyer.`id` = q.`buyer_account_id`
LEFT JOIN `sky_phone_marketplace_profiles` seller_profile ON seller_profile.`account_id` = seller.`id`
LEFT JOIN `sky_phone_marketplace_profiles` buyer_profile ON buyer_profile.`account_id` = buyer.`id`
WHERE q.`id` = ? AND (q.`seller_account_id` = ? OR q.`buyer_account_id` = ?)
LIMIT 1
]], { data.id, account.id, account.id })
if not inquiries[1] then return { success = false, error = "inquiry_not_found" } end
Bridge.Database.Query([[
UPDATE `sky_phone_marketplace_messages`
SET `read_at` = CURRENT_TIMESTAMP
WHERE `inquiry_id` = ? AND `sender_account_id` <> ? AND `read_at` IS NULL
]], { data.id, account.id })
Bridge.Database.Query([[
UPDATE `sky_phone_marketplace_offers`
SET `read_at` = CURRENT_TIMESTAMP
WHERE `inquiry_id` = ? AND `proposer_account_id` <> ? AND `read_at` IS NULL
]], { data.id, account.id })
Bridge.Database.Query([[
UPDATE `sky_phone_marketplace_offers`
SET `response_read_at` = CURRENT_TIMESTAMP
WHERE `inquiry_id` = ? AND `proposer_account_id` = ?
AND `status` IN ('accepted', 'rejected') AND `response_read_at` IS NULL
]], { data.id, account.id })
local messages = Bridge.Database.Query([[
SELECT `id`, `sender_account_id`, `body`, `created_at`, `read_at`
FROM `sky_phone_marketplace_messages`
WHERE `inquiry_id` = ?
ORDER BY `id` ASC
LIMIT ?
]], { data.id, Config.Marketplace.MessagePageSize })
local offers = Bridge.Database.Query([[
SELECT `id`, `proposer_account_id`, `amount`, `status`, `read_at`, `response_read_at`,
`created_at`, `updated_at`
FROM `sky_phone_marketplace_offers`
WHERE `inquiry_id` = ?
ORDER BY `id` ASC
LIMIT ?
]], { data.id, Config.Marketplace.OfferHistorySize })
notify_changed(account.id)
return {
success = true,
data = { inquiry = inquiries[1], messages = messages, offers = offers, accountId = account.id },
}
end)
Bridge.Callbacks.Register("sky_phone:marketplace:send-message", function(source, data)
local account, error_response = require_account(source)
if not account then return error_response end
if not SkyPhone.AllowOperation(source, "marketplace:message", 20, 60) then
return { success = false, error = "rate_limited" }
end
data = require_payload(source, "send-message", data)
local body = data and trim(data.body)
if not valid_text(body, 1, Config.Marketplace.MessageMaxLength) then
return { success = false, error = "invalid_message" }
end
local inquiry
if type(data.inquiryId) == "string" and #data.inquiryId == 36 then
local rows = Bridge.Database.Query([[
SELECT q.*, l.`status` FROM `sky_phone_marketplace_inquiries` q
JOIN `sky_phone_marketplace_listings` l ON l.`id` = q.`listing_id`
WHERE q.`id` = ? AND (q.`seller_account_id` = ? OR q.`buyer_account_id` = ?)
LIMIT 1
]], { data.inquiryId, account.id, account.id })
inquiry = rows[1]
elseif type(data.listingId) == "string" and #data.listingId == 36 then
local listings = Bridge.Database.Query([[
SELECT `id`, `seller_account_id`, `status` FROM `sky_phone_marketplace_listings`
WHERE `id` = ? AND `status` IN ('active', 'reserved') LIMIT 1
]], { data.listingId })
local listing = listings[1]
if listing and tonumber(listing.seller_account_id) ~= account.id then
local blocked = Bridge.Database.Query([[
SELECT 1 FROM `sky_phone_marketplace_blocks`
WHERE (`blocker_account_id` = ? AND `blocked_account_id` = ?)
OR (`blocker_account_id` = ? AND `blocked_account_id` = ?)
LIMIT 1
]], { account.id, listing.seller_account_id, listing.seller_account_id, account.id })
if blocked[1] then return { success = false, error = "blocked" } end
local existing = Bridge.Database.Query([[
SELECT * FROM `sky_phone_marketplace_inquiries`
WHERE `listing_id` = ? AND `buyer_account_id` = ? LIMIT 1
]], { listing.id, account.id })
inquiry = existing[1]
if not inquiry then
inquiry = {
id = new_id(),
listing_id = listing.id,
seller_account_id = listing.seller_account_id,
buyer_account_id = account.id,
status = listing.status,
}
Bridge.Database.Query([[
INSERT INTO `sky_phone_marketplace_inquiries`
(`id`, `listing_id`, `seller_account_id`, `buyer_account_id`)
VALUES (?, ?, ?, ?)
]], { inquiry.id, inquiry.listing_id, inquiry.seller_account_id, inquiry.buyer_account_id })
end
end
end
if not inquiry then return { success = false, error = "inquiry_not_found" } end
local other_account_id = tonumber(inquiry.seller_account_id) == account.id
and tonumber(inquiry.buyer_account_id) or tonumber(inquiry.seller_account_id)
local blocked = Bridge.Database.Query([[
SELECT 1 FROM `sky_phone_marketplace_blocks`
WHERE (`blocker_account_id` = ? AND `blocked_account_id` = ?)
OR (`blocker_account_id` = ? AND `blocked_account_id` = ?)
LIMIT 1
]], { account.id, other_account_id, other_account_id, account.id })
if blocked[1] then return { success = false, error = "blocked" } end
Bridge.Database.Query([[
INSERT INTO `sky_phone_marketplace_messages` (`inquiry_id`, `sender_account_id`, `body`)
VALUES (?, ?, ?)
]], { inquiry.id, account.id, body })
Bridge.Database.Query(
"UPDATE `sky_phone_marketplace_inquiries` SET `updated_at` = CURRENT_TIMESTAMP WHERE `id` = ?",
{ inquiry.id }
)
notify_changed(account.id)
SkyPhone.NotifyAccountDevices(other_account_id, "sky_phone:marketplace:new-message", {
inquiryId = inquiry.id,
listingId = inquiry.listing_id,
sender = account.email:match("^([^@]+)") or account.email,
text = body,
})
notify_changed(other_account_id)
return { success = true, data = { id = inquiry.id } }
end)
Bridge.Callbacks.Register("sky_phone:marketplace:make-offer", function(source, data)
local account, error_response = require_account(source)
if not account then return error_response end
if not SkyPhone.AllowOperation(source, "marketplace:offer", 10, 60) then
return { success = false, error = "rate_limited" }
end
data = require_payload(source, "make-offer", data)
local amount = data and tonumber(data.amount) or nil
if not data or type(data.inquiryId) ~= "string" or #data.inquiryId ~= 36
or not amount or amount ~= math.floor(amount) or amount < 1
or amount > Config.Marketplace.MaximumPrice
then
return { success = false, error = "invalid_offer" }
end
local rows = Bridge.Database.Query([[
SELECT q.*, l.`status` AS `listing_status`, l.`reserved_account_id`
FROM `sky_phone_marketplace_inquiries` q
JOIN `sky_phone_marketplace_listings` l ON l.`id` = q.`listing_id`
WHERE q.`id` = ? AND (q.`seller_account_id` = ? OR q.`buyer_account_id` = ?)
LIMIT 1
]], { data.inquiryId, account.id, account.id })
local inquiry = rows[1]
if not inquiry then return { success = false, error = "inquiry_not_found" } end
local buyer_account_id = tonumber(inquiry.buyer_account_id)
local seller_account_id = tonumber(inquiry.seller_account_id)
local reserved_account_id = tonumber(inquiry.reserved_account_id)
if inquiry.listing_status ~= "active"
and (inquiry.listing_status ~= "reserved" or reserved_account_id ~= buyer_account_id)
then
return { success = false, error = "offer_listing_unavailable" }
end
if inquiry.offer_status == "accepted" then
return { success = false, error = "offer_closed" }
end
local current_proposer_account_id = tonumber(inquiry.offer_proposer_account_id)
if inquiry.offer_status == "pending" and current_proposer_account_id == account.id then
return { success = false, error = "offer_waiting" }
end
if inquiry.offer_status ~= "pending" and account.id ~= buyer_account_id then
return { success = false, error = "offer_not_allowed" }
end
local other_account_id = account.id == seller_account_id and buyer_account_id or seller_account_id
local blocked = Bridge.Database.Query([[
SELECT 1 FROM `sky_phone_marketplace_blocks`
WHERE (`blocker_account_id` = ? AND `blocked_account_id` = ?)
OR (`blocker_account_id` = ? AND `blocked_account_id` = ?)
LIMIT 1
]], { account.id, other_account_id, other_account_id, account.id })
if blocked[1] then return { success = false, error = "blocked" } end
local offer_result = Bridge.Database.Query([[
INSERT INTO `sky_phone_marketplace_offers` (`inquiry_id`, `proposer_account_id`, `amount`)
VALUES (?, ?, ?)
]], { inquiry.id, account.id, amount })
local offer_id = insert_id(offer_result)
if not offer_id then
error("[sky_phone] Database did not return a marketplace offer id.")
end
local revision = tonumber(inquiry.offer_revision) or 0
local update_result = Bridge.Database.Query([[
UPDATE `sky_phone_marketplace_inquiries`
SET `offer_id` = ?, `offer_amount` = ?, `offer_proposer_account_id` = ?,
`offer_status` = 'pending', `offer_revision` = `offer_revision` + 1,
`updated_at` = CURRENT_TIMESTAMP
WHERE `id` = ? AND `offer_revision` = ?
]], { offer_id, amount, account.id, inquiry.id, revision })
if affected_rows(update_result) == 0 then
Bridge.Database.Query(
"UPDATE `sky_phone_marketplace_offers` SET `status` = 'countered' WHERE `id` = ?",
{ offer_id }
)
return { success = false, error = "offer_conflict" }
end
local previous_offer_id = tonumber(inquiry.offer_id)
if previous_offer_id then
Bridge.Database.Query([[
UPDATE `sky_phone_marketplace_offers`
SET `status` = 'countered'
WHERE `id` = ? AND `status` = 'pending'
]], { previous_offer_id })
end
notify_changed(account.id)
SkyPhone.NotifyAccountDevices(other_account_id, "sky_phone:marketplace:new-message", {
amount = amount,
inquiryId = inquiry.id,
kind = "offer",
listingId = inquiry.listing_id,
sender = account.email:match("^([^@]+)") or account.email,
})
notify_changed(other_account_id)
return { success = true, data = { id = offer_id } }
end)
Bridge.Callbacks.Register("sky_phone:marketplace:respond-offer", function(source, data)
local account, error_response = require_account(source)
if not account then return error_response end
if not SkyPhone.AllowOperation(source, "marketplace:offer-response", 10, 60) then
return { success = false, error = "rate_limited" }
end
data = require_payload(source, "respond-offer", data)
if not data or type(data.inquiryId) ~= "string" or #data.inquiryId ~= 36
or not offer_responses[data.action]
then
return { success = false, error = "invalid_offer_response" }
end
local rows = Bridge.Database.Query([[
SELECT q.*, l.`status` AS `listing_status`, l.`reserved_account_id`
FROM `sky_phone_marketplace_inquiries` q
JOIN `sky_phone_marketplace_listings` l ON l.`id` = q.`listing_id`
WHERE q.`id` = ? AND (q.`seller_account_id` = ? OR q.`buyer_account_id` = ?)
LIMIT 1
]], { data.inquiryId, account.id, account.id })
local inquiry = rows[1]
if not inquiry then return { success = false, error = "inquiry_not_found" } end
local offer_id = tonumber(inquiry.offer_id)
local proposer_account_id = tonumber(inquiry.offer_proposer_account_id)
if inquiry.offer_status ~= "pending" or not offer_id or proposer_account_id == account.id then
return { success = false, error = "offer_not_actionable" }
end
local buyer_account_id = tonumber(inquiry.buyer_account_id)
if data.action == "accepted" then
local reservation_result = Bridge.Database.Query([[
UPDATE `sky_phone_marketplace_listings`
SET `status` = 'reserved', `reserved_account_id` = ?, `revision` = `revision` + 1
WHERE `id` = ? AND (`status` = 'active'
OR (`status` = 'reserved' AND `reserved_account_id` = ?))
]], { buyer_account_id, inquiry.listing_id, buyer_account_id })
if affected_rows(reservation_result) == 0 then
return { success = false, error = "offer_listing_unavailable" }
end
end
local revision = tonumber(inquiry.offer_revision) or 0
local update_result = Bridge.Database.Query([[
UPDATE `sky_phone_marketplace_inquiries`
SET `offer_status` = ?, `offer_revision` = `offer_revision` + 1,
`updated_at` = CURRENT_TIMESTAMP
WHERE `id` = ? AND `offer_id` = ? AND `offer_revision` = ? AND `offer_status` = 'pending'
]], { data.action, inquiry.id, offer_id, revision })
if affected_rows(update_result) == 0 then
return { success = false, error = "offer_conflict" }
end
Bridge.Database.Query([[
UPDATE `sky_phone_marketplace_offers`
SET `status` = ?
WHERE `id` = ? AND `status` = 'pending'
]], { data.action, offer_id })
notify_changed(account.id)
SkyPhone.NotifyAccountDevices(proposer_account_id, "sky_phone:marketplace:new-message", {
action = data.action,
amount = tonumber(inquiry.offer_amount),
inquiryId = inquiry.id,
kind = "offer-response",
listingId = inquiry.listing_id,
sender = account.email:match("^([^@]+)") or account.email,
})
notify_changed(proposer_account_id)
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:marketplace:report", function(source, data)
local account, error_response = require_account(source)
if not account then return error_response end
if not SkyPhone.AllowOperation(source, "marketplace:report", 5, 60) then
return { success = false, error = "rate_limited" }
end
data = require_payload(source, "report", data)
local details = data and trim(data.details) or ""
if not data or type(data.id) ~= "string" or #data.id ~= 36 or not report_reasons[data.reason]
or not valid_text(details, 0, 500)
then
return { success = false, error = "invalid_report" }
end
local result = Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_marketplace_reports`
(`id`, `reporter_account_id`, `listing_id`, `reason`, `details`)
SELECT ?, ?, `id`, ?, ? FROM `sky_phone_marketplace_listings`
WHERE `id` = ? AND `seller_account_id` <> ?
]], { new_id(), account.id, data.reason, details, data.id, account.id })
if affected_rows(result) == 0 then
return { success = false, error = "already_reported" }
end
return { success = true }
end)
Bridge.Callbacks.Register("sky_phone:marketplace:block", function(source, data)
local account, error_response = require_account(source)
if not account then return error_response end
data = require_payload(source, "block", data)
if not data or type(data.listingId) ~= "string" or #data.listingId ~= 36 then
return { success = false, error = "invalid_listing" }
end
local listings = Bridge.Database.Query(
"SELECT `seller_account_id` FROM `sky_phone_marketplace_listings` WHERE `id` = ? LIMIT 1",
{ data.listingId }
)
local blocked_id = listings[1] and tonumber(listings[1].seller_account_id)
if not blocked_id or blocked_id == account.id then
return { success = false, error = "invalid_listing" }
end
if data.blocked == false then
Bridge.Database.Query([[
DELETE FROM `sky_phone_marketplace_blocks`
WHERE `blocker_account_id` = ? AND `blocked_account_id` = ?
]], { account.id, blocked_id })
else
Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_marketplace_blocks` (`blocker_account_id`, `blocked_account_id`)
VALUES (?, ?)
]], { account.id, blocked_id })
end
return { success = true }
end)
end)