mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 16:23:22 +00:00
ENH - update SMS branch from dev
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
Bridge.Database.AfterMigration("sky_phone", function()
|
||||
local reminder_minutes = {
|
||||
[0] = true,
|
||||
[10] = true,
|
||||
[30] = true,
|
||||
[60] = true,
|
||||
[1440] = true,
|
||||
}
|
||||
|
||||
local function text_length(value)
|
||||
return type(value) == "string" and utf8.len(value) or nil
|
||||
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 event_dto(row)
|
||||
return {
|
||||
id = row.id,
|
||||
title = row.title,
|
||||
note = row.note,
|
||||
startsAt = (tonumber(row.starts_at_unix) or 0) * 1000,
|
||||
endsAt = (tonumber(row.ends_at_unix) or 0) * 1000,
|
||||
reminderMinutes = row.reminder_minutes and tonumber(row.reminder_minutes) or nil,
|
||||
remindedAt = row.reminded_at_unix and tonumber(row.reminded_at_unix) * 1000 or nil,
|
||||
revision = tonumber(row.revision) or 1,
|
||||
}
|
||||
end
|
||||
|
||||
local function list_events(account_id, starts_at, ends_at)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `id`, `title`, `note`, `reminder_minutes`, `revision`,
|
||||
UNIX_TIMESTAMP(`starts_at`) AS `starts_at_unix`,
|
||||
UNIX_TIMESTAMP(`ends_at`) AS `ends_at_unix`,
|
||||
UNIX_TIMESTAMP(`reminded_at`) AS `reminded_at_unix`
|
||||
FROM `sky_phone_calendar_events`
|
||||
WHERE `account_id` = ?
|
||||
AND `ends_at` >= FROM_UNIXTIME(?)
|
||||
AND `starts_at` < FROM_UNIXTIME(?)
|
||||
ORDER BY `starts_at`, `ends_at`, `id`
|
||||
LIMIT 500
|
||||
]], { account_id, starts_at, ends_at })
|
||||
local events = {}
|
||||
for _, row in ipairs(rows) do
|
||||
events[#events + 1] = event_dto(row)
|
||||
end
|
||||
return events
|
||||
end
|
||||
|
||||
local function valid_range(starts_at, ends_at)
|
||||
local now = os.time()
|
||||
return starts_at >= now - Config.Calendar.PastEditSeconds
|
||||
and starts_at <= now + Config.Calendar.FutureSeconds
|
||||
and ends_at > starts_at
|
||||
and ends_at - starts_at <= Config.Calendar.MaximumDurationSeconds
|
||||
end
|
||||
|
||||
local function validate_event(data)
|
||||
if type(data) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
local title_length = text_length(data.title)
|
||||
local note_length = text_length(data.note)
|
||||
local starts_at = math.floor(tonumber(data.startsAt) or 0)
|
||||
local ends_at = math.floor(tonumber(data.endsAt) or 0)
|
||||
local reminder = data.reminderMinutes
|
||||
if not title_length
|
||||
or title_length < 1
|
||||
or title_length > Config.Calendar.TitleMaxLength
|
||||
or not note_length
|
||||
or note_length > Config.Calendar.NoteMaxLength
|
||||
or not valid_range(starts_at, ends_at)
|
||||
or (reminder ~= nil and not reminder_minutes[tonumber(reminder)])
|
||||
then
|
||||
return nil
|
||||
end
|
||||
return {
|
||||
title = data.title,
|
||||
note = data.note,
|
||||
starts_at = starts_at,
|
||||
ends_at = ends_at,
|
||||
reminder_minutes = reminder == nil and nil or tonumber(reminder),
|
||||
}
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:calendar:list", function(source, data)
|
||||
local account, error_response = SkyPhone.RequireAccount(source)
|
||||
if not account then
|
||||
return error_response
|
||||
end
|
||||
local starts_at = math.floor(tonumber(data and data.startsAt) or 0)
|
||||
local ends_at = math.floor(tonumber(data and data.endsAt) or 0)
|
||||
if starts_at <= 0 or ends_at <= starts_at or ends_at - starts_at > Config.Calendar.MaximumQuerySeconds then
|
||||
return { success = false, error = "invalid_range" }
|
||||
end
|
||||
return { success = true, data = list_events(account.id, starts_at, ends_at) }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:calendar:create", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "calendar_write", 60, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local account, error_response = SkyPhone.RequireAccount(source)
|
||||
if not account then
|
||||
return error_response
|
||||
end
|
||||
local event = validate_event(data)
|
||||
if not event then
|
||||
return { success = false, error = "invalid_event" }
|
||||
end
|
||||
local ids = Bridge.Database.Query("SELECT UUID() AS `id`", {})
|
||||
local id = ids[1] and ids[1].id
|
||||
if type(id) ~= "string" then
|
||||
error("[sky_phone] Database did not generate a calendar event id.")
|
||||
end
|
||||
Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_calendar_events`
|
||||
(`id`, `account_id`, `title`, `note`, `starts_at`, `ends_at`, `reminder_minutes`)
|
||||
VALUES (?, ?, ?, ?, FROM_UNIXTIME(?), FROM_UNIXTIME(?), ?)
|
||||
]], {
|
||||
id,
|
||||
account.id,
|
||||
event.title,
|
||||
event.note,
|
||||
event.starts_at,
|
||||
event.ends_at,
|
||||
event.reminder_minutes,
|
||||
})
|
||||
return { success = true, data = { id = id } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:calendar:update", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "calendar_write", 60, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local account, error_response = SkyPhone.RequireAccount(source)
|
||||
if not account then
|
||||
return error_response
|
||||
end
|
||||
local event = validate_event(data)
|
||||
if not event or type(data.id) ~= "string" then
|
||||
return { success = false, error = "invalid_event" }
|
||||
end
|
||||
local revision = math.max(1, math.floor(tonumber(data.revision) or 0))
|
||||
local result = Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_calendar_events`
|
||||
SET `title` = ?, `note` = ?, `starts_at` = FROM_UNIXTIME(?),
|
||||
`ends_at` = FROM_UNIXTIME(?), `reminder_minutes` = ?,
|
||||
`reminded_at` = NULL, `revision` = `revision` + 1
|
||||
WHERE `id` = ? AND `account_id` = ? AND `revision` = ?
|
||||
]], {
|
||||
event.title,
|
||||
event.note,
|
||||
event.starts_at,
|
||||
event.ends_at,
|
||||
event.reminder_minutes,
|
||||
data.id,
|
||||
account.id,
|
||||
revision,
|
||||
})
|
||||
if affected_rows(result) ~= 1 then
|
||||
return { success = false, error = "conflict" }
|
||||
end
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:calendar:delete", function(source, data)
|
||||
if not SkyPhone.AllowOperation(source, "calendar_write", 60, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
local account, error_response = SkyPhone.RequireAccount(source)
|
||||
if not account then
|
||||
return error_response
|
||||
end
|
||||
if type(data) ~= "table" or type(data.id) ~= "string" then
|
||||
return { success = false, error = "invalid_event" }
|
||||
end
|
||||
Bridge.Database.Query(
|
||||
"DELETE FROM `sky_phone_calendar_events` WHERE `id` = ? AND `account_id` = ?",
|
||||
{ data.id, account.id }
|
||||
)
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
while true do
|
||||
Wait(Config.Calendar.ReminderPollSeconds * 1000)
|
||||
local due_events = Bridge.Database.Query([[
|
||||
SELECT `id`, `account_id`, `title`, UNIX_TIMESTAMP(`starts_at`) AS `starts_at_unix`
|
||||
FROM `sky_phone_calendar_events`
|
||||
WHERE `reminder_minutes` IS NOT NULL
|
||||
AND `reminded_at` IS NULL
|
||||
AND DATE_SUB(`starts_at`, INTERVAL `reminder_minutes` MINUTE) <= CURRENT_TIMESTAMP
|
||||
AND `starts_at` >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 1 DAY)
|
||||
ORDER BY `starts_at`
|
||||
LIMIT 100
|
||||
]], {})
|
||||
for _, event in ipairs(due_events) do
|
||||
local result = Bridge.Database.Query([[
|
||||
UPDATE `sky_phone_calendar_events`
|
||||
SET `reminded_at` = CURRENT_TIMESTAMP
|
||||
WHERE `id` = ? AND `reminded_at` IS NULL
|
||||
]], { event.id })
|
||||
if affected_rows(result) == 1 then
|
||||
SkyPhone.NotifyAccountDevices(event.account_id, "sky_phone:calendar:reminder", {
|
||||
eventId = event.id,
|
||||
eventTitle = event.title,
|
||||
startsAt = (tonumber(event.starts_at_unix) or 0) * 1000,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
end)
|
||||
@@ -246,6 +246,39 @@ local schema = {
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_media",
|
||||
columns = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{ name = "account_id", type = "BIGINT UNSIGNED NULL" },
|
||||
{
|
||||
name = "device_imei",
|
||||
type = "CHAR(15) NULL",
|
||||
characterSet = "ascii",
|
||||
collation = "ascii_bin",
|
||||
},
|
||||
{ name = "url", type = "TEXT NOT NULL" },
|
||||
{ name = "remote_id", type = "VARCHAR(128) NOT NULL" },
|
||||
{ name = "media_type", type = "ENUM('photo', 'video') NOT NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_media_account", columns = "(`account_id`, `created_at`, `id`)" },
|
||||
{ name = "idx_sky_phone_media_device", columns = "(`device_imei`, `created_at`, `id`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{
|
||||
column = "account_id",
|
||||
references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE",
|
||||
},
|
||||
{
|
||||
column = "device_imei",
|
||||
references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE",
|
||||
},
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_contacts",
|
||||
columns = {
|
||||
@@ -346,6 +379,284 @@ local schema = {
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_marketplace_listings",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "seller_account_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "reserved_account_id", type = "BIGINT UNSIGNED NULL" },
|
||||
{ name = "title", type = "VARCHAR(70) NOT NULL" },
|
||||
{ name = "description", type = "TEXT NOT NULL" },
|
||||
{ name = "category", type = "VARCHAR(32) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "item_condition", type = "ENUM('new', 'very_good', 'used', 'defective') NOT NULL" },
|
||||
{ name = "price_type", type = "ENUM('fixed', 'negotiable', 'free') NOT NULL" },
|
||||
{ name = "price", type = "BIGINT UNSIGNED NULL" },
|
||||
{ name = "district", type = "VARCHAR(32) NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "show_phone", type = "TINYINT(1) NOT NULL DEFAULT 0" },
|
||||
{ name = "phone_number", type = "VARCHAR(24) NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "status", type = "ENUM('active', 'reserved', 'sold', 'expired', 'removed') NOT NULL DEFAULT 'active'" },
|
||||
{ name = "revision", type = "INT UNSIGNED NOT NULL DEFAULT 1" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
|
||||
{ name = "expires_at", type = "DATETIME NOT NULL" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_marketplace_feed", columns = "(`status`, `created_at`)" },
|
||||
{ name = "idx_sky_phone_marketplace_category", columns = "(`category`, `status`, `created_at`)" },
|
||||
{ name = "idx_sky_phone_marketplace_seller", columns = "(`seller_account_id`, `updated_at`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "seller_account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "reserved_account_id", references = "`sky_phone_accounts` (`id`) ON DELETE SET NULL" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_marketplace_images",
|
||||
columns = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{ name = "listing_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "media_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "gradient", type = "VARCHAR(160) NOT NULL" },
|
||||
{ name = "sort_order", type = "TINYINT UNSIGNED NOT NULL" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {
|
||||
{ name = "uniq_sky_phone_marketplace_image_order", columns = "(`listing_id`, `sort_order`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "listing_id", references = "`sky_phone_marketplace_listings` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_marketplace_favorites",
|
||||
columns = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{ name = "account_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "listing_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {
|
||||
{ name = "uniq_sky_phone_marketplace_favorite", columns = "(`account_id`, `listing_id`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "listing_id", references = "`sky_phone_marketplace_listings` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_marketplace_inquiries",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "listing_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "seller_account_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "buyer_account_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "offer_id", type = "BIGINT UNSIGNED NULL" },
|
||||
{ name = "offer_amount", type = "BIGINT UNSIGNED NULL" },
|
||||
{ name = "offer_proposer_account_id", type = "BIGINT UNSIGNED NULL" },
|
||||
{ name = "offer_status", type = "ENUM('pending', 'accepted', 'rejected') NULL" },
|
||||
{ name = "offer_revision", type = "INT UNSIGNED NOT NULL DEFAULT 0" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {
|
||||
{ name = "uniq_sky_phone_marketplace_inquiry", columns = "(`listing_id`, `buyer_account_id`)" },
|
||||
},
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_marketplace_inquiry_seller", columns = "(`seller_account_id`, `updated_at`)" },
|
||||
{ name = "idx_sky_phone_marketplace_inquiry_buyer", columns = "(`buyer_account_id`, `updated_at`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "listing_id", references = "`sky_phone_marketplace_listings` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "seller_account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "buyer_account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_marketplace_messages",
|
||||
columns = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{ name = "inquiry_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "sender_account_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "body", type = "VARCHAR(1000) NOT NULL" },
|
||||
{ name = "read_at", type = "DATETIME NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_marketplace_messages", columns = "(`inquiry_id`, `id`)" },
|
||||
{ name = "idx_sky_phone_marketplace_unread", columns = "(`inquiry_id`, `read_at`, `sender_account_id`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "inquiry_id", references = "`sky_phone_marketplace_inquiries` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "sender_account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_marketplace_offers",
|
||||
columns = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{ name = "inquiry_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "proposer_account_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "amount", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "status", type = "ENUM('pending', 'accepted', 'rejected', 'countered') NOT NULL DEFAULT 'pending'" },
|
||||
{ name = "read_at", type = "DATETIME NULL" },
|
||||
{ name = "response_read_at", type = "DATETIME NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_marketplace_offers", columns = "(`inquiry_id`, `id`)" },
|
||||
{ name = "idx_sky_phone_marketplace_offer_unread", columns = "(`inquiry_id`, `read_at`, `proposer_account_id`)" },
|
||||
{ name = "idx_sky_phone_marketplace_offer_response_unread", columns = "(`inquiry_id`, `response_read_at`, `proposer_account_id`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "inquiry_id", references = "`sky_phone_marketplace_inquiries` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "proposer_account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_marketplace_blocks",
|
||||
columns = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{ name = "blocker_account_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "blocked_account_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {
|
||||
{ name = "uniq_sky_phone_marketplace_block", columns = "(`blocker_account_id`, `blocked_account_id`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "blocker_account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "blocked_account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_marketplace_reports",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "reporter_account_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "listing_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "reason", type = "ENUM('prohibited', 'fraud', 'spam', 'offensive', 'other') NOT NULL" },
|
||||
{ name = "details", type = "VARCHAR(500) NOT NULL DEFAULT ''" },
|
||||
{ name = "status", type = "ENUM('open', 'reviewed', 'dismissed') NOT NULL DEFAULT 'open'" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {
|
||||
{ name = "uniq_sky_phone_marketplace_report", columns = "(`reporter_account_id`, `listing_id`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "reporter_account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "listing_id", references = "`sky_phone_marketplace_listings` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_pages_posts",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "account_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "source_type", type = "ENUM('personal', 'citymarkt') NOT NULL DEFAULT 'personal'" },
|
||||
{ name = "share_date", type = "DATE NULL" },
|
||||
{ name = "citymarkt_listing_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "title", type = "VARCHAR(80) NOT NULL" },
|
||||
{ name = "body", type = "TEXT NOT NULL" },
|
||||
{ name = "category", type = "VARCHAR(32) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "district", type = "VARCHAR(32) NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {
|
||||
{ name = "uniq_sky_phone_pages_citymarkt_listing", columns = "(`citymarkt_listing_id`)" },
|
||||
{ name = "uniq_sky_phone_pages_daily_share", columns = "(`account_id`, `source_type`, `share_date`)" },
|
||||
},
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_pages_feed", columns = "(`created_at`)" },
|
||||
{ name = "idx_sky_phone_pages_category", columns = "(`category`, `created_at`)" },
|
||||
{ name = "idx_sky_phone_pages_owner", columns = "(`account_id`, `created_at`)" },
|
||||
{ name = "idx_sky_phone_pages_daily_share", columns = "(`account_id`, `source_type`, `created_at`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "citymarkt_listing_id", references = "`sky_phone_marketplace_listings` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_pages_images",
|
||||
columns = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{ name = "post_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "media_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "gradient", type = "VARCHAR(160) NOT NULL" },
|
||||
{ name = "sort_order", type = "TINYINT UNSIGNED NOT NULL" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {
|
||||
{ name = "uniq_sky_phone_pages_image_order", columns = "(`post_id`, `sort_order`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "post_id", references = "`sky_phone_pages_posts` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_pages_reactions",
|
||||
columns = {
|
||||
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
|
||||
{ name = "post_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "account_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "kind", type = "ENUM('like', 'save') NOT NULL" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
uniqueKeys = {
|
||||
{ name = "uniq_sky_phone_pages_reaction", columns = "(`post_id`, `account_id`, `kind`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "post_id", references = "`sky_phone_pages_posts` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
{
|
||||
name = "sky_phone_calendar_events",
|
||||
columns = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "account_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "title", type = "VARCHAR(120) NOT NULL" },
|
||||
{ name = "note", type = "TEXT NOT NULL" },
|
||||
{ name = "starts_at", type = "DATETIME NOT NULL" },
|
||||
{ name = "ends_at", type = "DATETIME NOT NULL" },
|
||||
{ name = "reminder_minutes", type = "SMALLINT UNSIGNED NULL" },
|
||||
{ name = "reminded_at", type = "DATETIME NULL" },
|
||||
{ name = "revision", type = "INT UNSIGNED NOT NULL DEFAULT 1" },
|
||||
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
{ name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" },
|
||||
},
|
||||
primaryKey = "id",
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_calendar_account", columns = "(`account_id`, `starts_at`)" },
|
||||
{ name = "idx_sky_phone_calendar_reminders", columns = "(`reminded_at`, `starts_at`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
}
|
||||
|
||||
Bridge.Database.Migrate("sky_phone", schema)
|
||||
|
||||
@@ -373,7 +373,6 @@ Bridge.Callbacks.Register("sky_phone:mail:delete-draft", function(source, data)
|
||||
broadcast_mailbox_changed(session.id)
|
||||
return { success = true }
|
||||
end)
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:mail:send", function(source, data)
|
||||
local session, error_response = require_session(source)
|
||||
@@ -581,3 +580,4 @@ Bridge.Callbacks.Register("sky_phone:mail:empty-trash", function(source)
|
||||
broadcast_mailbox_changed(session.id)
|
||||
return { success = true }
|
||||
end)
|
||||
end)
|
||||
|
||||
@@ -0,0 +1,971 @@
|
||||
Bridge.Database.AfterMigration("sky_phone", function()
|
||||
local categories = {}
|
||||
local districts = {}
|
||||
local photo_gradients = {}
|
||||
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
|
||||
for _, gradient in ipairs(Config.Marketplace.PhotoGradients) do
|
||||
photo_gradients[gradient] = 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 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, imei, images)
|
||||
if type(images) ~= "table" or #images > Config.Marketplace.MaxImages then
|
||||
return nil
|
||||
end
|
||||
if #images == 0 then
|
||||
return {}
|
||||
end
|
||||
|
||||
local owned_media = {
|
||||
["sunset-drive"] = Config.Marketplace.PhotoGradients[1],
|
||||
["ocean-air"] = Config.Marketplace.PhotoGradients[2],
|
||||
["city-lights"] = Config.Marketplace.PhotoGradients[3],
|
||||
["desert-road"] = Config.Marketplace.PhotoGradients[4],
|
||||
}
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `payload` FROM `sky_phone_device_data`
|
||||
WHERE `device_imei` = ? AND `namespace` = 'media'
|
||||
LIMIT 1
|
||||
]], { imei })
|
||||
local media = rows[1] and json.decode(rows[1].payload) or nil
|
||||
for _, capture in ipairs(type(media) == "table" and media.captures or {}) do
|
||||
if type(capture) == "table" and type(capture.id) == "string" and photo_gradients[capture.gradient] then
|
||||
owned_media[capture.id] = capture.gradient
|
||||
end
|
||||
end
|
||||
|
||||
local normalized = {}
|
||||
local seen = {}
|
||||
for index, image in ipairs(images) do
|
||||
local media_id = type(image) == "table" and image.id or nil
|
||||
local gradient = media_id and owned_media[media_id] or nil
|
||||
if type(media_id) ~= "string" or #media_id > 64 or not gradient or seen[media_id] then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Rejected unowned marketplace image from source %s.",
|
||||
tostring(source)
|
||||
)
|
||||
return nil
|
||||
end
|
||||
seen[media_id] = true
|
||||
normalized[index] = { id = media_id, gradient = gradient }
|
||||
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, account.imei, 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`,
|
||||
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`
|
||||
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.*, SUBSTRING_INDEX(a.`email`, '@', 1) AS `seller_name`, 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`
|
||||
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.phone_number = listing.show_phone == 1 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
|
||||
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" 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`,
|
||||
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
|
||||
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`,
|
||||
SUBSTRING_INDEX(seller.`email`, '@', 1) AS `seller_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`
|
||||
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)
|
||||
@@ -1,4 +1,251 @@
|
||||
local function current_device(source)
|
||||
Bridge.Database.AfterMigration("sky_phone", function()
|
||||
SkyPhoneMedia = {}
|
||||
|
||||
local pending_uploads = {}
|
||||
local pending_deletes = {}
|
||||
|
||||
local function media_config()
|
||||
return Config.Media.FiveManage
|
||||
end
|
||||
|
||||
local function api_configured()
|
||||
local api_key = media_config().ApiKey
|
||||
return type(api_key) == "string" and api_key ~= "" and api_key ~= "YOUR_API_TOKEN"
|
||||
end
|
||||
|
||||
local function http_request(url, method, body, headers, timeout_ms)
|
||||
local request = promise.new()
|
||||
local settled = false
|
||||
PerformHttpRequest(url, function(status, response_body, response_headers)
|
||||
if settled then
|
||||
return
|
||||
end
|
||||
settled = true
|
||||
request:resolve({
|
||||
body = response_body,
|
||||
headers = response_headers,
|
||||
status = status,
|
||||
})
|
||||
end, method, body or "", headers or {})
|
||||
SetTimeout(timeout_ms, function()
|
||||
if settled then
|
||||
return
|
||||
end
|
||||
settled = true
|
||||
request:resolve({ status = 0, body = "request_timeout" })
|
||||
end)
|
||||
return Await(request)
|
||||
end
|
||||
|
||||
local function decode_response(response)
|
||||
if type(response) ~= "table" or type(response.status) ~= "number" then
|
||||
return nil, "invalid_response"
|
||||
end
|
||||
if response.status == 0 then
|
||||
return nil, "request_timeout"
|
||||
end
|
||||
if response.status < 200 or response.status >= 300 then
|
||||
return nil, ("request_failed_%s"):format(response.status)
|
||||
end
|
||||
local success, decoded = pcall(json.decode, response.body or "")
|
||||
if not success or type(decoded) ~= "table" then
|
||||
return nil, "invalid_response"
|
||||
end
|
||||
return decoded.data or decoded
|
||||
end
|
||||
|
||||
local function request_presigned_url()
|
||||
if not api_configured() then
|
||||
return nil, "missing_config"
|
||||
end
|
||||
local config = media_config()
|
||||
local response = http_request(
|
||||
tostring(config.BaseUrl):gsub("/+$", "") .. "/presigned-url",
|
||||
"GET",
|
||||
"",
|
||||
{ ["Authorization"] = config.ApiKey },
|
||||
tonumber(config.RequestTimeoutMs) or 10000
|
||||
)
|
||||
local data, response_error = decode_response(response)
|
||||
if not data then
|
||||
return nil, response_error
|
||||
end
|
||||
local presigned_url = data.presignedUrl or data.presigned_url
|
||||
if type(presigned_url) ~= "string" or presigned_url == "" then
|
||||
return nil, "missing_presigned_url"
|
||||
end
|
||||
return presigned_url
|
||||
end
|
||||
|
||||
local function get_remote_file(remote_id)
|
||||
if not api_configured() then
|
||||
return nil, "missing_config"
|
||||
end
|
||||
local config = media_config()
|
||||
local response = http_request(
|
||||
("%s/%s"):format(tostring(config.BaseUrl):gsub("/+$", ""), remote_id),
|
||||
"GET",
|
||||
"",
|
||||
{ ["Authorization"] = config.ApiKey },
|
||||
tonumber(config.RequestTimeoutMs) or 10000
|
||||
)
|
||||
return decode_response(response)
|
||||
end
|
||||
|
||||
local function delete_remote_file(remote_id)
|
||||
if not api_configured() then
|
||||
return false, "missing_config"
|
||||
end
|
||||
local config = media_config()
|
||||
local response = http_request(
|
||||
("%s/%s"):format(tostring(config.BaseUrl):gsub("/+$", ""), remote_id),
|
||||
"DELETE",
|
||||
"",
|
||||
{ ["Authorization"] = config.ApiKey },
|
||||
tonumber(config.RequestTimeoutMs) or 10000
|
||||
)
|
||||
if response.status < 200 or response.status >= 300 then
|
||||
return false, response.status == 0 and "request_timeout" or ("delete_failed_%s"):format(response.status)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function session_owner(source)
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then
|
||||
return nil, error_response
|
||||
end
|
||||
local device = SkyPhone.LoadDevice(session.imei)
|
||||
if not device then
|
||||
return nil, { success = false, error = "device_not_found" }
|
||||
end
|
||||
return {
|
||||
account_id = device.account_id and tonumber(device.account_id) or nil,
|
||||
imei = session.imei,
|
||||
}
|
||||
end
|
||||
|
||||
local function owner_condition(owner)
|
||||
if owner.account_id then
|
||||
return "`account_id` = ?", { owner.account_id }
|
||||
end
|
||||
return "`account_id` IS NULL AND `device_imei` = ?", { owner.imei }
|
||||
end
|
||||
|
||||
local function owners_match(left, right)
|
||||
return left.imei == right.imei and left.account_id == right.account_id
|
||||
end
|
||||
|
||||
local function upload_result(source, correlation_id, success, error_code, media)
|
||||
TriggerClientEvent("sky_phone:media:upload-result", source, {
|
||||
correlationId = correlation_id,
|
||||
success = success,
|
||||
error = error_code,
|
||||
media = media,
|
||||
})
|
||||
end
|
||||
|
||||
local function delete_result(source, correlation_id, success, error_code, media_id)
|
||||
TriggerClientEvent("sky_phone:media:delete-result", source, {
|
||||
correlationId = correlation_id,
|
||||
success = success,
|
||||
error = error_code,
|
||||
id = media_id,
|
||||
})
|
||||
end
|
||||
|
||||
local function parse_metadata(value)
|
||||
if type(value) == "table" then
|
||||
return value
|
||||
end
|
||||
if type(value) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local success, decoded = pcall(json.decode, value)
|
||||
return success and type(decoded) == "table" and decoded or nil
|
||||
end
|
||||
|
||||
local function valid_remote_id(value)
|
||||
return type(value) == "string" and #value >= 4 and #value <= 128 and value:match("^[%w_%-]+$") ~= nil
|
||||
end
|
||||
|
||||
local function verify_remote_upload(state, remote_id, uploaded_url)
|
||||
if not valid_remote_id(remote_id) or type(uploaded_url) ~= "string" or #uploaded_url > 2048
|
||||
or not uploaded_url:match("^https://")
|
||||
then
|
||||
return nil, "invalid_upload"
|
||||
end
|
||||
local remote, remote_error = get_remote_file(remote_id)
|
||||
if not remote then
|
||||
return nil, remote_error
|
||||
end
|
||||
if remote.id ~= remote_id then
|
||||
return nil, "invalid_upload"
|
||||
end
|
||||
if remote.url ~= uploaded_url and remote.originalUrl ~= uploaded_url then
|
||||
return nil, "invalid_upload"
|
||||
end
|
||||
local remote_type = tostring(remote.type or remote.mimeType or ""):lower()
|
||||
if state.media_type == "photo" and remote_type ~= "" and not remote_type:find("image", 1, true) then
|
||||
return nil, "invalid_media_type"
|
||||
end
|
||||
if state.media_type == "video" and remote_type ~= "" and not remote_type:find("video", 1, true) then
|
||||
return nil, "invalid_media_type"
|
||||
end
|
||||
local metadata = parse_metadata(remote.metadata)
|
||||
if not metadata or metadata.captureToken ~= state.capture_token then
|
||||
return nil, "invalid_upload_token"
|
||||
end
|
||||
return {
|
||||
remote_id = remote_id,
|
||||
url = remote.url or uploaded_url,
|
||||
}
|
||||
end
|
||||
|
||||
local function expire_upload(request_id)
|
||||
local state = pending_uploads[request_id]
|
||||
if not state or state.completing then
|
||||
return
|
||||
end
|
||||
pending_uploads[request_id] = nil
|
||||
upload_result(state.source, state.correlation_id, false, "upload_timeout")
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:gallery:list", function(source, data)
|
||||
local owner, error_response = session_owner(source)
|
||||
if not owner then
|
||||
return error_response
|
||||
end
|
||||
data = data or {}
|
||||
local limit = math.max(1, math.min(math.floor(tonumber(data.limit) or Config.Media.PageSize), 100))
|
||||
local offset = math.max(0, math.floor(tonumber(data.offset) or 0))
|
||||
local media_type = data.mediaType
|
||||
if media_type ~= "photo" and media_type ~= "video" then
|
||||
media_type = nil
|
||||
end
|
||||
local condition, params = owner_condition(owner)
|
||||
if media_type then
|
||||
condition = condition .. " AND `media_type` = ?"
|
||||
params[#params + 1] = media_type
|
||||
end
|
||||
params[#params + 1] = limit
|
||||
params[#params + 1] = offset
|
||||
local rows = Bridge.Database.Query(([[
|
||||
SELECT `id`, `url`, `media_type` AS `mediaType`,
|
||||
UNIX_TIMESTAMP(`created_at`) * 1000 AS `createdAt`
|
||||
FROM `sky_phone_media`
|
||||
WHERE %s
|
||||
ORDER BY `created_at` DESC, `id` DESC
|
||||
LIMIT ? OFFSET ?
|
||||
]]):format(condition), params)
|
||||
for _, row in ipairs(rows) do
|
||||
row.id = tonumber(row.id)
|
||||
row.createdAt = tonumber(row.createdAt) or 0
|
||||
end
|
||||
return { success = true, data = rows }
|
||||
end)
|
||||
|
||||
local function current_messaging_device(source)
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then
|
||||
return nil, error_response
|
||||
@@ -13,18 +260,18 @@ local function current_device(source)
|
||||
return device
|
||||
end
|
||||
|
||||
local function await_http(url, method, body, headers)
|
||||
local function await_giphy_http(url)
|
||||
local request = promise.new()
|
||||
PerformHttpRequest(url, function(status, response_body)
|
||||
request:resolve({
|
||||
body = response_body,
|
||||
status = status,
|
||||
})
|
||||
end, method, body or "", headers or {})
|
||||
return Citizen.Await(request)
|
||||
end, "GET", "", {})
|
||||
return Await(request)
|
||||
end
|
||||
|
||||
local function parse_json(value)
|
||||
local function parse_giphy_json(value)
|
||||
if type(value) == "table" then
|
||||
return value
|
||||
end
|
||||
@@ -47,7 +294,7 @@ Bridge.Callbacks.Register("sky_phone:messages:gifs", function(source, data)
|
||||
if type(data) ~= "table" then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
local device, error_response = current_device(source)
|
||||
local device, error_response = current_messaging_device(source)
|
||||
if not device then
|
||||
return error_response
|
||||
end
|
||||
@@ -71,7 +318,7 @@ Bridge.Callbacks.Register("sky_phone:messages:gifs", function(source, data)
|
||||
if query ~= "" then
|
||||
url = url .. "&q=" .. url_encode(query)
|
||||
end
|
||||
local response = await_http(url, "GET")
|
||||
local response = await_giphy_http(url)
|
||||
if response.status == 401 or response.status == 403 then
|
||||
Bridge.Debug("error", "[sky_phone] GIPHY rejected the configured API key with HTTP %s.", tostring(response.status))
|
||||
return { success = false, error = "gif_provider_unauthorized" }
|
||||
@@ -84,7 +331,7 @@ Bridge.Callbacks.Register("sky_phone:messages:gifs", function(source, data)
|
||||
Bridge.Debug("error", "[sky_phone] GIPHY request failed with HTTP %s.", tostring(response.status))
|
||||
return { success = false, error = "gif_provider_failed" }
|
||||
end
|
||||
local payload = parse_json(response.body)
|
||||
local payload = parse_giphy_json(response.body)
|
||||
if type(payload) ~= "table" or type(payload.data) ~= "table" then
|
||||
return { success = false, error = "gif_provider_failed" }
|
||||
end
|
||||
@@ -118,3 +365,232 @@ Bridge.Callbacks.Register("sky_phone:messages:gifs", function(source, data)
|
||||
},
|
||||
}
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:media:config", function(source)
|
||||
local owner, error_response = session_owner(source)
|
||||
if not owner then
|
||||
return error_response
|
||||
end
|
||||
return {
|
||||
success = true,
|
||||
data = {
|
||||
videoBitrateKbps = tonumber(Config.Media.Video.BitrateKbps) or 1500,
|
||||
},
|
||||
}
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:media:request-upload", function(data)
|
||||
local src = source
|
||||
data = data or {}
|
||||
local correlation_id = data.correlationId
|
||||
local media_type = data.mediaType
|
||||
if type(correlation_id) ~= "string" or #correlation_id > 80
|
||||
or (media_type ~= "photo" and media_type ~= "video")
|
||||
then
|
||||
upload_result(src, correlation_id, false, "invalid_request")
|
||||
return
|
||||
end
|
||||
if not SkyPhone.AllowOperation(src, "media_write", 20, 60) then
|
||||
upload_result(src, correlation_id, false, "rate_limited")
|
||||
return
|
||||
end
|
||||
local owner, error_response = session_owner(src)
|
||||
if not owner then
|
||||
upload_result(src, correlation_id, false, error_response.error)
|
||||
return
|
||||
end
|
||||
local presigned_url, presigned_error = request_presigned_url()
|
||||
if not presigned_url then
|
||||
upload_result(src, correlation_id, false, presigned_error)
|
||||
return
|
||||
end
|
||||
local ids = Bridge.Database.Query("SELECT UUID() AS `request_id`, UUID() AS `capture_token`", {})
|
||||
local request_id = ids[1] and ids[1].request_id
|
||||
local capture_token = ids[1] and ids[1].capture_token
|
||||
if type(request_id) ~= "string" or type(capture_token) ~= "string" then
|
||||
upload_result(src, correlation_id, false, "request_failed")
|
||||
return
|
||||
end
|
||||
pending_uploads[request_id] = {
|
||||
capture_token = capture_token,
|
||||
correlation_id = correlation_id,
|
||||
media_type = media_type,
|
||||
owner = owner,
|
||||
source = src,
|
||||
}
|
||||
SetTimeout(tonumber(Config.Media.UploadSessionTimeoutMs) or 60000, function()
|
||||
expire_upload(request_id)
|
||||
end)
|
||||
TriggerClientEvent("sky_phone:media:upload-ready", src, {
|
||||
captureToken = capture_token,
|
||||
correlationId = correlation_id,
|
||||
mediaType = media_type,
|
||||
photo = Config.Media.Photo,
|
||||
presignedUrl = presigned_url,
|
||||
requestId = request_id,
|
||||
uploadTimeoutMs = media_config().UploadTimeoutMs,
|
||||
video = Config.Media.Video,
|
||||
})
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:media:complete-upload", function(data)
|
||||
local src = source
|
||||
data = data or {}
|
||||
local request_id = data.requestId
|
||||
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
|
||||
if not state or state.source ~= src or state.completing then
|
||||
return
|
||||
end
|
||||
state.completing = true
|
||||
local owner, error_response = session_owner(src)
|
||||
if not owner or not owners_match(owner, state.owner) then
|
||||
pending_uploads[request_id] = nil
|
||||
upload_result(src, state.correlation_id, false, error_response and error_response.error or "owner_changed")
|
||||
return
|
||||
end
|
||||
local verified, verify_error = verify_remote_upload(state, data.remoteId, data.url)
|
||||
if not verified then
|
||||
pending_uploads[request_id] = nil
|
||||
upload_result(src, state.correlation_id, false, verify_error)
|
||||
return
|
||||
end
|
||||
local result
|
||||
if owner.account_id then
|
||||
result = Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_media` (`account_id`, `device_imei`, `url`, `remote_id`, `media_type`)
|
||||
VALUES (?, NULL, ?, ?, ?)
|
||||
]], { owner.account_id, verified.url, verified.remote_id, state.media_type })
|
||||
else
|
||||
result = Bridge.Database.Query([[
|
||||
INSERT INTO `sky_phone_media` (`account_id`, `device_imei`, `url`, `remote_id`, `media_type`)
|
||||
VALUES (NULL, ?, ?, ?, ?)
|
||||
]], { owner.imei, verified.url, verified.remote_id, state.media_type })
|
||||
end
|
||||
pending_uploads[request_id] = nil
|
||||
local media_id = type(result) == "number" and result or (type(result) == "table" and tonumber(result.insertId))
|
||||
if not media_id then
|
||||
delete_remote_file(verified.remote_id)
|
||||
upload_result(src, state.correlation_id, false, "request_failed")
|
||||
return
|
||||
end
|
||||
upload_result(src, state.correlation_id, true, nil, {
|
||||
id = media_id,
|
||||
url = verified.url,
|
||||
mediaType = state.media_type,
|
||||
createdAt = os.time() * 1000,
|
||||
})
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:media:cancel-upload", function(data)
|
||||
local src = source
|
||||
local request_id = data and data.requestId
|
||||
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
|
||||
if state and state.source == src and not state.completing then
|
||||
pending_uploads[request_id] = nil
|
||||
upload_result(src, state.correlation_id, false, "cancelled")
|
||||
end
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:media:fail-upload", function(data)
|
||||
local src = source
|
||||
local request_id = data and data.requestId
|
||||
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
|
||||
if not state or state.source ~= src or state.completing then
|
||||
return
|
||||
end
|
||||
local allowed_errors = {
|
||||
capture_failed = true,
|
||||
unsupported = true,
|
||||
upload_failed = true,
|
||||
upload_timeout = true,
|
||||
}
|
||||
pending_uploads[request_id] = nil
|
||||
local error_code = allowed_errors[data.error] and data.error or "upload_failed"
|
||||
upload_result(src, state.correlation_id, false, error_code)
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:media:delete", function(data)
|
||||
local src = source
|
||||
data = data or {}
|
||||
local correlation_id = data.correlationId
|
||||
local media_id = tonumber(data.id)
|
||||
if type(correlation_id) ~= "string" or #correlation_id > 80 or not media_id then
|
||||
delete_result(src, correlation_id, false, "invalid_request", media_id)
|
||||
return
|
||||
end
|
||||
if not SkyPhone.AllowOperation(src, "media_delete", 30, 60) then
|
||||
delete_result(src, correlation_id, false, "rate_limited", media_id)
|
||||
return
|
||||
end
|
||||
local owner, error_response = session_owner(src)
|
||||
if not owner then
|
||||
delete_result(src, correlation_id, false, error_response.error, media_id)
|
||||
return
|
||||
end
|
||||
local condition, params = owner_condition(owner)
|
||||
local query_params = { media_id }
|
||||
for _, value in ipairs(params) do
|
||||
query_params[#query_params + 1] = value
|
||||
end
|
||||
local rows = Bridge.Database.Query(([[
|
||||
SELECT `id`, `remote_id` FROM `sky_phone_media`
|
||||
WHERE `id` = ? AND %s LIMIT 1
|
||||
]]):format(condition), query_params)
|
||||
local row = rows[1]
|
||||
if not row then
|
||||
delete_result(src, correlation_id, false, "not_found", media_id)
|
||||
return
|
||||
end
|
||||
if pending_deletes[media_id] then
|
||||
delete_result(src, correlation_id, false, "operation_in_progress", media_id)
|
||||
return
|
||||
end
|
||||
pending_deletes[media_id] = src
|
||||
local deleted, delete_error = delete_remote_file(row.remote_id)
|
||||
if not deleted then
|
||||
pending_deletes[media_id] = nil
|
||||
delete_result(src, correlation_id, false, delete_error, media_id)
|
||||
return
|
||||
end
|
||||
Bridge.Database.Query(("DELETE FROM `sky_phone_media` WHERE `id` = ? AND %s"):format(condition), query_params)
|
||||
pending_deletes[media_id] = nil
|
||||
delete_result(src, correlation_id, true, nil, media_id)
|
||||
end)
|
||||
|
||||
function SkyPhoneMedia.GetDeviceRemoteIds(imei)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `id`, `remote_id` FROM `sky_phone_media`
|
||||
WHERE `account_id` IS NULL AND `device_imei` = ?
|
||||
]], { imei })
|
||||
return rows
|
||||
end
|
||||
|
||||
function SkyPhoneMedia.CleanupRemoteFiles(rows)
|
||||
CreateThread(function()
|
||||
for _, row in ipairs(rows) do
|
||||
local deleted, delete_error = delete_remote_file(row.remote_id)
|
||||
if not deleted then
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] Could not delete remote media %s during factory reset: %s.",
|
||||
tostring(row.id),
|
||||
tostring(delete_error)
|
||||
)
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
AddEventHandler("playerDropped", function()
|
||||
local src = source
|
||||
for request_id, state in pairs(pending_uploads) do
|
||||
if state.source == src then
|
||||
pending_uploads[request_id] = nil
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
if not api_configured() then
|
||||
print("^3[sky_phone] Camera and Gallery uploads are disabled until Config.Media.FiveManage.ApiKey is set in config/media.lua.^7")
|
||||
end
|
||||
end)
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
Bridge.Database.AfterMigration("sky_phone", function()
|
||||
local categories = {}
|
||||
local districts = {}
|
||||
local photo_gradients = {}
|
||||
for _, value in ipairs(Config.LocalPages.Categories) do categories[value] = true end
|
||||
for _, value in ipairs(Config.Marketplace.Districts) do districts[value] = true end
|
||||
for _, value in ipairs(Config.Marketplace.PhotoGradients) do photo_gradients[value] = 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 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 Local Pages id.")
|
||||
end
|
||||
return rows[1].id
|
||||
end
|
||||
|
||||
local function load_images(post_id)
|
||||
return Bridge.Database.Query([[
|
||||
SELECT `media_id`, `gradient`, `sort_order`
|
||||
FROM `sky_phone_pages_images`
|
||||
WHERE `post_id` = ?
|
||||
ORDER BY `sort_order`
|
||||
]], { post_id })
|
||||
end
|
||||
|
||||
local function validate_images(source, imei, images)
|
||||
if type(images) ~= "table" or #images > Config.LocalPages.MaxImages then return nil end
|
||||
if #images == 0 then return {} end
|
||||
|
||||
local owned_media = {
|
||||
["sunset-drive"] = Config.Marketplace.PhotoGradients[1],
|
||||
["ocean-air"] = Config.Marketplace.PhotoGradients[2],
|
||||
["city-lights"] = Config.Marketplace.PhotoGradients[3],
|
||||
["desert-road"] = Config.Marketplace.PhotoGradients[4],
|
||||
}
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `payload` FROM `sky_phone_device_data`
|
||||
WHERE `device_imei` = ? AND `namespace` = 'media'
|
||||
LIMIT 1
|
||||
]], { imei })
|
||||
local media = rows[1] and json.decode(rows[1].payload) or nil
|
||||
for _, capture in ipairs(type(media) == "table" and media.captures or {}) do
|
||||
if type(capture) == "table" and type(capture.id) == "string" and photo_gradients[capture.gradient] then
|
||||
owned_media[capture.id] = capture.gradient
|
||||
end
|
||||
end
|
||||
|
||||
local normalized = {}
|
||||
local seen = {}
|
||||
for index, image in ipairs(images) do
|
||||
local media_id = type(image) == "table" and image.id or nil
|
||||
local gradient = media_id and owned_media[media_id] or nil
|
||||
if type(media_id) ~= "string" or #media_id > 64 or not gradient or seen[media_id] then
|
||||
Bridge.Debug("warn", "[sky_phone] Rejected unowned Local Pages image from source %s.", tostring(source))
|
||||
return nil
|
||||
end
|
||||
seen[media_id] = true
|
||||
normalized[index] = { id = media_id, gradient = gradient }
|
||||
end
|
||||
return normalized
|
||||
end
|
||||
|
||||
local function hydrate_posts(rows)
|
||||
for _, post in ipairs(rows) do
|
||||
local created_at = tonumber(post.created_at_unix)
|
||||
if not created_at then
|
||||
error("[sky_phone] Local Pages post has an invalid created_at timestamp.")
|
||||
end
|
||||
post.created_at = created_at * 1000
|
||||
post.created_at_unix = nil
|
||||
post.images = load_images(post.id)
|
||||
post.like_count = tonumber(post.like_count) or 0
|
||||
post.is_liked = tonumber(post.is_liked) or 0
|
||||
post.is_saved = tonumber(post.is_saved) or 0
|
||||
post.is_owner = tonumber(post.is_owner) or 0
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
local function list_posts(account_id, where_clause, values, limit, offset)
|
||||
local parameters = { account_id or 0, account_id or 0, account_id or 0 }
|
||||
for _, value in ipairs(values) do parameters[#parameters + 1] = value end
|
||||
parameters[#parameters + 1] = limit
|
||||
parameters[#parameters + 1] = offset
|
||||
return hydrate_posts(Bridge.Database.Query(([[
|
||||
SELECT p.`id`, p.`title`, p.`body`, p.`category`, p.`district`, p.`source_type`,
|
||||
p.`citymarkt_listing_id`, UNIX_TIMESTAMP(p.`created_at`) AS `created_at_unix`,
|
||||
SUBSTRING_INDEX(a.`email`, '@', 1) AS `author_name`,
|
||||
(p.`account_id` = ?) AS `is_owner`,
|
||||
EXISTS(SELECT 1 FROM `sky_phone_pages_reactions` r WHERE r.`post_id` = p.`id`
|
||||
AND r.`account_id` = ? AND r.`kind` = 'like') AS `is_liked`,
|
||||
EXISTS(SELECT 1 FROM `sky_phone_pages_reactions` r WHERE r.`post_id` = p.`id`
|
||||
AND r.`account_id` = ? AND r.`kind` = 'save') AS `is_saved`,
|
||||
(SELECT COUNT(*) FROM `sky_phone_pages_reactions` r
|
||||
WHERE r.`post_id` = p.`id` AND r.`kind` = 'like') AS `like_count`,
|
||||
(SELECT i.`gradient` FROM `sky_phone_pages_images` i
|
||||
WHERE i.`post_id` = p.`id` ORDER BY i.`sort_order` LIMIT 1) AS `image`,
|
||||
m.`price` AS `citymarkt_price`
|
||||
FROM `sky_phone_pages_posts` p
|
||||
JOIN `sky_phone_accounts` a ON a.`id` = p.`account_id`
|
||||
LEFT JOIN `sky_phone_marketplace_listings` m ON m.`id` = p.`citymarkt_listing_id`
|
||||
WHERE %s
|
||||
ORDER BY p.`created_at` DESC
|
||||
LIMIT ? OFFSET ?
|
||||
]]):format(where_clause), parameters))
|
||||
end
|
||||
|
||||
local function optional_account(source)
|
||||
local session, error_response = SkyPhone.RequireSession(source)
|
||||
if not session then return nil, error_response end
|
||||
local rows = Bridge.Database.Query("SELECT `account_id` FROM `sky_phone_devices` WHERE `imei` = ? LIMIT 1", { session.imei })
|
||||
return rows[1] and tonumber(rows[1].account_id) or nil, nil
|
||||
end
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:pages:list", function(source, data)
|
||||
if type(data) ~= "table" then return { success = false, error = "invalid_request" } end
|
||||
local account_id, error_response = optional_account(source)
|
||||
if error_response then return error_response end
|
||||
local limit = Config.LocalPages.PageSize
|
||||
local offset = math.max(0, math.floor(tonumber(data.offset) or 0))
|
||||
local where = { "1 = 1" }
|
||||
local values = {}
|
||||
if data.category and data.category ~= "all" then
|
||||
if data.category ~= "citymarkt" and not categories[data.category] then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
where[#where + 1] = "p.`category` = ?"
|
||||
values[#values + 1] = data.category
|
||||
end
|
||||
local search = trim(data.search)
|
||||
if search and search ~= "" then
|
||||
if utf8.len(search) > 80 then return { success = false, error = "invalid_request" } end
|
||||
where[#where + 1] = "(p.`title` LIKE ? OR p.`body` LIKE ?)"
|
||||
values[#values + 1] = "%" .. search .. "%"
|
||||
values[#values + 1] = "%" .. search .. "%"
|
||||
end
|
||||
if data.saved == true then
|
||||
if not account_id then return { success = false, error = "not_authenticated" } end
|
||||
where[#where + 1] = "EXISTS(SELECT 1 FROM `sky_phone_pages_reactions` sr WHERE sr.`post_id` = p.`id` AND sr.`account_id` = ? AND sr.`kind` = 'save')"
|
||||
values[#values + 1] = account_id
|
||||
end
|
||||
local rows = list_posts(account_id, table.concat(where, " AND "), values, limit + 1, offset)
|
||||
local has_more = #rows > limit
|
||||
if has_more then rows[#rows] = nil end
|
||||
return { success = true, data = { items = rows, offset = offset, hasMore = has_more } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:pages:list-own", function(source)
|
||||
local account, error_response = SkyPhone.RequireAccount(source)
|
||||
if not account then return error_response end
|
||||
local rows = list_posts(account.id, "p.`account_id` = ?", { account.id }, Config.LocalPages.PageSize, 0)
|
||||
return { success = true, data = { items = rows, offset = 0, hasMore = false } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:pages:get", function(source, data)
|
||||
if type(data) ~= "table" or type(data.id) ~= "string" then
|
||||
return { success = false, error = "post_not_found" }
|
||||
end
|
||||
local account_id, error_response = optional_account(source)
|
||||
if error_response then return error_response end
|
||||
local rows = list_posts(account_id, "p.`id` = ?", { data.id }, 1, 0)
|
||||
return rows[1] and { success = true, data = rows[1] } or { success = false, error = "post_not_found" }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:pages:create", function(source, data)
|
||||
local account, error_response = SkyPhone.RequireAccount(source)
|
||||
if not account then return error_response end
|
||||
if not SkyPhone.AllowOperation(source, "pages:create", 6, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
if type(data) ~= "table" then return { success = false, error = "invalid_post" } end
|
||||
local title = trim(data.title)
|
||||
local body = trim(data.body)
|
||||
local district = data.district == "" and nil or data.district
|
||||
if not valid_text(title, Config.LocalPages.TitleMinLength, Config.LocalPages.TitleMaxLength)
|
||||
or not valid_text(body, Config.LocalPages.BodyMinLength, Config.LocalPages.BodyMaxLength)
|
||||
or not categories[data.category]
|
||||
or (district and not districts[district])
|
||||
then
|
||||
return { success = false, error = "invalid_post" }
|
||||
end
|
||||
local images = validate_images(source, account.imei, data.images)
|
||||
if not images then return { success = false, error = "invalid_images" } end
|
||||
local id = new_id()
|
||||
local statements = {{
|
||||
query = [[INSERT INTO `sky_phone_pages_posts`
|
||||
(`id`, `account_id`, `source_type`, `title`, `body`, `category`, `district`)
|
||||
VALUES (?, ?, 'personal', ?, ?, ?, ?)]],
|
||||
params = { id, account.id, title, body, data.category, district },
|
||||
}}
|
||||
for index, image in ipairs(images) do
|
||||
statements[#statements + 1] = {
|
||||
query = "INSERT INTO `sky_phone_pages_images` (`post_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
|
||||
return { success = true, data = { id = id } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:pages:share-citymarkt", function(source, data)
|
||||
local account, error_response = SkyPhone.RequireAccount(source)
|
||||
if not account then return error_response end
|
||||
if not SkyPhone.AllowOperation(source, "pages:share-citymarkt", 3, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
if type(data) ~= "table" or type(data.listingId) ~= "string" then
|
||||
return { success = false, error = "citymarkt_not_found" }
|
||||
end
|
||||
local listings = Bridge.Database.Query([[
|
||||
SELECT `id`, `title`, `description`, `district`
|
||||
FROM `sky_phone_marketplace_listings`
|
||||
WHERE `id` = ? AND `seller_account_id` = ? AND `status` IN ('active', 'reserved')
|
||||
LIMIT 1
|
||||
]], { data.listingId, account.id })
|
||||
local listing = listings[1]
|
||||
if not listing then return { success = false, error = "citymarkt_not_found" } end
|
||||
local existing = Bridge.Database.Query("SELECT `id` FROM `sky_phone_pages_posts` WHERE `citymarkt_listing_id` = ? LIMIT 1", { listing.id })
|
||||
if existing[1] then return { success = false, error = "citymarkt_already_shared" } end
|
||||
local daily = Bridge.Database.Query([[
|
||||
SELECT COUNT(*) AS `count` FROM `sky_phone_pages_posts`
|
||||
WHERE `account_id` = ? AND `source_type` = 'citymarkt' AND `share_date` = CURRENT_DATE
|
||||
]], { account.id })
|
||||
if (tonumber(daily[1] and daily[1].count) or 0) >= Config.LocalPages.CityMarktSharesPerDay then
|
||||
return { success = false, error = "citymarkt_daily_limit" }
|
||||
end
|
||||
local id = new_id()
|
||||
local statements = {{
|
||||
query = [[INSERT INTO `sky_phone_pages_posts`
|
||||
(`id`, `account_id`, `source_type`, `share_date`, `citymarkt_listing_id`, `title`, `body`, `category`, `district`)
|
||||
VALUES (?, ?, 'citymarkt', CURRENT_DATE, ?, ?, ?, 'citymarkt', ?)]],
|
||||
params = { id, account.id, listing.id, listing.title, listing.description, listing.district },
|
||||
}}
|
||||
local images = Bridge.Database.Query([[
|
||||
SELECT `media_id`, `gradient`, `sort_order` FROM `sky_phone_marketplace_images`
|
||||
WHERE `listing_id` = ? ORDER BY `sort_order` LIMIT ?
|
||||
]], { listing.id, Config.LocalPages.MaxImages })
|
||||
for _, image in ipairs(images) do
|
||||
statements[#statements + 1] = {
|
||||
query = "INSERT INTO `sky_phone_pages_images` (`post_id`, `media_id`, `gradient`, `sort_order`) VALUES (?, ?, ?, ?)",
|
||||
params = { id, image.media_id, image.gradient, image.sort_order },
|
||||
}
|
||||
end
|
||||
if not Bridge.Database.Transaction(statements) then
|
||||
return { success = false, error = "citymarkt_daily_limit" }
|
||||
end
|
||||
return { success = true, data = { id = id } }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:pages:react", function(source, data)
|
||||
local account, error_response = SkyPhone.RequireAccount(source)
|
||||
if not account then return error_response end
|
||||
if not SkyPhone.AllowOperation(source, "pages:react", 30, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
if type(data) ~= "table" or type(data.id) ~= "string" or (data.kind ~= "like" and data.kind ~= "save") or type(data.active) ~= "boolean" then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
local posts = Bridge.Database.Query("SELECT `id` FROM `sky_phone_pages_posts` WHERE `id` = ? LIMIT 1", { data.id })
|
||||
if not posts[1] then return { success = false, error = "post_not_found" } end
|
||||
if data.active then
|
||||
Bridge.Database.Query("INSERT IGNORE INTO `sky_phone_pages_reactions` (`post_id`, `account_id`, `kind`) VALUES (?, ?, ?)", { data.id, account.id, data.kind })
|
||||
else
|
||||
Bridge.Database.Query("DELETE FROM `sky_phone_pages_reactions` WHERE `post_id` = ? AND `account_id` = ? AND `kind` = ?", { data.id, account.id, data.kind })
|
||||
end
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:pages:delete", function(source, data)
|
||||
local account, error_response = SkyPhone.RequireAccount(source)
|
||||
if not account then return error_response end
|
||||
if type(data) ~= "table" or type(data.id) ~= "string" then
|
||||
return { success = false, error = "post_not_found" }
|
||||
end
|
||||
local result = Bridge.Database.Query("DELETE FROM `sky_phone_pages_posts` WHERE `id` = ? AND `account_id` = ?", { data.id, account.id })
|
||||
local affected = type(result) == "number" and result or type(result) == "table" and tonumber(result.affectedRows) or 0
|
||||
return affected > 0 and { success = true } or { success = false, error = "post_not_found" }
|
||||
end)
|
||||
end)
|
||||
@@ -12,8 +12,8 @@ local allowed_device_namespaces = {
|
||||
notifications = true,
|
||||
wallpaper = true,
|
||||
alarms = true,
|
||||
media = true,
|
||||
apps = true,
|
||||
games = true,
|
||||
}
|
||||
|
||||
local function trim(value)
|
||||
@@ -357,6 +357,14 @@ local function link_account(source, account)
|
||||
]],
|
||||
params = { account.id, session.imei },
|
||||
},
|
||||
{
|
||||
query = [[
|
||||
UPDATE `sky_phone_media`
|
||||
SET `account_id` = ?, `device_imei` = NULL
|
||||
WHERE `device_imei` = ? AND `account_id` IS NULL
|
||||
]],
|
||||
params = { account.id, session.imei },
|
||||
},
|
||||
}) then
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
@@ -753,6 +761,7 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
|
||||
if not session then
|
||||
return error_response
|
||||
end
|
||||
local media_remote_ids = SkyPhoneMedia.GetDeviceRemoteIds(session.imei)
|
||||
if not Bridge.Database.Transaction({
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_device_data` WHERE `device_imei` = ?",
|
||||
@@ -762,6 +771,10 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
|
||||
query = "DELETE FROM `sky_phone_notes` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||
params = { session.imei },
|
||||
},
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_media` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||
params = { session.imei },
|
||||
},
|
||||
{
|
||||
query = "DELETE FROM `sky_phone_contacts` WHERE `device_imei` = ? AND `account_id` IS NULL",
|
||||
params = { session.imei },
|
||||
@@ -777,6 +790,7 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source)
|
||||
}) then
|
||||
return { success = false, error = "request_failed" }
|
||||
end
|
||||
SkyPhoneMedia.CleanupRemoteFiles(media_remote_ids)
|
||||
refresh_source(source)
|
||||
return { success = true }
|
||||
end)
|
||||
|
||||
Reference in New Issue
Block a user