mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 05:02:32 +00:00
a9143b0fba
* 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>
1120 lines
49 KiB
Lua
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)
|