mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 23:01:38 +00:00
77c1790f8b
Authenticate CityMarkt profiles with the linked iFruit account password, including server-side validation and rate limiting. Update the shared auth UI, localized copy, frontend state, browser mocks, and regression coverage for login and registration flows.
1110 lines
49 KiB
Lua
1110 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 }
|
|
|
|
for _, category in ipairs(Config.Marketplace.Categories) do
|
|
categories[category] = true
|
|
end
|
|
for _, district in ipairs(Config.Marketplace.Districts) do
|
|
districts[district] = true
|
|
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)
|