#include "card_service.h"
#include <cstdint>
#include <filesystem>
#include <optional>
#include <string>
#include <system_error>
#include <utility>
#include "public_id.h"
#include "game_field.h"
namespace
{
inline constexpr int MAX_ID_ATTEMPTS = 128;
mw::E<void> copyAsset(
const std::filesystem::path& source,
const std::filesystem::path& destination)
{
std::error_code filesystem_error;
std::filesystem::copy_file(
source,
destination,
std::filesystem::copy_options::none,
filesystem_error);
if(filesystem_error)
{
return std::unexpected(mw::runtimeError(
"Failed to stage an existing card asset: " +
filesystem_error.message()));
}
return {};
}
} // namespace
CardService::CardService(
DataSourceInterface& data_source,
NonSecretRandom& random,
ImageProcessor image_processor,
AssetStore asset_store)
: data_source_(data_source),
random_(random),
image_processor_(std::move(image_processor)),
asset_store_(std::move(asset_store))
{}
mw::E<std::string> CardService::createLooseCard(
std::int64_t actor_user_id, CreateLooseCardInput input)
{
return createCard(
actor_user_id, std::move(input), std::nullopt, std::nullopt, {}, {});
}
mw::E<std::string> CardService::createGameCard(
std::int64_t actor_user_id,
CreateCardInput input,
std::string game_short_name,
std::int64_t expected_game_revision,
SubmittedGameFields submitted_fields,
const std::vector<std::int64_t>& series_ids)
{
return createCard(
actor_user_id,
std::move(input),
std::move(game_short_name),
expected_game_revision,
std::move(submitted_fields),
series_ids);
}
mw::E<std::string> CardService::createCard(
std::int64_t actor_user_id,
CreateCardInput input,
std::optional<std::string> game_short_name,
std::optional<std::int64_t> expected_game_revision,
SubmittedGameFields submitted_fields,
const std::vector<std::int64_t>& series_ids)
{
if(input.short_description)
{
auto rendered = markdown_renderer_.render(*input.short_description);
if(!rendered)
{
return std::unexpected(std::move(rendered.error()));
}
}
if(input.long_description)
{
auto rendered = markdown_renderer_.render(*input.long_description);
if(!rendered)
{
return std::unexpected(std::move(rendered.error()));
}
}
auto front = image_processor_.process(
input.front, CardAssetType::FRONT_ART);
if(!front)
{
return std::unexpected(std::move(front.error()));
}
std::optional<ProcessedImage> foil;
if(input.foil)
{
auto processed = image_processor_.process(
*input.foil, CardAssetType::FOIL_CONTROL);
if(!processed)
{
return std::unexpected(std::move(processed.error()));
}
foil = std::move(*processed);
}
ProcessedImage thumbnail;
if(foil)
{
if(!input.thumbnail)
{
return std::unexpected(mw::httpError(
422,
"A rendered thumbnail is required for a foil card"));
}
auto processed = image_processor_.process(
*input.thumbnail, CardAssetType::THUMBNAIL);
if(!processed)
{
return std::unexpected(std::move(processed.error()));
}
thumbnail = std::move(*processed);
}
else
{
if(input.thumbnail)
{
return std::unexpected(mw::httpError(
422,
"A plain card must not include an uploaded thumbnail"));
}
auto generated = image_processor_.generatePlainThumbnail(
*front,
input.staging_directory / "thumb.avif");
if(!generated)
{
return std::unexpected(std::move(generated.error()));
}
thumbnail = std::move(*generated);
}
auto transaction = data_source_.beginTransaction();
if(!transaction)
{
return std::unexpected(std::move(transaction.error()));
}
auto actor = (*transaction)->getUserForUpdate(actor_user_id);
if(!actor)
{
return std::unexpected(std::move(actor.error()));
}
if(!*actor || !authorization_.canCreateCard(**actor))
{
return std::unexpected(mw::httpError(403, "Forbidden"));
}
if(!(**actor).username)
{
return std::unexpected(mw::httpError(
409, "Username onboarding is required"));
}
if((**actor).role == UserRole::CREATOR && input.rarity_was_submitted)
{
return std::unexpected(mw::httpError(
400, "Creators cannot submit card rarity"));
}
std::vector<GameFieldValue> field_values;
if(game_short_name)
{
if(!expected_game_revision)
{
return std::unexpected(mw::httpError(
400, "Game revision is required"));
}
auto definition = (*transaction)->getGameDefinitionForUpdate(
*game_short_name);
if(!definition)
{
return std::unexpected(std::move(definition.error()));
}
if(!*definition)
{
return std::unexpected(mw::httpError(422, "Unknown game"));
}
if(!authorization_.canUseGame(**actor, (**definition).game))
{
return std::unexpected(mw::httpError(422, "Unknown game"));
}
if((**definition).game.revision != *expected_game_revision)
{
return std::unexpected(mw::httpError(
409, "The game definition changed; reload and try again"));
}
auto decoded = decodeGameFields(**definition, submitted_fields);
if(!decoded)
{
return std::unexpected(std::move(decoded.error()));
}
field_values = std::move(*decoded);
auto memberships = (*transaction)->seriesBelongToGame(
*game_short_name, series_ids);
if(!memberships)
{
return std::unexpected(std::move(memberships.error()));
}
if(!*memberships)
{
return std::unexpected(mw::httpError(
422, "A selected series belongs to another game"));
}
}
else if(expected_game_revision || !submitted_fields.empty() ||
!series_ids.empty())
{
return std::unexpected(mw::httpError(
422, "Loose cards cannot have game fields or series"));
}
std::uint64_t number = 0;
if(game_short_name)
{
auto allocated = (*transaction)->allocateGameNumber(
*game_short_name);
if(!allocated)
{
return std::unexpected(std::move(allocated.error()));
}
number = *allocated;
}
else
{
std::optional<std::uint32_t> loose_number;
for(int attempt = 0; attempt < MAX_ID_ATTEMPTS; ++attempt)
{
const std::uint32_t candidate = random_.next();
auto exists = (*transaction)->looseNumberExists(candidate);
if(!exists)
{
return std::unexpected(std::move(exists.error()));
}
if(!*exists)
{
loose_number = candidate;
break;
}
}
if(!loose_number)
{
return std::unexpected(mw::runtimeError(
"Failed to allocate a unique loose-card number"));
}
number = *loose_number;
}
Card card = {
0,
{game_short_name, number},
std::move(input.name),
std::move(input.short_description),
std::move(input.long_description),
(**actor).role == UserRole::CREATOR ? 0 : input.rarity,
front->extension,
foil ? std::optional<std::string>(foil->extension) : std::nullopt,
thumbnail.extension,
1,
actor_user_id,
};
auto inserted = (*transaction)->insertCard(
card, field_values, series_ids);
if(!inserted)
{
return std::unexpected(std::move(inserted.error()));
}
auto public_id = formatPublicId(card.identity);
if(!public_id)
{
return std::unexpected(std::move(public_id.error()));
}
auto published = asset_store_.publish(
input.staging_directory, *public_id);
if(!published)
{
return std::unexpected(std::move(published.error()));
}
auto committed = (*transaction)->commit();
if(!committed)
{
asset_store_.removePublished(*public_id);
return std::unexpected(std::move(committed.error()));
}
return *public_id;
}
mw::E<std::string> CardService::updateLooseCard(
std::int64_t actor_user_id, UpdateLooseCardInput input)
{
return updateCard(
actor_user_id, std::move(input), std::nullopt, {}, {});
}
mw::E<std::string> CardService::updateGameCard(
std::int64_t actor_user_id,
UpdateLooseCardInput input,
std::int64_t expected_game_revision,
SubmittedGameFields submitted_fields,
const std::vector<std::int64_t>& series_ids)
{
return updateCard(
actor_user_id,
std::move(input),
expected_game_revision,
std::move(submitted_fields),
series_ids);
}
mw::E<std::string> CardService::updateCard(
std::int64_t actor_user_id,
UpdateLooseCardInput input,
std::optional<std::int64_t> expected_game_revision,
SubmittedGameFields submitted_fields,
const std::vector<std::int64_t>& series_ids)
{
auto stored = data_source_.getCard(
input.current_card.identity,
GameContentScope::INCLUDE_INTERNAL);
if(!stored)
{
return std::unexpected(std::move(stored.error()));
}
if(!*stored)
{
return std::unexpected(mw::httpError(404, "Card not found"));
}
Card card = std::move(**stored);
if(input.short_description)
{
auto rendered = markdown_renderer_.render(*input.short_description);
if(!rendered)
{
return std::unexpected(std::move(rendered.error()));
}
}
if(input.long_description)
{
auto rendered = markdown_renderer_.render(*input.long_description);
if(!rendered)
{
return std::unexpected(std::move(rendered.error()));
}
}
const bool is_game_card = card.identity.game_short_name.has_value();
if(card.id <= 0 || is_game_card != expected_game_revision.has_value())
{
return std::unexpected(mw::httpError(
400, "Card identity does not match its game definition"));
}
if(input.expected_revision != card.revision)
{
return std::unexpected(mw::httpError(
409, "The card was changed in another request"));
}
const bool rendering_changed =
input.front_action == FrontAssetAction::REPLACE ||
input.foil_action != FoilAssetAction::KEEP;
if((input.front_action == FrontAssetAction::REPLACE) !=
input.front.has_value())
{
return std::unexpected(mw::httpError(
422, "Front artwork does not match the requested action"));
}
if((input.foil_action == FoilAssetAction::REPLACE) !=
input.foil.has_value())
{
return std::unexpected(mw::httpError(
422, "Foil control does not match the requested action"));
}
if(!rendering_changed && input.thumbnail)
{
return std::unexpected(mw::httpError(
422, "A metadata-only edit must not include a thumbnail"));
}
auto public_id = formatPublicId(card.identity);
if(!public_id)
{
return std::unexpected(std::move(public_id.error()));
}
std::optional<AssetReplacement> replacement;
if(rendering_changed)
{
const std::filesystem::path published =
asset_store_.directory(*public_id);
ProcessedImage front;
if(input.front_action == FrontAssetAction::REPLACE)
{
auto processed = image_processor_.process(
*input.front, CardAssetType::FRONT_ART);
if(!processed)
{
return std::unexpected(std::move(processed.error()));
}
front = std::move(*processed);
}
else
{
const std::string name =
"front-art." + card.front_extension;
auto copied = copyAsset(
published / name, input.staging_directory / name);
if(!copied)
{
return std::unexpected(std::move(copied.error()));
}
front = {
input.staging_directory / name,
card.front_extension,
0,
0,
false,
};
}
std::optional<ProcessedImage> foil;
if(input.foil_action == FoilAssetAction::REPLACE)
{
auto processed = image_processor_.process(
*input.foil, CardAssetType::FOIL_CONTROL);
if(!processed)
{
return std::unexpected(std::move(processed.error()));
}
foil = std::move(*processed);
}
else if(input.foil_action == FoilAssetAction::KEEP &&
card.foil_extension)
{
const std::string name = "foil." + *card.foil_extension;
auto copied = copyAsset(
published / name, input.staging_directory / name);
if(!copied)
{
return std::unexpected(std::move(copied.error()));
}
foil = ProcessedImage{
input.staging_directory / name,
*card.foil_extension,
0,
0,
false,
};
}
ProcessedImage thumbnail;
if(foil)
{
if(!input.thumbnail)
{
return std::unexpected(mw::httpError(
422,
"A rendered thumbnail is required for this edit"));
}
auto processed = image_processor_.process(
*input.thumbnail, CardAssetType::THUMBNAIL);
if(!processed)
{
return std::unexpected(std::move(processed.error()));
}
thumbnail = std::move(*processed);
}
else
{
if(input.thumbnail)
{
return std::unexpected(mw::httpError(
422,
"A plain card must not include an uploaded thumbnail"));
}
auto generated = image_processor_.generatePlainThumbnail(
front, input.staging_directory / "thumb.avif");
if(!generated)
{
return std::unexpected(std::move(generated.error()));
}
thumbnail = std::move(*generated);
}
card.front_extension = std::move(front.extension);
card.foil_extension = foil
? std::optional<std::string>(std::move(foil->extension))
: std::nullopt;
card.thumbnail_extension = std::move(thumbnail.extension);
}
card.name = std::move(input.name);
card.short_description = std::move(input.short_description);
card.long_description = std::move(input.long_description);
++card.revision;
auto transaction = data_source_.beginTransaction();
if(!transaction)
{
return std::unexpected(std::move(transaction.error()));
}
auto current = (*transaction)->getCardForUpdate(card.id);
if(!current)
{
return std::unexpected(std::move(current.error()));
}
if(!*current)
{
return std::unexpected(mw::httpError(404, "Card not found"));
}
if((**current).revision != input.expected_revision)
{
return std::unexpected(mw::httpError(
409, "The card was changed in another request"));
}
auto actor = (*transaction)->getUserForUpdate(actor_user_id);
if(!actor)
{
return std::unexpected(std::move(actor.error()));
}
if(!*actor || !authorization_.canEditCard(**actor, **current))
{
return std::unexpected(mw::httpError(404, "Card not found"));
}
if(!(**actor).username)
{
return std::unexpected(mw::httpError(
409, "Username onboarding is required"));
}
if((**actor).role == UserRole::CREATOR && input.rarity_was_submitted)
{
return std::unexpected(mw::httpError(
400, "Creators cannot submit card rarity"));
}
card.id = (**current).id;
card.identity = (**current).identity;
card.creator_user_id = (**current).creator_user_id;
card.rarity = (**actor).role == UserRole::CREATOR
? (**current).rarity
: input.rarity;
std::vector<GameFieldValue> field_values;
if(card.identity.game_short_name)
{
auto definition = (*transaction)->getGameDefinitionForUpdate(
*card.identity.game_short_name);
if(!definition)
{
return std::unexpected(std::move(definition.error()));
}
if(!*definition)
{
return std::unexpected(mw::runtimeError(
"Card references a missing game"));
}
if(!authorization_.canUseGame(**actor, (**definition).game))
{
return std::unexpected(mw::httpError(404, "Card not found"));
}
if((**definition).game.revision != *expected_game_revision)
{
return std::unexpected(mw::httpError(
409, "The game definition changed; reload and try again"));
}
auto decoded = decodeGameFields(**definition, submitted_fields);
if(!decoded)
{
return std::unexpected(std::move(decoded.error()));
}
field_values = std::move(*decoded);
auto memberships = (*transaction)->seriesBelongToGame(
*card.identity.game_short_name, series_ids);
if(!memberships)
{
return std::unexpected(std::move(memberships.error()));
}
if(!*memberships)
{
return std::unexpected(mw::httpError(
422, "A selected series belongs to another game"));
}
}
else if(!submitted_fields.empty() || !series_ids.empty())
{
return std::unexpected(mw::httpError(
422, "Loose cards cannot have game fields or series"));
}
auto updated = (*transaction)->updateCard(
card, field_values, series_ids);
if(!updated)
{
return std::unexpected(std::move(updated.error()));
}
if(rendering_changed)
{
auto replaced = asset_store_.replace(
input.staging_directory,
*public_id,
input.expected_revision);
if(!replaced)
{
return std::unexpected(std::move(replaced.error()));
}
replacement = std::move(*replaced);
}
auto committed = (*transaction)->commit();
if(!committed)
{
if(replacement)
{
asset_store_.restore(*replacement);
}
return std::unexpected(std::move(committed.error()));
}
if(replacement)
{
asset_store_.finish(*replacement);
}
return *public_id;
}
mw::E<void> CardService::deleteCard(
std::int64_t actor_user_id, const Card& card)
{
auto transaction = data_source_.beginTransaction();
if(!transaction)
{
return std::unexpected(std::move(transaction.error()));
}
auto current = (*transaction)->getCardForUpdate(card.id);
if(!current)
{
return std::unexpected(std::move(current.error()));
}
if(!*current)
{
return std::unexpected(mw::httpError(404, "Card not found"));
}
auto actor = (*transaction)->getUserForUpdate(actor_user_id);
if(!actor)
{
return std::unexpected(std::move(actor.error()));
}
if(!*actor || !authorization_.canDeleteCard(**actor))
{
return std::unexpected(mw::httpError(403, "Forbidden"));
}
if(!(**actor).username)
{
return std::unexpected(mw::httpError(
409, "Username onboarding is required"));
}
auto public_id = formatPublicId((**current).identity);
if(!public_id)
{
return std::unexpected(std::move(public_id.error()));
}
auto trashed = asset_store_.trash(
*public_id, (**current).revision, random_.hex(16));
if(!trashed)
{
return std::unexpected(std::move(trashed.error()));
}
auto deleted = (*transaction)->deleteCard((**current).id);
if(!deleted)
{
asset_store_.restore(*trashed);
return std::unexpected(std::move(deleted.error()));
}
auto committed = (*transaction)->commit();
if(!committed)
{
asset_store_.restore(*trashed);
return std::unexpected(std::move(committed.error()));
}
asset_store_.finish(*trashed);
return {};
}