diff --git a/app/Console/Commands/AddSiteSettings.php b/app/Console/Commands/AddSiteSettings.php index 424fa26124..b5dfe41fec 100644 --- a/app/Console/Commands/AddSiteSettings.php +++ b/app/Console/Commands/AddSiteSettings.php @@ -45,6 +45,12 @@ public function handle() { $this->addSiteSetting('open_transfers_queue', 0, '0: Character transfers do not need mod approval, 1: Transfers must be approved by a mod.'); + $this->addSiteSetting('open_trades_queue', 0, '0: Trades do not need mod approval, 1: Trades must be approved by a mod.'); + + $this->addSiteSetting('trade_listing_duration', 14, 'Number of days a trade listing is displayed for.'); + + $this->addSiteSetting('trade_listing_limit', 3, 'Number of trade listings a user can have at once.'); + $this->addSiteSetting('is_prompts_open', 1, '0: New prompt submissions cannot be made (mods can work on the queue still), 1: Prompts are submittable.'); $this->addSiteSetting('is_claims_open', 1, '0: New claims cannot be made (mods can work on the queue still), 1: Claims are submittable.'); diff --git a/app/Helpers/AssetHelpers.php b/app/Helpers/AssetHelpers.php index 0743861e1a..6280958b41 100644 --- a/app/Helpers/AssetHelpers.php +++ b/app/Helpers/AssetHelpers.php @@ -437,6 +437,31 @@ function countAssets($array) { return $count; } +/** + * Returns whether or not an asset can be traded based on its type. + * + * @param mixed $type + * @param mixed $asset + * + * @return bool + */ +function canTradeAsset($type, $asset) { + switch ($type) { + case 'Item': + return $asset->allow_transfer; + break; + case 'Currency': + // we don't have to worry about character->user or user->character transfers here + // technically you can loophole by transferring to a character and then transferring to a user + // but that is a process issue, not a code issue + return $asset->is_user_owned && $asset->allow_user_to_user; + break; + default: + return false; + break; + } +} + /** * Distributes the assets in an assets array to the given recipient (character). * Loot tables will be rolled before distribution. diff --git a/app/Http/Controllers/Admin/Characters/CharacterController.php b/app/Http/Controllers/Admin/Characters/CharacterController.php index 70018d20f3..637a4f6765 100644 --- a/app/Http/Controllers/Admin/Characters/CharacterController.php +++ b/app/Http/Controllers/Admin/Characters/CharacterController.php @@ -12,10 +12,9 @@ use App\Models\Rarity; use App\Models\Species\Species; use App\Models\Species\Subtype; -use App\Models\Trade; +use App\Models\Trade\Trade; use App\Models\User\User; use App\Services\CharacterManager; -use App\Services\TradeManager; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -649,86 +648,6 @@ public function postTransferQueue(Request $request, CharacterManager $service, $ return redirect()->back(); } - /** - * Shows the character trade queue. - * - * @param string $type - * - * @return \Illuminate\Contracts\Support\Renderable - */ - public function getTradeQueue($type) { - $trades = Trade::query(); - $user = Auth::user(); - - if ($type == 'completed') { - $trades->completed(); - } elseif ($type == 'incoming') { - $trades->where('status', 'Pending'); - } else { - abort(404); - } - - $openTransfersQueue = Settings::get('open_transfers_queue'); - - return view('admin.masterlist.character_trades', [ - 'trades' => $trades->orderBy('id', 'DESC')->paginate(20), - 'tradesQueue' => Settings::get('open_transfers_queue'), - 'openTransfersQueue' => $openTransfersQueue, - 'transferCount' => $openTransfersQueue ? CharacterTransfer::active()->where('is_approved', 0)->count() : 0, - 'tradeCount' => $openTransfersQueue ? Trade::where('status', 'Pending')->count() : 0, - ]); - } - - /** - * Shows the character trade action modal. - * - * @param int $id - * @param string $action - * - * @return \Illuminate\Contracts\Support\Renderable - */ - public function getTradeModal($id, $action) { - if ($action != 'approve' && $action != 'reject') { - abort(404); - } - $trade = Trade::where('id', $id)->first(); - if (!$trade) { - abort(404); - } - - return view('admin.masterlist._'.$action.'_trade_modal', [ - 'trade' => $trade, - 'cooldown' => Settings::get('transfer_cooldown'), - ]); - } - - /** - * Acts on a trade in the trade queue. - * - * @param App\Services\CharacterManager $service - * @param int $id - * - * @return \Illuminate\Http\RedirectResponse - */ - public function postTradeQueue(Request $request, TradeManager $service, $id) { - if (!Auth::check()) { - abort(404); - } - - $action = strtolower($request->get('action')); - if ($action == 'approve' && $service->approveTrade($request->only(['action', 'cooldowns']) + ['id' => $id], Auth::user())) { - flash('Trade approved.')->success(); - } elseif ($action == 'reject' && $service->rejectTrade($request->only(['action', 'reason']) + ['id' => $id], Auth::user())) { - flash('Trade rejected.')->success(); - } else { - foreach ($service->errors()->getMessages()['error'] as $error) { - flash($error)->error(); - } - } - - return redirect()->back(); - } - /** * Shows a list of all existing MYO slots. * diff --git a/app/Http/Controllers/Admin/HomeController.php b/app/Http/Controllers/Admin/HomeController.php index dfea74f501..36e25904b0 100644 --- a/app/Http/Controllers/Admin/HomeController.php +++ b/app/Http/Controllers/Admin/HomeController.php @@ -11,7 +11,7 @@ use App\Models\Gallery\GallerySubmission; use App\Models\Report\Report; use App\Models\Submission\Submission; -use App\Models\Trade; +use App\Models\Trade\Trade; use App\Models\User\User; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -25,6 +25,7 @@ class HomeController extends Controller { */ public function getIndex() { $openTransfersQueue = Settings::get('open_transfers_queue'); + $openTradesQueue = Settings::get('open_trades_queue'); $galleryRequireApproval = Settings::get('gallery_submissions_require_approval'); $galleryCurrencyAwards = Settings::get('gallery_submissions_reward_currency'); @@ -36,6 +37,7 @@ public function getIndex() { 'reportCount' => Report::where('status', 'Pending')->count(), 'assignedReportCount' => Report::assignedToMe(Auth::user())->count(), 'openTransfersQueue' => $openTransfersQueue, + 'openTradesQueue' => $openTradesQueue, 'transferCount' => $openTransfersQueue ? CharacterTransfer::active()->where('is_approved', 0)->count() : 0, 'tradeCount' => $openTransfersQueue ? Trade::where('status', 'Pending')->count() : 0, 'galleryRequireApproval' => $galleryRequireApproval, diff --git a/app/Http/Controllers/Admin/TradeController.php b/app/Http/Controllers/Admin/TradeController.php new file mode 100644 index 0000000000..0b877efd8b --- /dev/null +++ b/app/Http/Controllers/Admin/TradeController.php @@ -0,0 +1,103 @@ +only(['sort']); + if (isset($data['sort'])) { + switch ($data['sort']) { + case 'newest': + $trades->sortNewest(); + break; + case 'oldest': + $trades->sortOldest(); + break; + } + } else { + $trades->sortOldest(); + } + + if ($type == 'completed') { + $trades->completed(); + } elseif ($type == 'incoming') { + $trades->where('status', 'Pending'); + } else { + abort(404); + } + + $openTransfersQueue = Settings::get('open_trades_queue'); + + return view('admin.trades.trades', [ + 'trades' => $trades->orderBy('id', 'DESC')->paginate(20), + 'tradesQueue' => Settings::get('open_trades_queue'), + 'tradeCount' => $openTransfersQueue ? Trade::where('status', 'Pending')->count() : 0, + ]); + } + + /** + * Shows the character trade action modal. + * + * @param int $id + * @param string $action + * + * @return \Illuminate\Contracts\Support\Renderable + */ + public function getTradeModal($id, $action) { + if ($action != 'approve' && $action != 'reject') { + abort(404); + } + $trade = Trade::where('id', $id)->first(); + if (!$trade) { + abort(404); + } + + return view('admin.trades._'.$action.'_trade_modal', [ + 'trade' => $trade, + 'cooldown' => Settings::get('transfer_cooldown'), + ]); + } + + /** + * Acts on a trade in the trade queue. + * + * @param App\Services\CharacterManager $service + * @param int $id + * + * @return \Illuminate\Http\RedirectResponse + */ + public function postTradeQueue(Request $request, TradeManager $service, $id) { + if (!Auth::check()) { + abort(404); + } + + $action = strtolower($request->get('action')); + if ($action == 'approve' && $service->approveTrade($request->only(['action', 'cooldowns']) + ['id' => $id], Auth::user())) { + flash('Trade approved.')->success(); + } elseif ($action == 'reject' && $service->rejectTrade($request->only(['action', 'reason']) + ['id' => $id], Auth::user())) { + flash('Trade rejected.')->success(); + } else { + foreach ($service->errors()->getMessages()['error'] as $error) { + flash($error)->error(); + } + } + + return redirect()->back(); + } +} diff --git a/app/Http/Controllers/Admin/Users/GrantController.php b/app/Http/Controllers/Admin/Users/GrantController.php index bbe1d52df5..84a8f97a46 100644 --- a/app/Http/Controllers/Admin/Users/GrantController.php +++ b/app/Http/Controllers/Admin/Users/GrantController.php @@ -9,7 +9,7 @@ use App\Models\Currency\Currency; use App\Models\Item\Item; use App\Models\Submission\Submission; -use App\Models\Trade; +use App\Models\Trade\Trade; use App\Models\User\User; use App\Models\User\UserItem; use App\Services\CurrencyManager; diff --git a/app/Http/Controllers/Characters/DesignController.php b/app/Http/Controllers/Characters/DesignController.php index e178537739..58ea7a54b1 100644 --- a/app/Http/Controllers/Characters/DesignController.php +++ b/app/Http/Controllers/Characters/DesignController.php @@ -170,12 +170,21 @@ public function getAddons($id) { $inventory = isset($r->data['user']) ? parseAssetData($r->data['user']) : null; } + $item_filter = Item::orderBy('name')->get()->mapWithKeys(function ($item) { + return [ + $item->id => json_encode([ + 'name' => $item->name, + 'image_url' => $item->image_url, + ]), + ]; + }); + return view('character.design.addons', [ 'request' => $r, 'categories' => ItemCategory::visible(Auth::user() ?? null)->orderBy('sort', 'DESC')->get(), 'inventory' => $inventory, 'items' => Item::all()->keyBy('id'), - 'item_filter' => Item::orderBy('name')->get()->keyBy('id'), + 'item_filter' => $item_filter, 'page' => 'update', ]); } diff --git a/app/Http/Controllers/Comments/CommentController.php b/app/Http/Controllers/Comments/CommentController.php index 1adb184dd2..70dfddb407 100644 --- a/app/Http/Controllers/Comments/CommentController.php +++ b/app/Http/Controllers/Comments/CommentController.php @@ -10,6 +10,7 @@ use App\Models\Report\Report; use App\Models\Sales\Sales; use App\Models\SitePage; +use App\Models\Trade\TradeListing; use App\Models\User\User; use Illuminate\Http\Request; use Illuminate\Routing\Controller; @@ -130,6 +131,12 @@ public function store(Request $request, $model, $id) { $post = 'your site page'; $link = $page->url.'/#comment-'.$comment->getKey(); break; + case 'App\Models\Trade\TradeListing': + $listing = TradeListing::find($comment->commentable_id); + $recipient = $listing->user; + $post = 'your trade listing'; + $link = $listing->url.'/#comment-'.$comment->getKey(); + break; case 'App\Models\Gallery\GallerySubmission': $submission = GallerySubmission::find($comment->commentable_id); if ($type == 'Staff-Staff') { diff --git a/app/Http/Controllers/Users/InventoryController.php b/app/Http/Controllers/Users/InventoryController.php index b0f603c769..1db8870cdb 100644 --- a/app/Http/Controllers/Users/InventoryController.php +++ b/app/Http/Controllers/Users/InventoryController.php @@ -11,7 +11,7 @@ use App\Models\Item\ItemCategory; use App\Models\Rarity; use App\Models\Submission\Submission; -use App\Models\Trade; +use App\Models\Trade\Trade; use App\Models\User\User; use App\Models\User\UserItem; use App\Services\InventoryManager; diff --git a/app/Http/Controllers/Users/SubmissionController.php b/app/Http/Controllers/Users/SubmissionController.php index 00e476fedb..3919af313c 100644 --- a/app/Http/Controllers/Users/SubmissionController.php +++ b/app/Http/Controllers/Users/SubmissionController.php @@ -100,6 +100,15 @@ public function getNewSubmission(Request $request) { $gallerySubmissions = []; } + $item_filter = Item::released()->orderBy('name')->get()->mapWithKeys(function ($item) { + return [ + $item->id => json_encode([ + 'name' => $item->name, + 'image_url' => $item->image_url, + ]), + ]; + }); + return view('home.create_submission', [ 'closed' => $closed, 'isClaim' => false, @@ -108,7 +117,7 @@ public function getNewSubmission(Request $request) { 'prompts' => Prompt::active()->sortAlphabetical()->pluck('name', 'id')->toArray(), 'characterCurrencies' => Currency::where('is_character_owned', 1)->orderBy('sort_character', 'DESC')->pluck('name', 'id'), 'categories' => ItemCategory::visible(Auth::user() ?? null)->orderBy('sort', 'DESC')->get(), - 'item_filter' => Item::orderBy('name')->released()->get()->keyBy('id'), + 'item_filter' => $item_filter, 'items' => Item::orderBy('name')->released()->pluck('name', 'id'), 'character_items' => Item::whereIn('item_category_id', ItemCategory::where('is_character_owned', 1)->pluck('id')->toArray())->orderBy('name')->released()->pluck('name', 'id'), 'currencies' => Currency::where('is_user_owned', 1)->orderBy('name')->pluck('name', 'id'), @@ -143,6 +152,15 @@ public function getEditSubmission(Request $request, $id) { $gallerySubmissions = []; } + $item_filter = Item::released()->orderBy('name')->get()->mapWithKeys(function ($item) { + return [ + $item->id => json_encode([ + 'name' => $item->name, + 'image_url' => $item->image_url, + ]), + ]; + }); + return view('home.edit_submission', [ 'closed' => $closed, 'isClaim' => false, @@ -151,7 +169,7 @@ public function getEditSubmission(Request $request, $id) { 'prompts' => Prompt::active()->sortAlphabetical()->pluck('name', 'id')->toArray(), 'characterCurrencies' => Currency::where('is_character_owned', 1)->orderBy('sort_character', 'DESC')->pluck('name', 'id'), 'categories' => ItemCategory::orderBy('sort', 'DESC')->get(), - 'item_filter' => Item::orderBy('name')->released()->get()->keyBy('id'), + 'item_filter' => $item_filter, 'items' => Item::orderBy('name')->released()->pluck('name', 'id'), 'character_items' => Item::whereIn('item_category_id', ItemCategory::where('is_character_owned', 1)->pluck('id')->toArray())->orderBy('name')->released()->pluck('name', 'id'), 'currencies' => Currency::where('is_user_owned', 1)->orderBy('name')->pluck('name', 'id'), @@ -410,6 +428,15 @@ public function getNewClaim(Request $request) { $closed = !Settings::get('is_claims_open'); $inventory = UserItem::with('item')->whereNull('deleted_at')->where('count', '>', '0')->where('user_id', Auth::user()->id)->get(); + $item_filter = Item::released()->orderBy('name')->get()->mapWithKeys(function ($item) { + return [ + $item->id => json_encode([ + 'name' => $item->name, + 'image_url' => $item->image_url, + ]), + ]; + }); + return view('home.create_submission', [ 'closed' => $closed, 'isClaim' => true, @@ -418,7 +445,7 @@ public function getNewClaim(Request $request) { 'characterCurrencies' => Currency::where('is_character_owned', 1)->orderBy('sort_character', 'DESC')->pluck('name', 'id'), 'categories' => ItemCategory::visible(Auth::user() ?? null)->orderBy('sort', 'DESC')->get(), 'inventory' => $inventory, - 'item_filter' => Item::orderBy('name')->released()->get()->keyBy('id'), + 'item_filter' => $item_filter, 'items' => Item::orderBy('name')->released()->pluck('name', 'id'), 'currencies' => Currency::where('is_user_owned', 1)->orderBy('name')->pluck('name', 'id'), 'raffles' => Raffle::where('rolled_at', null)->where('is_active', 1)->orderBy('name')->pluck('name', 'id'), @@ -443,6 +470,15 @@ public function getEditClaim(Request $request, $id) { abort(404); } + $item_filter = Item::released()->orderBy('name')->get()->mapWithKeys(function ($item) { + return [ + $item->id => json_encode([ + 'name' => $item->name, + 'image_url' => $item->image_url, + ]), + ]; + }); + return view('home.edit_submission', [ 'closed' => $closed, 'isClaim' => true, @@ -452,7 +488,7 @@ public function getEditClaim(Request $request, $id) { 'character_items' => Item::whereIn('item_category_id', ItemCategory::where('is_character_owned', 1)->pluck('id')->toArray())->orderBy('name')->released()->pluck('name', 'id'), 'categories' => ItemCategory::orderBy('sort', 'DESC')->get(), 'currencies' => Currency::where('is_user_owned', 1)->orderBy('name')->pluck('name', 'id'), - 'item_filter' => Item::orderBy('name')->released()->get()->keyBy('id'), + 'item_filter' => $item_filter, 'items' => Item::orderBy('name')->released()->pluck('name', 'id'), 'inventory' => $inventory, 'raffles' => Raffle::where('rolled_at', null)->where('is_active', 1)->orderBy('name')->pluck('name', 'id'), diff --git a/app/Http/Controllers/Users/TradeController.php b/app/Http/Controllers/Users/TradeController.php index d7ce4f73e2..4fd5729d7b 100644 --- a/app/Http/Controllers/Users/TradeController.php +++ b/app/Http/Controllers/Users/TradeController.php @@ -2,13 +2,17 @@ namespace App\Http\Controllers\Users; +use App\Facades\Settings; use App\Http\Controllers\Controller; use App\Models\Character\CharacterCategory; +use App\Models\Currency\Currency; use App\Models\Item\Item; use App\Models\Item\ItemCategory; -use App\Models\Trade; +use App\Models\Trade\Trade; +use App\Models\Trade\TradeListing; use App\Models\User\User; use App\Models\User\UserItem; +use App\Services\TradeListingManager; use App\Services\TradeManager; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -31,10 +35,9 @@ class TradeController extends Controller { * @return \Illuminate\Contracts\Support\Renderable */ public function getIndex($status = 'open') { - $user = Auth::user(); $trades = Trade::with('recipient')->with('sender')->with('staff')->where(function ($query) { $query->where('recipient_id', Auth::user()->id)->orWhere('sender_id', Auth::user()->id); - })->where('status', ucfirst($status))->orderBy('id', 'DESC'); + })->where('status', $status == 'proposals' ? 'Proposal' : ucfirst($status))->orderBy('id', 'DESC'); return view('home.trades.index', [ 'trades' => $trades->paginate(20), @@ -80,10 +83,18 @@ public function getCreateTrade() { return $userItem->isTransferrable == true; }) ->sortBy('item.name'); + $item_filter = Item::orderBy('name')->get()->mapWithKeys(function ($item) { + return [ + $item->id => json_encode([ + 'name' => $item->name, + 'image_url' => $item->image_url, + ]), + ]; + }); return view('home.trades.create_trade', [ 'categories' => ItemCategory::visible(Auth::user() ?? null)->orderBy('sort', 'DESC')->get(), - 'item_filter' => Item::orderBy('name')->get()->keyBy('id'), + 'item_filter' => $item_filter, 'inventory' => $inventory, 'userOptions' => User::visible()->where('id', '!=', Auth::user()->id)->orderBy('name')->pluck('name', 'id')->toArray(), 'characters' => Auth::user()->allCharacters()->visible()->tradable()->with('designUpdate')->get(), @@ -102,7 +113,7 @@ public function getCreateTrade() { public function getEditTrade($id) { $trade = Trade::where('id', $id)->where(function ($query) { $query->where('recipient_id', Auth::user()->id)->orWhere('sender_id', Auth::user()->id); - })->where('status', 'Open')->first(); + })->whereIn('status', ['Open', 'Proposal'])->first(); if ($trade) { $inventory = UserItem::with('item')->whereNull('deleted_at')->where('count', '>', '0')->where('user_id', Auth::user()->id) @@ -115,11 +126,20 @@ public function getEditTrade($id) { $trade = null; } + $item_filter = Item::orderBy('name')->get()->mapWithKeys(function ($item) { + return [ + $item->id => json_encode([ + 'name' => $item->name, + 'image_url' => $item->image_url, + ]), + ]; + }); + return view('home.trades.edit_trade', [ 'trade' => $trade, 'partner' => (Auth::user()->id == $trade->sender_id) ? $trade->recipient : $trade->sender, 'categories' => ItemCategory::visible(Auth::user() ?? null)->orderBy('sort', 'DESC')->get(), - 'item_filter' => Item::orderBy('name')->get()->keyBy('id'), + 'item_filter' => $item_filter, 'inventory' => $inventory, 'userOptions' => User::visible()->orderBy('name')->pluck('name', 'id')->toArray(), 'characters' => Auth::user()->allCharacters()->visible()->with('designUpdate')->get(), @@ -128,6 +148,95 @@ public function getEditTrade($id) { ]); } + /** + * Gets the propose trade page. + * + * @param mixed|null $id + * + * @return \Illuminate\Contracts\Support\Renderable + */ + public function getCreateEditTradeProposal(Request $request, $id = null) { + $trade = Trade::where('id', $id)->where('status', 'Proposal')->first(); + $recipient = $trade ? (Auth::user()->id == $trade->recipient->id ? $trade->sender : $trade->recipient) + : User::find($request->input('recipient_id')); + $tradeListing = $request->input('trade_listing_id') ? TradeListing::find($request->input('trade_listing_id')) : null; + if ($recipient) { + $recipientInventory = UserItem::with('item')->whereNull('deleted_at')->where('count', '>', '0')->where('user_id', $recipient->id) + ->get() + ->filter(function ($userItem) { + return $userItem->isTransferrable == true; + }) + ->sortBy('item.name'); + if ($tradeListing) { + $recipientSelectedItems = $tradeListing->data['offering']['user_items'] ?? []; + $recipientSelectedCharacters = array_keys($tradeListing->data['offering']['characters'] ?? []); + } + } + + $inventory = UserItem::with('item')->whereNull('deleted_at')->where('count', '>', '0')->where('user_id', Auth::user()->id) + ->get() + ->filter(function ($userItem) { + return $userItem->isTransferrable == true; + }) + ->sortBy('item.name'); + $item_filter = Item::orderBy('name')->get()->mapWithKeys(function ($item) { + return [ + $item->id => json_encode([ + 'name' => $item->name, + 'image_url' => $item->image_url, + ]), + ]; + }); + + return view('home.trades.create_edit_trade_proposal', [ + 'trade' => $id ? Trade::where('id', $id)->where('status', 'Proposal')->first() : null, + 'recipient' => $recipient ?? null, + 'recipientInventory' => $recipientInventory ?? null, + 'recipientSelectedItems' => $recipientSelectedItems ?? [], + 'recipientSelectedCharacters' => $recipientSelectedCharacters ?? [], + 'categories' => ItemCategory::visible(Auth::user() ?? null)->orderBy('sort', 'DESC')->get(), + 'item_filter' => $item_filter, + 'inventory' => $inventory, + 'userOptions' => User::visible()->where('id', '!=', Auth::user()->id)->orderBy('name')->pluck('name', 'id')->toArray(), + 'characterCategories' => CharacterCategory::visible(Auth::user() ?? null)->orderBy('sort', 'DESC')->get(), + 'page' => 'proposal', + ]); + } + + /** + * Returns the mini view for the trade proposal for the recipient. + * + * @param int $id + * + * @return \Illuminate\Contracts\Support\Renderable + */ + public function getUserTradeProposal($id) { + $user = User::findOrFail($id); + $inventory = UserItem::with('item')->whereNull('deleted_at')->where('count', '>', '0')->where('user_id', $user->id)->get()->filter(function ($userItem) { + return $userItem->isTransferrable == true; + })->sortBy('item.name'); + + $item_filter = Item::orderBy('name')->get()->mapWithKeys(function ($item) { + return [ + $item->id => json_encode([ + 'name' => $item->name, + 'image_url' => $item->image_url, + ]), + ]; + }); + + return view('home.trades._proposal_offer', [ + 'user' => $user, + 'inventory' => $inventory, + 'item_filter' => $item_filter, + 'categories' => ItemCategory::visible(Auth::user() ?? null)->orderBy('sort', 'DESC')->get(), + 'page' => 'proposal', + 'characters' => $user->allCharacters()->visible()->tradable()->with('designUpdate')->get(), + 'characterCategories' => CharacterCategory::visible(Auth::user() ?? null)->orderBy('sort', 'DESC')->get(), + 'fieldPrefix' => 'recipient_', + ]); + } + /** * Creates a new trade. * @@ -136,7 +245,9 @@ public function getEditTrade($id) { * @return \Illuminate\Http\RedirectResponse */ public function postCreateTrade(Request $request, TradeManager $service) { - if ($trade = $service->createTrade($request->only(['recipient_id', 'comments', 'stack_id', 'stack_quantity', 'currency_id', 'currency_quantity', 'character_id']), Auth::user())) { + if ($trade = $service->createTrade($request->only([ + 'recipient_id', 'comments', 'stack_id', 'stack_quantity', 'currency_id', 'currency_quantity', 'character_id', 'terms_link', + ]), Auth::user())) { flash('Trade created successfully.')->success(); return redirect()->to($trade->url); @@ -158,7 +269,9 @@ public function postCreateTrade(Request $request, TradeManager $service) { * @return \Illuminate\Http\RedirectResponse */ public function postEditTrade(Request $request, TradeManager $service, $id) { - if ($trade = $service->editTrade($request->only(['comments', 'stack_id', 'stack_quantity', 'currency_id', 'currency_quantity', 'character_id']) + ['id' => $id], Auth::user())) { + if ($trade = $service->editTrade($request->only([ + 'comments', 'stack_id', 'stack_quantity', 'currency_id', 'currency_quantity', 'character_id', + ]) + ['id' => $id], Auth::user())) { flash('Trade offer edited successfully.')->success(); } else { foreach ($service->errors()->getMessages()['error'] as $error) { @@ -169,6 +282,58 @@ public function postEditTrade(Request $request, TradeManager $service, $id) { return redirect()->back(); } + /** + * Proposes a trade. + * + * @param App\Services\TradeManager $service + * @param mixed|null $id + * + * @return \Illuminate\Http\RedirectResponse + */ + public function postCreateEditTradeProposal(Request $request, TradeManager $service, $id = null) { + $existingTrade = $id ? Trade::where('id', $id)->where(function ($query) { + $query->where('recipient_id', Auth::user()->id)->orWhere('sender_id', Auth::user()->id); + })->where('status', 'Proposal')->first() : null; + if ($trade = $service->proposeTrade($request->only([ + 'recipient_id', 'comments', 'stack_id', 'stack_quantity', 'currency_id', 'currency_quantity', 'character_id', + 'recipient_stack_id', 'recipient_stack_quantity', 'recipient_character_id', + ]), Auth::user(), $existingTrade)) { + flash('Trade '.($existingTrade ? 'proposal edited' : 'proposed').' successfully.')->success(); + + return redirect()->to($trade->url); + } else { + foreach ($service->errors()->getMessages()['error'] as $error) { + flash($error)->error(); + } + } + + return redirect()->back(); + } + + /** + * Rejects or accepts a trade proposal. + * + * @param App\Services\TradeManager $service + * @param mixed $id + * @param mixed $action + * + * @return \Illuminate\Http\RedirectResponse + */ + public function postRespondToTradeProposal(Request $request, TradeManager $service, $id, $action) { + $trade = Trade::where('id', $id)->where(function ($query) { + $query->where('recipient_id', Auth::user()->id)->orWhere('sender_id', Auth::user()->id); + })->where('status', 'Proposal')->first(); + if (!$service->respondToTradeProposal($trade, Auth::user(), $action)) { + foreach ($service->errors()->getMessages()['error'] as $error) { + flash($error)->error(); + } + } else { + flash('Trade proposal '.$action.'ed successfully.')->success(); + } + + return redirect()->to($trade->url); + } + /** * Shows the offer confirmation modal. * @@ -285,4 +450,183 @@ public function postCancelTrade(Request $request, TradeManager $service, $id) { return redirect()->back(); } + + /********************************************************************************************** + + TRADE LISTINGS + + **********************************************************************************************/ + + /** + * Shows the trade listing index. + * + * @return \Illuminate\Contracts\Support\Renderable + */ + public function getListingIndex(Request $request) { + return view('home.trades.listings.index', [ + 'listings' => TradeListing::active()->orderBy('id', 'DESC')->paginate(10), + 'listingDuration' => Settings::get('trade_listing_duration'), + ]); + } + + /** + * Shows the user's expired trade listings. + * + * @return \Illuminate\Contracts\Support\Renderable + */ + public function getExpiredListings(Request $request) { + return view('home.trades.listings.listings', [ + 'listings' => TradeListing::where('user_id', Auth::user()->id)->orderBy('id', 'DESC')->paginate(10), + 'listingDuration' => Settings::get('trade_listing_duration'), + ]); + } + + /** + * Shows a trade. + * + * @param int $id + * + * @return \Illuminate\Contracts\Support\Renderable + */ + public function getListing($id) { + $listing = TradeListing::find($id); + if (!$listing) { + abort(404); + } + + return view('home.trades.listings.view_listing', [ + 'listing' => $listing, + 'seekingData' => isset($listing->data['seeking']) ? parseAssetData($listing->data['seeking']) : null, + 'offeringData' => isset($listing->data['offering']) ? parseAssetData($listing->data['offering']) : null, + 'items' => Item::all()->keyBy('id'), + ]); + } + + /** + * Shows the create trade listing page. + * + * @return \Illuminate\Contracts\Support\Renderable + */ + public function getCreateListing(Request $request) { + $inventory = UserItem::with('item')->whereNull('deleted_at')->where('count', '>', '0')->where('user_id', Auth::user()->id) + ->get() + ->filter(function ($userItem) { + return $userItem->isTransferrable == true; + }) + ->sortBy('item.name'); + $currencies = Currency::where('is_user_owned', 1)->where('allow_user_to_user', 1)->orderBy('sort_user', 'DESC')->get()->pluck('name', 'id'); + $item_filter = Item::orderBy('name')->get()->mapWithKeys(function ($item) { + return [ + $item->id => json_encode([ + 'name' => $item->name, + 'image_url' => $item->image_url, + ]), + ]; + }); + + return view('home.trades.listings.create_edit_listing', [ + 'listing' => new TradeListing, + 'currencies' => $currencies, + 'categories' => ItemCategory::orderBy('sort', 'DESC')->get(), + 'item_filter' => $item_filter, + 'inventory' => $inventory, + 'characters' => Auth::user()->allCharacters()->visible()->tradable()->with('designUpdate')->get(), + 'characterCategories' => CharacterCategory::orderBy('sort', 'DESC')->get(), + 'page' => 'listing', + 'listingDuration' => Settings::get('trade_listing_duration'), + ]); + } + + /** + * Shows the edit trade listing page. + * + * @param int $id + * + * @return \Illuminate\Contracts\Support\Renderable + */ + public function getEditListing($id) { + $listing = TradeListing::find($id); + if (!$listing) { + abort(404); + } + + $inventory = UserItem::with('item')->whereNull('deleted_at')->where('count', '>', '0')->where('user_id', Auth::user()->id) + ->get() + ->filter(function ($userItem) { + return $userItem->isTransferrable == true; + }) + ->sortBy('item.name'); + $currencies = Currency::where('is_user_owned', 1)->where('allow_user_to_user', 1)->orderBy('sort_user', 'DESC')->get()->pluck('name', 'id'); + $item_filter = Item::orderBy('name')->get()->mapWithKeys(function ($item) { + return [ + $item->id => json_encode([ + 'name' => $item->name, + 'image_url' => $item->image_url, + ]), + ]; + }); + + return view('home.trades.listings.create_edit_listing', [ + 'listing' => $listing, + 'currencies' => $currencies, + 'categories' => ItemCategory::orderBy('sort', 'DESC')->get(), + 'item_filter' => $item_filter, + 'inventory' => $inventory, + 'characters' => Auth::user()->allCharacters()->visible()->tradable()->with('designUpdate')->get(), + 'characterCategories' => CharacterCategory::orderBy('sort', 'DESC')->get(), + 'page' => 'listing', + 'listingDuration' => Settings::get('trade_listing_duration'), + ]); + } + + /** + * Creates a new trade listing. + * + * @param App\Services\TradeListingManager $service + * @param mixed|null $id + * + * @return \Illuminate\Http\RedirectResponse + */ + public function postCreateEditListing(Request $request, TradeListingManager $service, $id = null) { + if (!$listing = $service->createEditTradeListing($request->only([ + 'title', 'comments', 'contact', 'item_ids', 'offering_etc', 'seeking_etc', + 'rewardable_type', 'rewardable_id', 'quantity', + 'offer_currency_ids', 'character_id', 'stack_id', 'stack_quantity', + ]), Auth::user(), $id)) { + foreach ($service->errors()->getMessages()['error'] as $error) { + flash($error)->error(); + } + + return redirect()->back(); + } + + flash('Trade listing '.($id ? 'edited' : 'created').' successfully.')->success(); + + return redirect()->to($listing->url); + } + + /** + * Manually marks a trade listing as expired. + * + * @param App\Services\TradeListingManager $service + * @param int $id + * + * @return \Illuminate\Http\RedirectResponse + */ + public function postExpireListing(Request $request, TradeListingManager $service, $id) { + $listing = TradeListing::find($id); + if (!$listing) { + abort(404); + } + + if ($service->markExpired(['id' => $id], Auth::user())) { + flash('Listing expired successfully.')->success(); + } else { + foreach ($service->errors()->getMessages()['error'] as $error) { + flash($error)->error(); + } + } + + return redirect()->back(); + } } diff --git a/app/Models/Character/Character.php b/app/Models/Character/Character.php index b0f3301989..605a157e81 100644 --- a/app/Models/Character/Character.php +++ b/app/Models/Character/Character.php @@ -12,7 +12,7 @@ use App\Models\Rarity; use App\Models\Submission\Submission; use App\Models\Submission\SubmissionCharacter; -use App\Models\Trade; +use App\Models\Trade\Trade; use App\Models\User\User; use App\Models\User\UserCharacterLog; use Carbon\Carbon; diff --git a/app/Models/Notification.php b/app/Models/Notification.php index ff439e6ccb..453fd48c91 100644 --- a/app/Models/Notification.php +++ b/app/Models/Notification.php @@ -64,10 +64,10 @@ public function user() { public function getMessageAttribute() { $notification = config('lorekeeper.notifications.'.$this->notification_type_id); - $message = $notification['message']; + $message = $notification['message'] ?? 'Unknown Notification'; // Replace the URL... - $message = str_replace('{url}', url($notification['url']), $message); + $message = isset($notification['url']) ? str_replace('{url}', url($notification['url']), $message) : $message; // Replace any variables in data... $data = $this->data; @@ -144,6 +144,10 @@ public static function getNotificationId($type) { public const REPORT_CLOSED = 221; public const COMMENT_MADE = 239; public const COMMENT_REPLY = 240; + public const TRADE_PROPOSAL_RECEIVED = 280; + public const TRADE_PROPOSAL_UPDATED = 281; + public const TRADE_PROPOSAL_ACCEPTED = 282; + public const TRADE_PROPOSAL_REJECTED = 283; public const CHARACTER_ITEM_GRANT = 501; public const CHARACTER_ITEM_REMOVAL = 502; public const GALLERY_SUBMISSION_COLLABORATOR = 505; diff --git a/app/Models/Trade.php b/app/Models/Trade/Trade.php similarity index 83% rename from app/Models/Trade.php rename to app/Models/Trade/Trade.php index b83d01ff3a..515f547313 100644 --- a/app/Models/Trade.php +++ b/app/Models/Trade/Trade.php @@ -1,9 +1,10 @@ 'required|in:Open,Pending,Completed,Rejected,Canceled,Proposal', + 'terms_link' => 'nullable|url', + ]; + + /** + * Validation rules for character updating. + * + * @var array + */ + public static $updateRules = [ + 'status' => 'required|in:Open,Pending,Completed,Rejected,Canceled,Proposal', + 'terms_link' => 'nullable|url', + ]; + /********************************************************************************************** RELATIONS @@ -75,10 +96,33 @@ public function staff() { **********************************************************************************************/ + /** + * Scope a query to only include finalised trades. + * + * @param mixed $query + */ public function scopeCompleted($query) { return $query->where('status', 'Completed')->orWhere('status', 'Rejected'); } + /** + * Scope a query to order by the newest trades. + * + * @param mixed $query + */ + public function scopeSortNewest($query) { + return $query->orderBy('id', 'DESC'); + } + + /** + * Scope a query to order by the oldest trades. + * + * @param mixed $query + */ + public function scopeSortOldest($query) { + return $query->orderBy('id', 'ASC'); + } + /********************************************************************************************** ACCESSORS diff --git a/app/Models/Trade/TradeListing.php b/app/Models/Trade/TradeListing.php new file mode 100644 index 0000000000..3240801b45 --- /dev/null +++ b/app/Models/Trade/TradeListing.php @@ -0,0 +1,230 @@ + 'array', + 'expires_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + /** + * Validation rules for character creation. + * + * @var array + */ + public static $createRules = [ + 'title' => 'nullable|between:3,50', + 'comments' => 'nullable', + 'contact' => 'required', + 'seeking_etc' => 'nullable|between:3,100', + 'offering_etc' => 'nullable|between:3,100', + ]; + + /** + * Validation rules for character updating. + * + * @var array + */ + public static $updateRules = [ + 'title' => 'nullable|between:3,50', + 'comments' => 'nullable', + 'contact' => 'required', + 'seeking_etc' => 'nullable|between:3,100', + 'offering_etc' => 'nullable|between:3,100', + ]; + + /********************************************************************************************** + + RELATIONS + + **********************************************************************************************/ + + /** + * Get the user who posted the trade listing. + */ + public function user() { + return $this->belongsTo(User::class, 'user_id'); + } + + /********************************************************************************************** + + SCOPES + + **********************************************************************************************/ + + /** + * Scope a query to only include active trade listings. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeActive($query) { + return $query->where(function ($query) { + $query->where('expires_at', '>', Carbon::now())->orWhere(function ($query) { + $query->where('expires_at', '>=', Carbon::now()); + }); + }); + } + + /** + * Scope a query to only include active trade listings. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeExpired($query) { + return $query->where(function ($query) { + $query->where('expires_at', '<', Carbon::now())->orWhere(function ($query) { + $query->where('expires_at', '<=', Carbon::now()); + }); + }); + } + + /********************************************************************************************** + + ACCESSORS + + **********************************************************************************************/ + + /** + * Gets the Display Name of the trade listing. + * + * @return string + */ + public function getDisplayNameAttribute() { + if ($this->title == null) { + return $this->user->displayName.'\'s Trade Listing (#'.$this->id.')'; + } else { + return ''.$this->title.' (Trade Listing #'.$this->id.')'; + } + } + + /** + * Gets the Display Name of the trade listing with the title portion somewhat shorter. + * + * @return string + */ + public function getDisplayNameShortAttribute() { + if ($this->title == null) { + return $this->user->displayName.'\'s Trade Listing (#'.$this->id.')'; + } else { + return ''.$this->title.''; + } + } + + /** + * Gets the name for the trade listing for use in forms. + * + * @return string + */ + public function getFormNameAttribute() { + return $this->title.' (Trade Listing #'.$this->id.')'; + } + + /** + * Check if the trade listing is active. + * + * @return bool + */ + public function getIsActiveAttribute() { + if ($this->expires_at >= Carbon::now()) { + return true; + } + + return false; + } + + /** + * Gets the URL of the trade listing. + * + * @return string + */ + public function getUrlAttribute() { + return url('trades/listings/'.$this->id); + } + + /** + * Returns the seeking data for use in loot rows. + */ + public function getSeekingDataAttribute() { + if (!isset($this->data['seeking'])) { + return []; + } + + $assets = parseAssetData($this->data['seeking']); + $rewards = []; + foreach ($assets as $type => $a) { + $class = getAssetModelString($type, false); + foreach ($a as $id => $asset) { + $rewards[] = (object) [ + 'rewardable_type' => $class, + 'rewardable_id' => $id, + 'quantity' => $asset['quantity'], + ]; + } + } + + return $rewards; + } + + /** + * Gets the selected inventory for the trade listing. + * + * @return array + */ + public function getInventoryAttribute() { + return $this->data && isset($this->data['offering']['user_items']) ? $this->data['offering']['user_items'] : []; + } + + /** + * Gets the currencies of the given user for selection. + * + * @return array + */ + public function getCurrenciesAttribute() { + return $this->data && isset($this->data['offering']['currencies']) ? array_keys($this->data['offering']['currencies']) : []; + } + + /** + * Gets the characters from the offering (you cannot seek characters directly). + * + * @return array + */ + public function getCharactersAttribute() { + return $this->data && isset($this->data['offering']) && isset($this->data['offering']['characters']) ? array_keys($this->data['offering']['characters']) : []; + } +} diff --git a/app/Models/User/User.php b/app/Models/User/User.php index e89c18cd67..02ef860115 100644 --- a/app/Models/User/User.php +++ b/app/Models/User/User.php @@ -398,7 +398,7 @@ public function getDisplayAliasAttribute() { return '(Unverified)'; } - return $this->primaryAlias->displayAlias; + return $this->primaryAlias?->displayAlias ?? '(Unverified)'; } /** diff --git a/app/Services/CharacterManager.php b/app/Services/CharacterManager.php index a1b1340075..8d7c344d27 100644 --- a/app/Services/CharacterManager.php +++ b/app/Services/CharacterManager.php @@ -1634,6 +1634,14 @@ public function processTransfer($data, $user) { 'sender_name' => $transfer->recipient->name, 'sender_url' => $transfer->recipient->url, ]); + + // Notify recipient of the successful transfer + Notifications::create('CHARACTER_TRANSFER_ACCEPTED', $transfer->recipient, [ + 'character_name' => $transfer->character->slug, + 'character_url' => $transfer->character->url, + 'sender_name' => $transfer->sender->name, + 'sender_url' => $transfer->sender->url, + ]); } } else { $transfer->status = 'Rejected'; diff --git a/app/Services/TradeListingManager.php b/app/Services/TradeListingManager.php new file mode 100644 index 0000000000..c90b6246c0 --- /dev/null +++ b/app/Services/TradeListingManager.php @@ -0,0 +1,298 @@ +isActive) { + throw new \Exception('This listing is already expired.'); + } + if (!$listing->user->id == Auth::user()->id && !Auth::user()->hasPower('manage_submissions')) { + throw new \Exception("You can't edit this listing."); + } + $listing->update([ + 'title' => $data['title'] ?? null, + 'comments' => $data['comments'] ?? null, + 'contact' => $data['contact'], + ]); + } else { + if (TradeListing::where('user_id', $user->id)->where('expires_at', '>', Carbon::now())->count() > Settings::get('trade_listing_limit')) { + throw new \Exception('You already have the maximum number of active trade listings. Please wait for them to expire before creating a new one.'); + } + $listing = TradeListing::create([ + 'title' => $data['title'] ?? null, + 'user_id' => $user->id, + 'comments' => $data['comments'] ?? null, + 'contact' => $data['contact'], + ]); + } + + $listingData = []; + if (!$seekingData = $this->handleSeekingAssets($listing, $data, $user)) { + throw new \Exception('Error attaching sought attachments.'); + } else { + $listingData['seeking'] = getDataReadyAssets($seekingData); + } + + if (!$offeringData = $this->handleOfferingAssets($listing, $data, $user)) { + throw new \Exception('Error attaching offered attachments.'); + } else { + $listingData['offering'] = getDataReadyAssets($offeringData); + } + + if ($data['offering_etc'] || $data['seeking_etc']) { + $listingData['offering_etc'] = $data['offering_etc'] ?? null; + $listingData['seeking_etc'] = $data['seeking_etc'] ?? null; + } + + $listing->expires_at = $id ? $listing->expires_at : Carbon::now()->addDays(Settings::get('trade_listing_duration')); + $listing->created_at = $id ? $listing->created_at : Carbon::now(); + $listing->updated_at = Carbon::now(); + $listing->data = $listingData; + $listing->save(); + + if (!$listing->data) { + throw new \Exception("Please enter what you're seeking and offering."); + } + if (!isset($listing->data['seeking']) && !isset($listing->data['seeking_etc'])) { + throw new \Exception("Please enter what you're seeking."); + } + if (!isset($listing->data['offering']) && !isset($listing->data['offering_etc'])) { + throw new \Exception("Please enter what you're offering."); + } + + return $this->commitReturn($listing); + } catch (\Exception $e) { + $this->setError('error', $e->getMessage()); + } + + return $this->rollbackReturn(false); + } + + /** + * Marks a trade listing as expired. + * + * @param array $data + * @param User $user + * + * @return bool|TradeListing + */ + public function markExpired($data, $user) { + DB::beginTransaction(); + try { + $listing = TradeListing::find($data['id']); + if (!$listing) { + throw new \Exception('Invalid trade listing.'); + } + if (!$listing->isActive) { + throw new \Exception('This listing is already expired.'); + } + if (!$listing->user->id == Auth::user()->id && !Auth::user()->hasPower('manage_submissions')) { + throw new \Exception("You can't edit this listing."); + } + + $listing->expires_at = Carbon::now(); + $listing->save(); + + return $this->commitReturn($listing); + } catch (\Exception $e) { + $this->setError('error', $e->getMessage()); + } + + return $this->rollbackReturn(false); + } + + /** + * Handles recording of assets on the seeking side of a trade listing, as well as initial validation. + * + * @param TradeListing $listing + * @param array $data + * @param mixed $user + * + * @return array|bool + */ + private function handleSeekingAssets($listing, $data, $user) { + DB::beginTransaction(); + try { + $seekingAssets = createAssetsArray(); + $assetCount = 0; + $assetLimit = config('lorekeeper.settings.trade_asset_limit'); + + if (isset($data['rewardable_type'])) { + foreach ($data['rewardable_type'] as $key=>$type) { + $model = getAssetModelString(strtolower($type)); + $asset = $model::find($data['rewardable_id'][$key]); + if (!$asset) { + throw new \Exception("Invalid {$type} selected."); + } + + if (!canTradeAsset($type, $asset)) { + throw new \Exception("One or more of the selected {$type}s cannot be traded."); + } + + if ($type == 'Item') { + if (!$asset->allow_transfer) { + throw new \Exception('One or more of the selected items cannot be transferred.'); + } + } elseif ($type == 'Currency') { + if (!$asset->is_user_owned) { + throw new \Exception('One or more of the selected currencies cannot be held by users.'); + } + if (!$asset->allow_user_to_user) { + throw new \Exception('One or more of the selected currencies cannot be traded.'); + } + } + + if ($data['quantity'][$key] < 1) { + throw new \Exception('You must select a quantity of at least 1 for each asset.'); + } + + addAsset($seekingAssets, $asset, $data['quantity'][$key]); + $assetCount += 1; + } + } + if ($assetCount > $assetLimit) { + throw new \Exception("You may only include a maximum of {$assetLimit} things in a listing."); + } + + return $this->commitReturn($seekingAssets); + } catch (\Exception $e) { + $this->setError('error', $e->getMessage()); + } + + return $this->rollbackReturn(false); + } + + /** + * Handles recording of assets on the user's side of a trade listing, as well as initial validation. + * + * @param TradeListing $listing + * @param array $data + * @param User $user + * + * @return array|bool + */ + private function handleOfferingAssets($listing, $data, $user) { + DB::beginTransaction(); + try { + $userAssets = createAssetsArray(); + $assetCount = 0; + $assetLimit = config('lorekeeper.settings.trade_asset_limit'); + + // Attach items. They are not even held, merely recorded for display on the listing. + if (isset($data['stack_id'])) { + foreach ($data['stack_id'] as $key=>$stackId) { + $stack = UserItem::with('item')->find($stackId); + if (!$stack || $stack->user_id != $user->id) { + throw new \Exception('Invalid item selected.'); + } + if (!$stack->item->allow_transfer || isset($stack->data['disallow_transfer'])) { + throw new \Exception('One or more of the selected items cannot be transferred.'); + } + + if ($data['stack_quantity'][$stackId] < 1) { + throw new \Exception('You must select a quantity of at least 1 for each item.'); + } + + addAsset($userAssets, $stack, $data['stack_quantity'][$stackId]); + $assetCount++; + } + } + if ($assetCount > $assetLimit) { + throw new \Exception("You may only include a maximum of {$assetLimit} things in a listing."); + } + + // Attach currencies. Character currencies cannot be attached to trades, so we're just checking the user's bank. + if (isset($data['offer_currency_ids'])) { + foreach ($data['offer_currency_ids'] as $key=>$currencyId) { + $currency = Currency::where('allow_user_to_user', 1)->where('id', $currencyId)->first(); + if (!$currency) { + throw new \Exception('Invalid currency selected.'); + } + + addAsset($userAssets, $currency, 1); + $assetCount++; + } + } + if ($assetCount > $assetLimit) { + throw new \Exception("You may only include a maximum of {$assetLimit} things in a listing."); + } + + // Attach characters. + if (isset($data['character_id'])) { + foreach ($data['character_id'] as $characterId) { + $character = Character::where('id', $characterId)->where('user_id', $user->id)->first(); + if (!$character) { + throw new \Exception('Invalid character selected.'); + } + if (!$character->is_sellable && !$character->is_tradeable && !$character->is_giftable) { + throw new \Exception('One or more of the selected characters cannot be transferred.'); + } + if (CharacterTransfer::active()->where('character_id', $character->id)->exists()) { + throw new \Exception('One or more of the selected characters is already pending a character transfer.'); + } + if ($character->trade_id) { + throw new \Exception('One or more of the selected characters is already in a trade.'); + } + if ($character->designUpdate()->active()->exists()) { + throw new \Exception('One or more of the selected characters has an active design update. Please wait for it to be processed, or delete it.'); + } + if ($character->transferrable_at && $character->transferrable_at->isFuture()) { + throw new \Exception('One or more of the selected characters is still on transfer cooldown and cannot be transferred.'); + } + + addAsset($userAssets, $character, 1); + $assetCount++; + } + } + if ($assetCount > $assetLimit) { + throw new \Exception("You may only include a maximum of {$assetLimit} things in a listing."); + } + + return $this->commitReturn($userAssets); + } catch (\Exception $e) { + $this->setError('error', $e->getMessage()); + } + + return $this->rollbackReturn(false); + } +} diff --git a/app/Services/TradeManager.php b/app/Services/TradeManager.php index 312d6c5db8..2f37f04cb1 100644 --- a/app/Services/TradeManager.php +++ b/app/Services/TradeManager.php @@ -7,7 +7,7 @@ use App\Models\Character\Character; use App\Models\Character\CharacterTransfer; use App\Models\Currency\Currency; -use App\Models\Trade; +use App\Models\Trade\Trade; use App\Models\User\User; use App\Models\User\UserItem; use Illuminate\Support\Facades\DB; @@ -49,6 +49,7 @@ public function createTrade($data, $user) { 'sender_id' => $user->id, 'recipient_id' => $data['recipient_id'], 'status' => 'Open', + 'terms_link' => $data['terms_link'], 'comments' => $data['comments'] ?? null, 'is_sender_confirmed' => 0, 'is_recipient_confirmed' => 0, @@ -119,6 +120,135 @@ public function editTrade($data, $user) { return $this->rollbackReturn(false); } + /** + * Proposes a trade. + * + * @param array $data + * @param User $user + * @param mixed|null $trade + * + * @return bool|Trade + */ + public function proposeTrade($data, $user, $trade = null) { + DB::beginTransaction(); + + try { + $created = true; + if ($trade) { + $created = false; + $recipient = $trade->recipient; + if ($trade->status != 'Proposal') { + throw new \Exception('Invalid trade.'); + } + if ($trade->recipient_id != $user->id && $trade->sender_id != $user->id) { + throw new \Exception('You are not a participant in this trade.'); + } + + // update the confirmation status + $trade->is_sender_confirmed = $user->id == $trade->sender_id ? 1 : 0; + $trade->is_recipient_confirmed = $user->id == $trade->recipient_id ? 1 : 0; + } else { + $recipient = User::find($data['recipient_id']); + if (!$recipient) { + throw new \Exception('Invalid recipient.'); + } + if ($recipient->is_banned) { + throw new \Exception('The recipient is a banned user and cannot receive a trade.'); + } + if ($user->id == $recipient->id) { + throw new \Exception('Cannot start a trade with yourself.'); + } + + $trade = Trade::create([ + 'sender_id' => $user->id, + 'recipient_id' => $recipient->id, + 'status' => 'Proposal', + 'terms_link' => 'Proposal from '.$user->name, + 'comments' => $data['comments'] ?? null, + 'is_sender_confirmed' => 1, + 'is_recipient_confirmed' => 0, + 'data' => null, + ]); + } + + if (!$assetData = $this->handleTradeProposalAssets($data, $trade, $user, $recipient)) { + throw new \Exception('Failed to handle trade assets.'); + } + + $trade->data = [ + 'sender' => getDataReadyAssets($assetData['sender']), + 'recipient' => getDataReadyAssets($assetData['recipient']), + ]; + $trade->save(); + + // send a notification + Notifications::create($created ? 'TRADE_PROPOSAL_RECEIVED' : 'TRADE_PROPOSAL_UPDATED', $user->id == $trade->sender_id ? $trade->recipient : $trade->sender, [ + 'sender_url' => $user->url, + 'sender_name' => $user->name, + 'trade_id' => $trade->id, + ]); + + return $this->commitReturn($trade); + } catch (\Exception $e) { + $this->setError('error', $e->getMessage()); + } + + return $this->rollbackReturn(false); + } + + /** + * Responds to a trade proposal. + * + * @param Trade $trade + * @param User $user + * @param string $action + * + * @return bool|Trade + */ + public function respondToTradeProposal($trade, $user, $action) { + DB::beginTransaction(); + + try { + // if it's an accept, simply confirm both sides of the trade and set the status to Open + if ($action == 'accept') { + // we also have to attach the assets to the trade + if (!$tradeData = $this->handleAcceptedTradeAssets($trade, $trade->data['sender'], $trade->data['recipient'])) { + throw new \Exception('Failed to handle sender assets.'); + } + + $trade->update([ + 'status' => 'Open', + 'is_sender_confirmed' => 1, + 'is_recipient_confirmed' => 1, + 'data' => [ + 'sender' => getDataReadyAssets($tradeData['sender']), + 'recipient' => getDataReadyAssets($tradeData['recipient']), + ], + ]); + } else { + // if it's a reject, return the assets to their owners + if (!$this->returnAttachments($trade)) { + throw new \Exception('Failed to return trade attachments.'); + } + $trade->status = 'Canceled'; + $trade->save(); + } + + // send a notification + Notifications::create($action == 'accept' ? 'TRADE_PROPOSAL_ACCEPTED' : 'TRADE_PROPOSAL_REJECTED', $trade->sender_id == $user->id ? $trade->recipient : $trade->sender, [ + 'sender_name' => $user->url, + 'sender_url' => $user->name, + 'trade_id' => $trade->id, + ]); + + return $this->commitReturn(true); + } catch (\Exception $e) { + $this->setError('error', $e->getMessage()); + } + + return $this->rollbackReturn(false); + } + /** * Cancels a trade. * @@ -265,7 +395,7 @@ public function confirmTrade($data, $user) { } if ($trade->is_sender_trade_confirmed && $trade->is_recipient_trade_confirmed) { - if (!(Settings::get('open_transfers_queue') && (isset($trade->data['sender']['characters']) || isset($trade->data['recipient']['characters'])))) { + if (!Settings::get('open_trades_queue') || !(Settings::get('open_transfers_queue') && (isset($trade->data['sender']['characters']) || isset($trade->data['recipient']['characters'])))) { // Distribute the trade attachments $this->creditAttachments($trade); @@ -407,16 +537,49 @@ public function rejectTrade($data, $user) { return $this->rollbackReturn(false); } + /** + * Handles the accepted assets of a trade proposal. + * This function increments the trade count of items and characters, and debits currencies. + * + * @param Trade $trade + * @param array $senderData + * @param array $recipientData + * + * @return bool + */ + public function handleAcceptedTradeAssets($trade, $senderData, $recipientData) { + DB::beginTransaction(); + + try { + if (!$senderAssets = $this->attachAssets($trade, $senderData, $trade->sender)) { + throw new \Exception('Failed to handle sender assets.'); + } + if (!$recipientAssets = $this->attachAssets($trade, $recipientData, $trade->recipient)) { + throw new \Exception('Failed to handle recipient assets.'); + } + + return $this->commitReturn([ + 'sender' => $senderAssets, + 'recipient' => $recipientAssets, + ]); + } catch (\Exception $e) { + $this->setError('error', $e->getMessage()); + } + + return $this->rollbackReturn(false); + } + /** * Handles modification of assets on the user's side of a trade. * * @param Trade $trade * @param array $data * @param User $user + * @param mixed $isProposal * * @return array|bool */ - private function handleTradeAssets($trade, $data, $user) { + private function handleTradeAssets($trade, $data, $user, $isProposal = false) { DB::beginTransaction(); try { $tradeData = $trade->data; @@ -425,35 +588,38 @@ private function handleTradeAssets($trade, $data, $user) { if (!$type) { throw new \Exception('User not found.'); } - // First return any item stacks attached to the trade - if (isset($tradeData[$type]['user_items'])) { - foreach ($tradeData[$type]['user_items'] as $userItemId=> $quantity) { - $quantity = (int) $quantity; - $userItemRow = UserItem::find($userItemId); - if (!$userItemRow) { - throw new \Exception('Cannot return an invalid item. ('.$userItemId.')'); - } - if ($userItemRow->trade_count < $quantity) { - throw new \Exception('Cannot return more items than was held. ('.$userItemId.')'); + // If this is a proposal, no items are actually attached to the trade yet. + if (!$isProposal) { + // First return any item stacks attached to the trade + if (isset($tradeData[$type]['user_items'])) { + foreach ($tradeData[$type]['user_items'] as $userItemId=> $quantity) { + $quantity = (int) $quantity; + $userItemRow = UserItem::find($userItemId); + if (!$userItemRow) { + throw new \Exception('Cannot return an invalid item. ('.$userItemId.')'); + } + if ($userItemRow->trade_count < $quantity) { + throw new \Exception('Cannot return more items than was held. ('.$userItemId.')'); + } + $userItemRow->trade_count -= $quantity; + $userItemRow->save(); } - $userItemRow->trade_count -= $quantity; - $userItemRow->save(); } - } - // Also return any currency attached to the trade - // This is stored in the data attribute - $currencyManager = new CurrencyManager; - if (isset($tradeData[$type]['currencies'])) { - foreach ($tradeData[$type]['currencies'] as $currencyId=> $quantity) { - $quantity = (int) $quantity; - $currencyManager->creditCurrency(null, $user, null, null, $currencyId, $quantity); + // Also return any currency attached to the trade + // This is stored in the data attribute + $currencyManager = new CurrencyManager; + if (isset($tradeData[$type]['currencies'])) { + foreach ($tradeData[$type]['currencies'] as $currencyId=> $quantity) { + $quantity = (int) $quantity; + $currencyManager->creditCurrency(null, $user, null, null, $currencyId, $quantity); + } } - } - // Unattach characters too - Character::where('trade_id', $trade->id)->where('user_id', $user->id)->update(['trade_id' => null]); + // Unattach characters too + Character::where('trade_id', $trade->id)->where('user_id', $user->id)->update(['trade_id' => null]); + } $userAssets = createAssetsArray(); $assetCount = 0; @@ -467,16 +633,25 @@ private function handleTradeAssets($trade, $data, $user) { if (!$stack || $stack->user_id != $user->id) { throw new \Exception('Invalid item selected.'); } + if (!isset($data['stack_quantity'][$stackId])) { throw new \Exception('Invalid quantity selected.'); } + $quantity = intval($data['stack_quantity'][$stackId]); + if ($quantity <= 0 || $quantity > $stack->availableQuantity) { + throw new \Exception('Invalid quantity selected for item stack.'); + } + if (!$stack->item->allow_transfer || isset($stack->data['disallow_transfer'])) { throw new \Exception('One or more of the selected items cannot be transferred.'); } - $stack->trade_count += intval($data['stack_quantity'][$stackId]); - $stack->save(); - addAsset($userAssets, $stack, intval($data['stack_quantity'][$stackId])); + if (!$isProposal) { + $stack->trade_count += $quantity; + $stack->save(); + } + + addAsset($userAssets, $stack, $quantity); $assetCount++; } } @@ -489,16 +664,21 @@ private function handleTradeAssets($trade, $data, $user) { if ($user->id != $trade->sender_id && $user->id != $trade->recipient_id) { throw new \Exception('Error attaching currencies to this trade.'); } - // dd([$data['currency_id'], $data['currency_quantity']]); $data['currency_id'] = $data['currency_id']['user-'.$user->id]; $data['currency_quantity'] = $data['currency_quantity']['user-'.$user->id]; - foreach ($data['currency_id'] as $key=> $currencyId) { + foreach ($data['currency_id'] as $key => $currencyId) { + if (!$currencyId) { + continue; + } $currency = Currency::where('allow_user_to_user', 1)->where('id', $currencyId)->first(); if (!$currency) { throw new \Exception('Invalid currency selected.'); } - if (!$currencyManager->debitCurrency($user, null, null, null, $currency, intval($data['currency_quantity'][$key]))) { - throw new \Exception('Invalid currency/quantity selected.'); + + if (!$isProposal) { + if (!$currencyManager->debitCurrency($user, null, null, null, $currency, intval($data['currency_quantity'][$key]))) { + throw new \Exception('Invalid currency/quantity selected.'); + } } addAsset($userAssets, $currency, intval($data['currency_quantity'][$key])); @@ -532,8 +712,10 @@ private function handleTradeAssets($trade, $data, $user) { throw new \Exception('One or more of the selected characters is still on transfer cooldown and cannot be transferred.'); } - $character->trade_id = $trade->id; - $character->save(); + if (!$isProposal) { + $character->trade_id = $trade->id; + $character->save(); + } addAsset($userAssets, $character, 1); $assetCount++; @@ -707,4 +889,132 @@ private function creditAttachments($trade, $data = []) { return $this->rollbackReturn(false); } + + /** + * Handles the initial creation of a proposed trade's assets, due to the nature of the data keys. + * + * @param array $data + * @param Trade $trade + * @param User $user + * @param User $recipient + * + * @return array|bool + */ + private function handleTradeProposalAssets($data, $trade, $user, $recipient) { + DB::beginTransaction(); + + try { + // Prepare recipient data for use in the handleTradeAssets method + $recipientData = [ + 'stack_id' => $data['recipient_stack_id'] ?? [], + 'stack_quantity' => $data['stack_quantity'], // this is keyed by stack id anyway + 'currency_id' => $data['currency_id'], // this is divided by user id anyway + 'currency_quantity' => $data['currency_quantity'], + 'character_id' => $data['recipient_character_id'] ?? [], + ]; + + if (!$senderAssets = $this->handleTradeAssets($trade, $data, $trade->sender, true)) { + throw new \Exception('Failed to handle sender assets.'); + } + if (!$recipientAssets = $this->handleTradeAssets($trade, $recipientData, $trade->recipient, true)) { + throw new \Exception('Failed to handle recipient assets.'); + } + + return $this->commitReturn([ + 'sender' => $senderAssets['sender'], + 'recipient' => $recipientAssets['recipient'], + ]); + } catch (\Exception $e) { + $this->setError('error', $e->getMessage()); + } + + return $this->rollbackReturn(false); + } + + /** + * Attaches assets from a proposal to a trade. + * + * @param Trade $trade + * @param array $data + * @param mixed $user + */ + private function attachAssets($trade, $data, $user) { + DB::beginTransaction(); + + try { + $userAssets = createAssetsArray(); + if (isset($data['user_items'])) { + foreach ($data['user_items'] as $stackId=>$quantity) { + $stack = UserItem::with('item')->find($stackId); + if (!$stack || $stack->user_id != $user->id) { + throw new \Exception('Invalid item selected.'); + } + if (!isset($quantity) || $stack->availableQuantity < intval($quantity)) { + throw new \Exception('Invalid quantity of '.$stack->item->name.' selected.'); + } + if (!$stack->item->allow_transfer || isset($stack->data['disallow_transfer'])) { + throw new \Exception('One or more of the selected items cannot be transferred.'); + } + + $stack->trade_count += intval($quantity); + $stack->save(); + + addAsset($userAssets, $stack, intval($quantity)); + } + } + + // Attach currencies + if (isset($data['currencies'])) { + foreach ($data['currencies'] as $currencyId => $quantity) { + $currency = Currency::where('allow_user_to_user', 1)->where('id', $currencyId)->first(); + if (!$currency) { + throw new \Exception('Invalid currency selected.'); + } + + $currencyManager = new CurrencyManager; + if (!$currencyManager->debitCurrency($user, null, null, null, $currency, intval($quantity))) { + throw new \Exception('Invalid currency/quantity selected.'); + } + + addAsset($userAssets, $currency, intval($quantity)); + } + } + + // Attach characters. + if (isset($data['characters'])) { + foreach ($data['characters'] as $characterId=>$quantity) { + $character = Character::where('id', $characterId)->where('user_id', $user->id)->first(); + if (!$character) { + throw new \Exception('Invalid character selected.'); + } + if (!$character->is_sellable && !$character->is_tradeable && !$character->is_giftable) { + throw new \Exception('One or more of the selected characters cannot be transferred.'); + } + if (CharacterTransfer::active()->where('character_id', $character->id)->exists()) { + throw new \Exception('One or more of the selected characters is already pending a character transfer.'); + } + if ($character->trade_id) { + throw new \Exception('One or more of the selected characters is already in a trade.'); + } + if ($character->designUpdate()->active()->exists()) { + throw new \Exception('One or more of the selected characters has an active design update. Please wait for it to be processed, or delete it.'); + } + if ($character->transferrable_at && $character->transferrable_at->isFuture()) { + throw new \Exception('One or more of the selected characters is still on transfer cooldown and cannot be transferred.'); + } + + $character->trade_id = $trade->id; + $character->save(); + + addAsset($userAssets, $character, 1); + } + } + + return $this->commitReturn($userAssets); + } catch (\Exception $e) { + $this->setError('error', $e->getMessage()); + } + + return $this->rollbackReturn(false); + } } diff --git a/app/Services/UserService.php b/app/Services/UserService.php index 751085bc30..1ebae4f634 100644 --- a/app/Services/UserService.php +++ b/app/Services/UserService.php @@ -10,7 +10,7 @@ use App\Models\Invitation; use App\Models\Rank\Rank; use App\Models\Submission\Submission; -use App\Models\Trade; +use App\Models\Trade\Trade; use App\Models\User\User; use App\Models\User\UserUpdateLog; use Carbon\Carbon; diff --git a/config/lorekeeper/admin_sidebar.php b/config/lorekeeper/admin_sidebar.php index 409c62f00d..9898cabdb8 100644 --- a/config/lorekeeper/admin_sidebar.php +++ b/config/lorekeeper/admin_sidebar.php @@ -99,6 +99,10 @@ 'name' => 'Claim Submissions', 'url' => 'admin/claims', ], + [ + 'name' => 'Trades', + 'url' => 'admin/trades/incoming', + ], ], ], 'Grants' => [ @@ -129,10 +133,6 @@ 'name' => 'Character Transfers', 'url' => 'admin/masterlist/transfers/incoming', ], - [ - 'name' => 'Character Trades', - 'url' => 'admin/masterlist/trades/incoming', - ], [ 'name' => 'Design Updates', 'url' => 'admin/design-approvals/pending', diff --git a/config/lorekeeper/allowed_comment_models.php b/config/lorekeeper/allowed_comment_models.php index 4450edff75..d411681409 100644 --- a/config/lorekeeper/allowed_comment_models.php +++ b/config/lorekeeper/allowed_comment_models.php @@ -9,5 +9,5 @@ 'App\Models\Report\Report', 'App\Models\SitePage', 'App\Models\Gallery\GallerySubmission', - + 'App\Models\Trade\TradeListing', ]; diff --git a/config/lorekeeper/notifications.php b/config/lorekeeper/notifications.php index 834cac3d6f..7a7e8fbf77 100644 --- a/config/lorekeeper/notifications.php +++ b/config/lorekeeper/notifications.php @@ -238,14 +238,14 @@ // TRADE_REJECTED 32 => [ 'name' => 'Trade Rejected', - 'message' => 'A trade has been rejected from the character transfer queue. (View Trade)', + 'message' => 'A trade has been rejected. (View Trade)', 'url' => 'trades/{trade_id}', ], // TRADE_CONFIRMED 33 => [ 'name' => 'Trade Confirmed', - 'message' => 'A trade has been confirmed and placed in the character transfer queue to be reviewed. (View Trade)', + 'message' => 'A trade has been confirmed and placed in the trade queue to be reviewed. (View Trade)', 'url' => 'trades/{trade_id}', ], @@ -332,14 +332,14 @@ 'message' => 'Your report (#{report_id}) was closed by {staff_name}. (View Report)', 'url' => 'reports/view/{report_id}', ], - // Comment made on user's model + // COMMENT_MADE 239 => [ 'name' => 'Comment Made', 'message' => '{sender} has made a comment on {post_type}. See Context.', 'url' => '', ], - // Comment recieved reply + // COMMENT_REPLY 240 => [ 'name' => 'Comment Reply', @@ -347,6 +347,34 @@ 'url' => '', ], + // TRADE_PROPOSAL_RECEIVED + 280 => [ + 'name' => 'Trade Proposal Received', + 'message' => 'You have received a new trade proposal from {sender_name}. (View Trade Proposal)', + 'url' => 'trades/{trade_id}', + ], + + // TRADE_PROPOSAL_UPDATED + 281 => [ + 'name' => 'Trade Proposal Updated', + 'message' => '{sender_name} has updated their trade proposal. (View Trade Proposal)', + 'url' => 'trades/{trade_id}', + ], + + // TRADE_PROPOSAL_ACCEPTED + 282 => [ + 'name' => 'Trade Proposal Accepted', + 'message' => 'Your trade proposal has been accepted by {sender_name}. (View Trade Proposal)', + 'url' => 'trades/{trade_id}', + ], + + // TRADE_PROPOSAL_REJECTED + 283 => [ + 'name' => 'Trade Proposal Cancelled', + 'message' => 'A trade proposal has been cancelled by {sender_name}. (View Trade Proposal)', + 'url' => 'trades/{trade_id}', + ], + // CHARACTER_ITEM_GRANT 501 => [ 'name' => 'Character Item Grant', diff --git a/database/migrations/2020_08_04_163916_create_trade_listings_table.php b/database/migrations/2020_08_04_163916_create_trade_listings_table.php new file mode 100644 index 0000000000..69213b9f20 --- /dev/null +++ b/database/migrations/2020_08_04_163916_create_trade_listings_table.php @@ -0,0 +1,38 @@ +engine = 'InnoDB'; + $table->increments('id'); + + // User that created the trade listing. + $table->integer('user_id')->unsigned()->index(); + $table->text('comments')->nullable()->default(null); + // Info about prefered method of contact. + $table->text('contact')->nullable()->default(null); + + // Information including requested & offered items, characters, currencies, and any other goods/services. + $table->string('data', 1024)->nullable()->default(null); + + // Timestamps, including for when the trade expires. + // Only listings whose expiry dates are in the future will be displayed. + $table->timestamp('expires_at')->nullable()->default(null); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down() { + Schema::dropIfExists('trade_listings'); + } +} diff --git a/database/migrations/2020_08_04_191125_add_terms_link_to_trades.php b/database/migrations/2020_08_04_191125_add_terms_link_to_trades.php new file mode 100644 index 0000000000..76d58cab86 --- /dev/null +++ b/database/migrations/2020_08_04_191125_add_terms_link_to_trades.php @@ -0,0 +1,27 @@ +string('terms_link', 200)->nullable()->default(null); + }); + } + + /** + * Reverse the migrations. + */ + public function down() { + Schema::table('trades', function (Blueprint $table) { + // + $table->dropColumn('terms_link'); + }); + } +} diff --git a/database/migrations/2021_01_19_165537_add_title_to_tradelistings.php b/database/migrations/2021_01_19_165537_add_title_to_tradelistings.php new file mode 100644 index 0000000000..97fa3abdae --- /dev/null +++ b/database/migrations/2021_01_19_165537_add_title_to_tradelistings.php @@ -0,0 +1,25 @@ +string('title')->after('id')->nullable()->default(null); + }); + } + + /** + * Reverse the migrations. + */ + public function down() { + Schema::table('trade_listings', function (Blueprint $table) { + $table->dropColumn('title'); + }); + } +} diff --git a/database/migrations/2025_01_02_002934_change_trade_status_enum_to_string.php b/database/migrations/2025_01_02_002934_change_trade_status_enum_to_string.php new file mode 100644 index 0000000000..da04eb53c7 --- /dev/null +++ b/database/migrations/2025_01_02_002934_change_trade_status_enum_to_string.php @@ -0,0 +1,26 @@ +string('status')->default('Open')->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void { + // `status` enum('Open','Pending','Completed','Rejected','Canceled') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'Open', + Schema::table('trades', function (Blueprint $table) { + $table->enum('status', ['Open', 'Pending', 'Completed', 'Rejected', 'Canceled'])->change(); + }); + } +}; diff --git a/package-lock.json b/package-lock.json index 40cf12f902..16a90ea2d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "lk-fork", + "name": "lorekeeper", "lockfileVersion": 2, "requires": true, "packages": { diff --git a/resources/views/admin/grants/item_search.blade.php b/resources/views/admin/grants/item_search.blade.php index febdd7e4e7..fed31550a3 100644 --- a/resources/views/admin/grants/item_search.blade.php +++ b/resources/views/admin/grants/item_search.blade.php @@ -76,7 +76,7 @@ $held = []; if (isset($holdLocations['trade'])) { foreach ($holdLocations['trade'] as $trade => $quantity) { - array_push($held, 'Trade #' . App\Models\Trade::find($trade)->id . '' . ' (' . $quantity . ')'); + array_push($held, 'Trade #' . App\Models\Trade\Trade::find($trade)->id . '' . ' (' . $quantity . ')'); } } if (isset($holdLocations['update'])) { diff --git a/resources/views/admin/index.blade.php b/resources/views/admin/index.blade.php index 1c97258e19..1f1b672545 100644 --- a/resources/views/admin/index.blade.php +++ b/resources/views/admin/index.blade.php @@ -97,13 +97,15 @@
- @if ($transferCount + $tradeCount) - {{ $transferCount + $tradeCount }} character transfer{{ $transferCount + $tradeCount == 1 ? '' : 's' }} and/or trade{{ $transferCount + $tradeCount == 1 ? '' : 's' }} awaiting processing. + @if ($transferCount) + {{ $transferCount }} character transfer{{ $transferCount == 1 ? '' : 's' }} awaiting processing. @else The character transfer/trade queue is clear. Hooray! @endif @@ -115,6 +117,30 @@
+ @if ($tradeCount) + {{ $tradeCount }} trade{{ $tradeCount == 1 ? '' : 's' }} awaiting processing. + @else + The trade queue is clear. Hooray! + @endif +
+This will process the trade between {!! $trade->sender->displayName !!} and {!! $trade->recipient->displayName !!} immediately. Please enter the transfer cooldown period for each character in days (the fields have been pre-filled with the default cooldown value).
@foreach ($trade->getCharacterData() as $character)This will reject the trade between {!! $trade->sender->displayName !!} and {!! $trade->recipient->displayName !!} automatically, returning all items/currency/characters to their owners. The character transfer cooldown will not be applied. Are you sure?
+ Here you can propose a trade to a user. Recipients can modify the proposal as a "counter offer" before accepting or rejecting. + Note that each person may only add up to {{ config('lorekeeper.settings.trade_asset_limit') }} things to one proposal. +
+ + {!! Form::open(['url' => 'trades/propose/' . ($trade ? $trade->id : null)]) !!} + +Here are your trades.
+Please note that to complete a trade, both parties will need to confirm twice each. @@ -56,12 +65,36 @@ First, after you are done editing your offer, confirm your offer to indicate to your partner that you have finished. Next, after both parties have confirmed, you will receive the option to confirm the entire trade. Please make sure that your partner has attached everything that you are expecting to receive!
-+
After both parties have confirmed the entire trade, @if (Settings::get('open_transfers_queue')) if the trade contains a character, it will enter the transfer approval queue. Otherwise, @endif the transfers will be processed immediately.
+ This trade is a propsal! This means that until it has been either accepted or rejected by either party, you can edit both offers. +
++ Once a proposal is accepted, it will be treated as a normal trade and you will not be able to edit your trade partner's offer. + You will have to confirm twice like a normal trade. +
+User Transfer Reasons by Snupsplus
++ Shop Features by ScuffedNewt +
++ Dynamic Limits by ScuffedNewt +
++ TinyMCE Code Editor by Moif +
++ Trade Listings by itinerare & ScuffedNewt +
Aliases on Userpage by Speedy ({{ config('lorekeeper.extensions.aliases_on_userpage') ? 'Enabled' : 'Disabled' }}) diff --git a/resources/views/widgets/_bank_select.blade.php b/resources/views/widgets/_bank_select.blade.php index e88552845a..9ffa9abced 100644 --- a/resources/views/widgets/_bank_select.blade.php +++ b/resources/views/widgets/_bank_select.blade.php @@ -1,28 +1,34 @@ -
Currency | -Quantity | -- | ||||||
---|---|---|---|---|---|---|---|---|
{!! Form::select('currency_id[' . strtolower($owner->logType) . '-' . $owner->id . '][]', $currencySelect, $currencyId, ['class' => 'form-control selectize', 'placeholder' => 'Select Currency ']) !!} | -{!! Form::text('currency_quantity[' . strtolower($owner->logType) . '-' . $owner->id . '][]', $quantity, ['class' => 'form-control']) !!} | -Remove | +
Currency | +Quantity | +
---|
+ | Item | Source | Notes | @@ -55,22 +64,31 @@||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
{!! Form::checkbox(isset($fieldName) && $fieldName ? $fieldName : 'stack_id[]', $itemRow->id, isset($selected) && in_array($itemRow->id, array_keys($selected)) ? true : false, ['class' => 'inventory-checkbox']) !!} | ++ {!! Form::checkbox(isset($fieldName) && $fieldName ? $fieldName : 'stack_id[]', $itemRow->id, isset($selected) && in_array($itemRow->id, array_keys($selected)) ? true : false, ['class' => $fieldPrefix . 'inventory-checkbox']) !!} + |
@if (isset($itemRow->item->image_url))
| {!! array_key_exists('data', $itemRow->data) ? ($itemRow->data['data'] ? $itemRow->data['data'] : 'N/A') : 'N/A' !!} | -{!! array_key_exists('notes', $itemRow->data) ? ($itemRow->data['notes'] ? $itemRow->data['notes'] : 'N/A') : 'N/A' !!} | + @endif + {!! $itemRow->item->name !!} + ++ {!! array_key_exists('data', $itemRow->data) ? ($itemRow->data['data'] ? $itemRow->data['data'] : 'N/A') : 'N/A' !!} + | ++ {!! array_key_exists('notes', $itemRow->data) ? ($itemRow->data['notes'] ? $itemRow->data['notes'] : 'N/A') : 'N/A' !!} + | @if ($itemRow->availableQuantity || in_array($itemRow->id, array_keys($selected))) @if (isset($old_selection) && isset($old_selection[$itemRow->id])) -{!! Form::selectRange('stack_quantity[' . $itemRow->id . ']', 1, $itemRow->getAvailableContextQuantity($selected[$itemRow->id] ?? 0), $old_selection[$itemRow->id], [ - 'class' => 'quantity-select', - 'type' => 'number', - 'style' => 'min-width:40px;', - ]) !!} + | + {!! Form::selectRange('stack_quantity[' . $itemRow->id . ']', 1, $itemRow->getAvailableContextQuantity($selected[$itemRow->id] ?? 0), $old_selection[$itemRow->id], [ + 'class' => $fieldPrefix . 'quantity-select', + 'type' => 'number', + 'style' => 'min-width:40px;', + ]) !!} / {{ $itemRow->getAvailableContextQuantity($selected[$itemRow->id] ?? 0) }} @if ($page == 'trade') @@ -85,8 +103,13 @@ class="d-flex {{ $itemRow->isTransferrable ? '' : 'accountbound' }} user-item se {{ $itemRow->getOthers() }} @endif | - @elseif(in_array($itemRow->id, array_keys($selected))) -{!! Form::selectRange('stack_quantity[' . $itemRow->id . ']', 1, $itemRow->getAvailableContextQuantity($selected[$itemRow->id]), $selected[$itemRow->id], ['class' => 'quantity-select', 'type' => 'number', 'style' => 'min-width:40px;']) !!} + @elseif(in_array($itemRow->id, array_keys($selected)) && $page != 'proposal') + | + {!! Form::selectRange('stack_quantity[' . $itemRow->id . ']', 1, $itemRow->getAvailableContextQuantity($selected[$itemRow->id]), $selected[$itemRow->id], [ + 'class' => 'quantity-select', + 'type' => 'number', + 'style' => 'min-width:40px;', + ]) !!} / {{ $itemRow->getAvailableContextQuantity($selected[$itemRow->id]) }} @if ($page == 'trade') @@ -101,14 +124,39 @@ class="d-flex {{ $itemRow->isTransferrable ? '' : 'accountbound' }} user-item se {{ $itemRow->getOthers() }} @endif | + @elseif (in_array($itemRow->id, array_keys($selected)) && $page == 'proposal') + {{-- We have a special case for proposals because they do not hold any items, therefore their selected should not be included --}} ++ {!! Form::selectRange('stack_quantity[' . $itemRow->id . ']', 1, $itemRow->getAvailableContextQuantity(0), $selected[$itemRow->id], [ + 'class' => 'quantity-select', + 'type' => 'number', + 'style' => 'min-width:40px;', + ]) !!}/{{ $itemRow->getAvailableContextQuantity(0) }} + @if ($itemRow->getOthers()) + {{ $itemRow->getOthers() }} + @endif + | @else -{!! Form::selectRange('', 1, $itemRow->availableQuantity, 1, ['class' => 'quantity-select', 'type' => 'number', 'style' => 'min-width:40px;']) !!} /{{ $itemRow->availableQuantity }} @if ($itemRow->getOthers()) + | + {!! Form::selectRange('', 1, $itemRow->availableQuantity, 1, [ + 'class' => 'quantity-select', + 'type' => 'number', + 'style' => 'min-width:40px;', + ]) !!}/{{ $itemRow->availableQuantity }} + @if ($itemRow->getOthers()) {{ $itemRow->getOthers() }} @endif | @endif @else -{!! Form::selectRange('', 0, 0, 0, ['class' => 'quantity-select', 'type' => 'number', 'style' => 'min-width:40px;', 'disabled']) !!} /{{ $itemRow->availableQuantity }} @if ($itemRow->getOthers()) + | + {!! Form::selectRange('', 0, 0, 0, [ + 'class' => 'quantity-select', + 'type' => 'number', + 'style' => 'min-width:40px;', + 'disabled', + ]) !!} /{{ $itemRow->availableQuantity }} + @if ($itemRow->getOthers()) {{ $itemRow->getOthers() }} @endif | diff --git a/resources/views/widgets/_inventory_select_js.blade.php b/resources/views/widgets/_inventory_select_js.blade.php index f9829df94b..dad7c47478 100644 --- a/resources/views/widgets/_inventory_select_js.blade.php +++ b/resources/views/widgets/_inventory_select_js.blade.php @@ -1,6 +1,11 @@ +@php + if (!isset($fieldPrefix)) { + $fieldPrefix = ''; + } +@endphp diff --git a/routes/lorekeeper/admin.php b/routes/lorekeeper/admin.php index c1a5d3946a..b992549dd7 100644 --- a/routes/lorekeeper/admin.php +++ b/routes/lorekeeper/admin.php @@ -336,6 +336,14 @@ Route::get('item-search', 'GrantController@getItemSearch'); }); +// TRADES +Route::group(['prefix' => 'trades', 'middleware' => 'power:manage_submissions'], function () { + Route::get('{type}', 'TradeController@getTradeQueue'); + Route::get('{id}', 'TradeController@getTradeInfo'); + Route::get('act/{id}/{action}', 'TradeController@getTradeModal'); + Route::post('{id}', 'TradeController@postTradeQueue'); +}); + // MASTERLIST Route::group(['prefix' => 'masterlist', 'namespace' => 'Characters', 'middleware' => 'power:manage_characters'], function () { Route::get('create-character', 'CharacterController@getCreateCharacter'); @@ -348,11 +356,6 @@ Route::get('transfer/act/{id}/{type}', 'CharacterController@getTransferModal'); Route::post('transfer/{id}', 'CharacterController@postTransferQueue'); - Route::get('trades/{type}', 'CharacterController@getTradeQueue'); - Route::get('trade/{id}', 'CharacterController@getTradeInfo'); - Route::get('trade/act/{id}/{type}', 'CharacterController@getTradeModal'); - Route::post('trade/{id}', 'CharacterController@postTradeQueue'); - Route::get('create-myo', 'CharacterController@getCreateMyo'); Route::post('create-myo', 'CharacterController@postCreateMyo'); diff --git a/routes/lorekeeper/members.php b/routes/lorekeeper/members.php index ec28cac80c..173191d76c 100644 --- a/routes/lorekeeper/members.php +++ b/routes/lorekeeper/members.php @@ -88,11 +88,26 @@ }); Route::group(['prefix' => 'trades', 'namespace' => 'Users'], function () { - Route::get('{status}', 'TradeController@getIndex')->where('status', 'open|pending|completed|rejected|canceled'); + // LISTINGS + Route::get('listings', 'TradeController@getListingIndex'); + Route::get('listings/expired', 'TradeController@getExpiredListings'); + Route::get('listings/create', 'TradeController@getCreateListing'); + Route::get('listings/{id}', 'TradeController@getListing')->where('id', '[0-9]+'); + Route::get('listings/{id}/edit', 'TradeController@getEditListing')->where('id', '[0-9]+'); + Route::post('listings/create', 'TradeController@postCreateEditListing'); + Route::post('listings/{id}/edit', 'TradeController@postCreateEditListing')->where('id', '[0-9]+'); + Route::post('listings/{id}/expire', 'TradeController@postExpireListing')->where('id', '[0-9]+'); + + // TRADES + Route::get('{status}', 'TradeController@getIndex')->where('status', 'proposals|open|pending|completed|rejected|canceled'); Route::get('create', 'TradeController@getCreateTrade'); Route::get('{id}/edit', 'TradeController@getEditTrade')->where('id', '[0-9]+'); + Route::get('proposal/{id?}', 'TradeController@getCreateEditTradeProposal')->where('id', '[0-9]+'); + Route::get('proposal/user/{id}', 'TradeController@getUserTradeProposal')->where('id', '[0-9]+'); Route::post('create', 'TradeController@postCreateTrade'); Route::post('{id}/edit', 'TradeController@postEditTrade')->where('id', '[0-9]+'); + Route::post('propose/{id?}', 'TradeController@postCreateEditTradeProposal')->where('id', '[0-9]+'); + Route::post('proposal/{id}/{action}', 'TradeController@postRespondToTradeProposal')->where('id', '[0-9]+')->where('action', 'accept|reject'); Route::get('{id}', 'TradeController@getTrade')->where('id', '[0-9]+'); Route::get('{id}/confirm-offer', 'TradeController@getConfirmOffer');