diff --git a/.dockerignore b/.dockerignore index 83d1bf93..0342259c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,13 +1,13 @@ -/vendor -/node_modules -/.vscode -/public/build -/public/hot -/public/storage -/public/app_images -/storage/*.key -/database/database.sqlite -/storage/database.sqlite +vendor +node_modules +.vscode +.github +public/storage +public/uploads +storage/logs/* +storage/http_cache/* +storage/debugbar/* +storage/bar-assistant .env Dockerfile diff --git a/.env.dist b/.env.dist index faf6528a..818f7887 100644 --- a/.env.dist +++ b/.env.dist @@ -22,8 +22,8 @@ REDIS_PORT=6379 SCOUT_DRIVER=meilisearch MEILISEARCH_HOST= MEILISEARCH_KEY= +MEILISEARCH_API_KEY= SANCTUM_STATEFUL_DOMAINS= -#DISABLE_LOGIN=true PARENT_INGREDIENT_SUBSTITUTE=false diff --git a/.github/workflows/build-dev-image.yml b/.github/workflows/build-dev-image.yml index a5beeab8..e3e19a1d 100644 --- a/.github/workflows/build-dev-image.yml +++ b/.github/workflows/build-dev-image.yml @@ -29,6 +29,7 @@ jobs: uses: docker/build-push-action@v3 with: context: . + target: dist platforms: linux/amd64 push: true tags: barassistant/server:dev diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index 1ffd2367..83c50435 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -12,6 +12,19 @@ jobs: - name: Checkout uses: actions/checkout@v3 + - + name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: | + barassistant/server + flavor: | + latest=false + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern=v{{major}} - name: Set up QEMU uses: docker/setup-qemu-action@v2 @@ -29,9 +42,10 @@ jobs: uses: docker/build-push-action@v3 with: context: . + target: dist platforms: linux/amd64,linux/arm64 push: true - tags: barassistant/server:latest, barassistant/server:${{github.ref_name}} + tags: ${{ steps.meta.outputs.tags }} build-args: BAR_ASSISTANT_VERSION=${{github.ref_name}} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.github/workflows/build-kmikus12-image.yml b/.github/workflows/build-kmikus12-image.yml deleted file mode 100644 index 8ede9628..00000000 --- a/.github/workflows/build-kmikus12-image.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Build legacy docker image - -on: - push: - tags: - - 'v1.4.*' - -jobs: - docker: - runs-on: ubuntu-latest - steps: - - - name: Checkout - uses: actions/checkout@v3 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Login to Docker Hub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Build and push - uses: docker/build-push-action@v3 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: kmikus12/bar-assistant-server:latest, kmikus12/bar-assistant-server:${{github.ref_name}} - cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/CHANGELOG.md b/CHANGELOG.md index f84d5af1..067c640a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,82 @@ +# v3.0.0 +## Multiple bars +- Bar Assistant now supports multiple bars. + - With this change a lot of endpoints now require to have bar reference, this comes in a form of `bar_id` query parameter + - Please refer to the new schema specification to see what endpoints now require `bar_id` query parameter +- This update also changed a lot database table schemas, so I advise you to create a backup of your data before you update to v3 +- Users can be invited or join with invite code to specific bars + +## Improved user control +- Users can have one of the following roles in a bar: + - Guest + - Rate and favorite cocktails + - Create personal collections + - General + - Everything as "Guest" + - Can add cocktails and ingredients + - Moderator + - Everything as "General" + - Can not modify bar + - Can not change user roles + - Admin + - Everything as "Moderator" + - Full access to all bar actions + +## Breaking changes +- Updated a lot of schemas, refer to openapi specification to see the changes +- Token response is now wrapped with `data` object like the rest of the endpoints endpoint +- Removed POST `shelf/ingredients` endpoint +- Removed POST `shelf/ingredients/{ingredientId}` endpoint + - **Upgrade guide**: Use `shelf/ingredients/batch-store` endpoint +- Removed DELETE `shelf/ingredients/{ingredientId}` endpoint + - **Upgrade guide**: Use `shelf/ingredients/batch-delete` endpoint +- Removed `notes` property from `Cocktail` schema + - **Upgrade guide**: Use `notes/` endpoint to get users notes +- Removed `glasses/find` endpoint + - **Upgrade guide**: Use `glasses/` endpoint with `filter[name]` query string +- Removed GET `/images` endpoint +- Removed `bar:make-admin` command +- Removed `bar:open` command +- Removed `bar:refresh-user-search-keys` command +- Removed `bar:import-zip` command +- Removed `bar:export-zip` command +- Removed `bar:scrape` command +- Renamed `/user` endpoint to `/profile` +- Renamed `user_id` filter on `cocktails` endpoint to `created_user_id` +- Renamed `user_id` filter on `ingredients` endpoint to `created_user_id` +- Ingredient category is not required anymore when adding an ingredient + +## New +- Added `bars/` endpoint +- Added GET `notes/` endpoint +- Stats now have users top 5 favorite ingredients, calculated from favorite cocktails +- Importing cocktails from collection now has actions on how to handle duplicates +- Added `bar:backup {barId}` command +- Cocktail ingredient now supports variable amounts, you can add max amount with `amount_max` attribute +- Cocktail ingredient now can have a note attached +- Cocktail substitutes now have the following attributes: `ingredient_id`, `amount`, `amount_max`, `units` +- Added options on how to handle duplicated recipes when importing collection + - Duplicates are matched by recipe name + - Possible actions: + - Do nothing + - Overwrite duplicated + - Skip duplicates +- Added `average_rating_min` filter to `cocktails` endpoint +- Added `average_rating_max` filter to `cocktails` endpoint + +## Changes +- Default database filename changed to `database.ba3.sqlite` +- Optimized base images of cocktails and ingredients +- Cocktail and ingredient images are now categorized in folders by bar id +- Merged all migrations to a single one +- Meilisearch API keys are now generating tenant tokens +- Changed the way slugs are generated, they now include bar id +- Changed what attributes are searchable + - Removed from `cocktails` index: `garnish`, `image_hash`, `main_image_id`, `user_id`, `glass`, `average_rating`, `main_ingredient_name`, `calculated_abv`, `method`, `has_public_link` + - Added to `cocktails` index: `bar_id` + - Removed from `ingredients` index: `strength_abv`, `color` + - Added to `ingredients` index: `bar_id` + # v2.6.0 ## New - Added export options for version 3: `php artisan bar:export-zip --version3` @@ -155,6 +234,7 @@ - Updated `UserIngredient` schema - Updated `Ingredient` schema - Moved `parent_ingredient_id` to `parent_ingredient` object, accessible via `parent_ingredient.id` +- Removed `DISABLE_LOGIN` env variable ## New - Meilisearch is no longer mandatory dependency for API to work @@ -165,6 +245,8 @@ - This will remove the need to authenticate with token to access the api - Added GET `/images` endpoint - `ImageRequest` schema now supports `image_url` parameter to upload image from URL +- Added `MAX_USER_BARS` env variable, defaults to 50 + - This limits how many bars can a single user create ## Fixes - Fixed openapi swagger docs url diff --git a/Dockerfile b/Dockerfile index c388d92f..1c745c66 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,16 @@ -FROM php:8.2-fpm +FROM php:8.2-fpm as php-base ARG BAR_ASSISTANT_VERSION -ENV BAR_ASSISTANT_VERSION=${BAR_ASSISTANT_VERSION:-v0-dev} +ENV BAR_ASSISTANT_VERSION=${BAR_ASSISTANT_VERSION:-develop} + +ARG PGID=1000 +ENV PGID=${PGID} +ARG PUID=1000 +ENV PUID=${PUID} + +# Add php extension manager +ADD https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/ -# Add dependencies RUN apt update \ && apt-get install -y \ git \ @@ -13,45 +20,51 @@ RUN apt update \ nginx \ gosu \ && apt-get autoremove -y \ - && apt-get clean - -ADD https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/ -RUN chmod +x /usr/local/bin/install-php-extensions && \ - install-php-extensions imagick opcache redis zip - -# Setup custom php config -COPY ./resources/docker/php.ini $PHP_INI_DIR/php.ini - -# Setup nginx -COPY ./resources/docker/nginx.conf /etc/nginx/sites-enabled/default -RUN echo "access.log = /dev/null" >> /usr/local/etc/php-fpm.d/www.conf - -# Add container entrypoint script -COPY ./resources/docker/entrypoint.sh /usr/local/bin/entrypoint -RUN chmod +x /usr/local/bin/entrypoint + && apt-get clean \ + && chmod +x /usr/local/bin/install-php-extensions && \ + install-php-extensions imagick opcache redis zip && \ + echo "access.log = /dev/null" >> /usr/local/etc/php-fpm.d/www.conf # Add composer COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer -USER www-data:www-data +FROM php-base as dist WORKDIR /var/www/cocktails -COPY --chown=www-data:www-data . . +COPY . . -RUN sed -i "s/{{VERSION}}/$BAR_ASSISTANT_VERSION/g" ./docs/open-api-spec.yml +# Configure nginx +COPY ./resources/docker/nginx.conf /etc/nginx/sites-enabled/default -RUN chmod +x /var/www/cocktails/resources/docker/run.sh +# Configure php +COPY ./resources/docker/php.ini $PHP_INI_DIR/php.ini -RUN composer install --optimize-autoloader --no-dev +# Add container entrypoint script +COPY ./resources/docker/entrypoint.sh /usr/local/bin/entrypoint -RUN mkdir -p /var/www/cocktails/storage/bar-assistant/ +RUN chmod +x /usr/local/bin/entrypoint \ + && chmod +x /var/www/cocktails/resources/docker/run.sh \ + && sed -i "s/{{VERSION}}/$BAR_ASSISTANT_VERSION/g" ./docs/open-api-spec.yml \ + && composer install --optimize-autoloader --no-dev \ + && mkdir -p /var/www/cocktails/storage/bar-assistant/ EXPOSE 3000 VOLUME ["/var/www/cocktails/storage/bar-assistant"] -USER root:root - ENTRYPOINT ["entrypoint"] -CMD ["/bin/bash", "-c", "php-fpm & nginx -g 'daemon off;'"] + +FROM php-base as localdev + +RUN useradd -G www-data,root -u $PUID -d /home/developer developer +RUN mkdir -p /home/developer/.composer && \ + chown -R developer:developer /home/developer + +USER developer + +WORKDIR /var/www/cocktails + +EXPOSE 9000 + +CMD ["php-fpm"] diff --git a/Dockerfile.dev b/Dockerfile.dev deleted file mode 100644 index 37dcf154..00000000 --- a/Dockerfile.dev +++ /dev/null @@ -1,35 +0,0 @@ -FROM php:8.2-fpm - -ARG user -ARG uid - -# Add dependencies -RUN apt update \ - && apt-get install -y \ - git \ - unzip \ - sqlite3 \ - bash \ - && apt-get autoremove -y \ - && apt-get clean - -ADD https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/ -RUN chmod +x /usr/local/bin/install-php-extensions && \ - install-php-extensions imagick opcache redis zip xdebug - -RUN echo "access.log = /dev/null" >> /usr/local/etc/php-fpm.d/www.conf - -# Add composer -COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer - -RUN useradd -G www-data,root -u $uid -d /home/$user $user -RUN mkdir -p /home/$user/.composer && \ - chown -R $user:$user /home/$user - -USER $user - -WORKDIR /var/www/cocktails - -EXPOSE 9000 - -CMD ["php-fpm"] diff --git a/README.md b/README.md index 3d09cde4..a747dce0 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ This repository only contains the API server, if you are looking for easy to use ## Features - Includes all current IBA cocktails - Over 100 ingredients +- Add and manage multiple bars +- Fine grained control with user roles - Endpoints for managing of ingredients and cocktails - Mark ingredients you have and get all cocktails that you can make - Detailed cocktail and ingredient information @@ -61,7 +63,6 @@ $ docker compose exec app composer install $ docker compose exec app php artisan key:generate $ docker compose exec app php artisan storage:link $ docker compose exec app php artisan migrate -$ docker compose exec app php artisan bar:open ``` Xdebug vscode launch config: diff --git a/app/BarContext.php b/app/BarContext.php new file mode 100644 index 00000000..351c45da --- /dev/null +++ b/app/BarContext.php @@ -0,0 +1,19 @@ +currentBar; + } +} diff --git a/app/Console/Commands/BarExport.php b/app/Console/Commands/BarExport.php deleted file mode 100644 index ad1a059e..00000000 --- a/app/Console/Commands/BarExport.php +++ /dev/null @@ -1,55 +0,0 @@ -option('version3'); - - try { - if ($forVersion3) { - $filepath = $this->exportService->exportForVersion3(); - } else { - $filepath = $this->exportService->instanceShareExport(); - } - } catch (Throwable $e) { - $this->error($e->getMessage()); - - return Command::FAILURE; - } - - $this->info('Export created successfully at "' . $filepath . '". Please note this will only create a zip file that will help you import data into a new BA instance. For a complete backup you should manually backup your uploads folder and database!'); - - return Command::SUCCESS; - } -} diff --git a/app/Console/Commands/BarFullBackup.php b/app/Console/Commands/BarFullBackup.php new file mode 100644 index 00000000..398cf15a --- /dev/null +++ b/app/Console/Commands/BarFullBackup.php @@ -0,0 +1,38 @@ +exporter->process(); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/BarImportFromFile.php b/app/Console/Commands/BarImportFromFile.php deleted file mode 100644 index 39c23bf6..00000000 --- a/app/Console/Commands/BarImportFromFile.php +++ /dev/null @@ -1,88 +0,0 @@ - 'local', - 'root' => storage_path('bar-assistant'), - ]); - - $selectedFilename = $this->argument('filename'); - if ($selectedFilename) { - $zipFilePath = $disk->path($this->argument('filename')); - } else { - $existingZipFiles = collect($disk->files())->filter(function ($filepath) { - return str_ends_with($filepath, 'zip'); - })->toArray(); - - $zipFilePath = $this->choice( - 'What is the filename that you want to import?', - $existingZipFiles, - ); - - $zipFilePath = $disk->path($zipFilePath); - } - - $this->info(sprintf('Checking for "%s" file...', $zipFilePath)); - - if (!file_exists($zipFilePath)) { - $this->info('File not found! Make sure the file is located in storage/ directory.'); - - return Command::FAILURE; - } - - if (!$this->confirm('This action will overwrite any data you currently have. Are you sure you want to continue?')) { - return Command::SUCCESS; - } - - try { - $this->importService->importFromZipFile($zipFilePath); - } catch (Throwable $e) { - $this->error($e->getMessage()); - } - - $this->info('Refreshing search indexes...'); - - Artisan::call('bar:refresh-search'); - Artisan::call('cache:clear'); - - $this->info('Importing is finished!'); - - return Command::SUCCESS; - } -} diff --git a/app/Console/Commands/BarMaintenance.php b/app/Console/Commands/BarMaintenance.php index 98914d9a..d2062955 100644 --- a/app/Console/Commands/BarMaintenance.php +++ b/app/Console/Commands/BarMaintenance.php @@ -1,5 +1,7 @@ info('Checking unused images...'); - $this->deleteUnusedImages(); + // $this->deleteUnusedImages(); // Optimize images $this->info('Optimizing images...'); - $this->optimizeImages(); + // $this->optimizeImages(); // Update indexes Artisan::call('bar:refresh-search --clear'); @@ -75,30 +77,30 @@ private function fixSort(): void }); } - private function deleteUnusedImages(): void - { - $baDisk = Storage::disk('bar-assistant'); - $images = Image::whereNull('imageable_id')->get(); - - if ($images->isNotEmpty()) { - $i = 0; - foreach ($images as $image) { - try { - $i++; - $image->delete(); - } catch (Throwable) { - } - } - $this->info('Deleted ' . $i . ' images.'); - } - - $tempFiles = $baDisk->files('temp/'); - - if (count($tempFiles) > 0) { - $baDisk->delete($tempFiles); - $this->info('Deleted ' . count($tempFiles) . ' temporary images.'); - } - } + // private function deleteUnusedImages(): void + // { + // $baDisk = Storage::disk('bar-assistant'); + // $images = Image::whereNull('imageable_id')->get(); + + // if ($images->isNotEmpty()) { + // $i = 0; + // foreach ($images as $image) { + // try { + // $i++; + // $image->delete(); + // } catch (Throwable) { + // } + // } + // $this->info('Deleted ' . $i . ' images.'); + // } + + // $tempFiles = $baDisk->files('temp/'); + + // if (count($tempFiles) > 0) { + // $baDisk->delete($tempFiles); + // $this->info('Deleted ' . count($tempFiles) . ' temporary images.'); + // } + // } public function optimizeImages(): void { diff --git a/app/Console/Commands/BarMakeAdmin.php b/app/Console/Commands/BarMakeAdmin.php deleted file mode 100644 index 8d178a11..00000000 --- a/app/Console/Commands/BarMakeAdmin.php +++ /dev/null @@ -1,41 +0,0 @@ -argument('email'))->first(); - - $user->is_admin = true; - - $user->save(); - - $this->info('User updated!'); - - return Command::SUCCESS; - } -} diff --git a/app/Console/Commands/BarOpen.php b/app/Console/Commands/BarOpen.php deleted file mode 100644 index 18b9c5e6..00000000 --- a/app/Console/Commands/BarOpen.php +++ /dev/null @@ -1,499 +0,0 @@ -info('Opening the bar in ' . App::environment() . ' environment...'); - - $this->info('Testing your search driver connection [' . config('scout.driver') . ']...'); - - /** @var SearchActionsContract */ - $searchActions = app(SearchActionsAdapter::class)->getActions(); - - if (!$searchActions->isAvailable()) { - $this->error('Unable to connect to search server with driver [' . config('scout.driver') . ']!'); - } - - DB::table('users')->insert([ - [ - 'id' => 1, - 'name' => 'Bas Assistant', - 'password' => '', // password - 'email' => 'default@localhost.com', - 'email_verified_at' => null, - 'remember_token' => null, - 'is_admin' => false, - 'search_api_key' => null, - ], - [ - 'id' => 2, - 'name' => 'Bartender', - 'password' => Hash::make($this->argument('pass')), - 'email' => $this->argument('email'), - 'email_verified_at' => now(), - 'remember_token' => Str::random(10), - 'is_admin' => true, - 'search_api_key' => $searchActions->getPublicApiKey() - ] - ]); - - // Also flush model indexes - Artisan::call('scout:flush', ['model' => "Kami\Cocktail\Models\Cocktail"]); - Artisan::call('scout:flush', ['model' => "Kami\Cocktail\Models\Ingredient"]); - - $searchActions->updateIndexSettings(); - - if ($this->option('clean')) { - Model::reguard(); - - $this->info('You are ready to serve, no data has been imported!'); - - return Command::SUCCESS; - } - - DB::table('glasses')->insert([ - ['name' => 'Cocktail', 'description' => 'A cocktail glass is a stemmed glass with an inverted cone bowl, mainly used to serve straight-up cocktails. The term cocktail glass is often used interchangeably with martini glass, despite their differing slightly. A standard cocktail glass contains 90 to 300 millilitres.'], - ['name' => 'Lowball', 'description' => 'The old fashioned glass, otherwise known as the rocks glass and lowball glass (or simply lowball), is a short tumbler used for serving spirits, such as whisky, neat or with ice cubes ("on the rocks"). Old fashioned glasses usually contain 180–300 ml.'], - ['name' => 'Highball', 'description' => 'A highball glass is a glass tumbler that can contain 240 to 350 millilitres (8 to 12 US fl oz).'], - ['name' => 'Shot', 'description' => 'A shot glass is a glass originally designed to hold or measure spirits or liquor, which is either imbibed straight from the glass ("a shot") or poured into a cocktail ("a drink").'], - ['name' => 'Coupe', 'description' => 'The champagne coupe is a shallow, broad-bowled saucer shaped stemmed glass generally capable of containing 180 to 240 ml (6.1 to 8.1 US fl oz) of liquid.'], - ['name' => 'Margarita', 'description' => 'A variant of the classic champagne coupe.'], - ['name' => 'Wine', 'description' => 'A wine glass is a type of glass that is used to drink and taste wine. Most wine glasses are stemware (goblets), i.e., they are composed of three parts: the bowl, stem, and foot.'], - ['name' => 'Champagne', 'description' => 'A champagne glass is stemware designed for champagne and other sparkling wines.'], - ['name' => 'Hurricane', 'description' => 'A hurricane glass is a form of drinking glass which typically will contain 20 US fluid ounces.'], - ['name' => 'Nick and Nora', 'description' => 'A Nick & Nora glass is a stemmed glass with an inverted bowl, mainly used to serve straight-up cocktails. The glass is similar to a cocktail glass or martini glass.'], - ['name' => 'Fizzio', 'description' => 'Wide flat bowl on a stem.'], - ['name' => 'Sour', 'description' => 'Tulip shaped with a fatter stem.'], - ['name' => 'Julep', 'description' => 'Metal bucket shaped glass.'], - ['name' => 'Absinthe', 'description' => 'Absinthe glasses have a reservoir in the stem to measure the correct amount of Absinthe for one serving.'], - ['name' => 'Glass mug', 'description' => 'A mug made of glass.'], - ['name' => 'Copper mug', 'description' => 'A mug made of copper.'], - ['name' => 'Tiki', 'description' => 'The term "tiki mug" is a blanket term for the sculptural drinkware even though they vary in size and most do not contain handles.'], - ]); - - $uncategorized = IngredientCategory::create(['id' => 1, 'name' => 'Uncategorized']); - $spirits = IngredientCategory::create(['name' => 'Spirits', 'description' => 'Alcoholic drinks produced by distillation of grains, fruits, vegetables, or sugar, that have already gone through alcoholic fermentation.']); - $liqueurs = IngredientCategory::create(['name' => 'Liqueurs', 'description' => 'Alcoholic drinks composed of spirits (often rectified spirit) and additional flavorings such as sugar, fruits, herbs, and spices.']); - $juices = IngredientCategory::create(['name' => 'Juices', 'description' => 'Drinks made from the extraction or pressing of the natural liquid contained in fruit and vegetables.']); - $fruits = IngredientCategory::create(['name' => 'Fruits and vegetables']); - $syrups = IngredientCategory::create(['name' => 'Syrups', 'description' => 'Condiment that is a thick, viscous liquid consisting primarily of a solution of sugar in water, containing a large amount of dissolved sugars but showing little tendency to deposit crystals.']); - $wines = IngredientCategory::create(['name' => 'Wines']); - $bitters = IngredientCategory::create(['name' => 'Bitters']); - $beverages = IngredientCategory::create(['name' => 'Beverages']); - - $this->info('Filling your bar with ingredients...'); - - // Fruits - $limeFruit = Ingredient::create(['name' => 'Lime', 'ingredient_category_id' => $fruits->id, 'strength' => 0.0, 'description' => 'Lime fruit', 'user_id' => 1]); - $lemonFruit = Ingredient::create(['name' => 'Lemon', 'ingredient_category_id' => $fruits->id, 'strength' => 0.0, 'description' => 'Lemon fruit', 'user_id' => 1]); - Ingredient::create(['name' => 'Orange', 'ingredient_category_id' => $fruits->id, 'strength' => 0.0, 'description' => 'Orange fruit', 'user_id' => 1]); - Ingredient::create(['name' => 'Pineapple', 'ingredient_category_id' => $fruits->id, 'strength' => 0.0, 'description' => 'Pineapple fruit', 'user_id' => 1]); - Ingredient::create(['name' => 'Apple', 'ingredient_category_id' => $fruits->id, 'strength' => 0.0, 'description' => 'Apple fruit', 'user_id' => 1]); - Ingredient::create(['name' => 'Peach', 'ingredient_category_id' => $fruits->id, 'strength' => 0.0, 'description' => 'Peach fruit', 'user_id' => 1]); - Ingredient::create(['name' => 'Mint', 'ingredient_category_id' => $fruits->id, 'strength' => 0.0, 'description' => 'Mint/mentha leaves.', 'user_id' => 1]); - Ingredient::create(['name' => 'Ginger', 'ingredient_category_id' => $fruits->id, 'strength' => 0.0, 'description' => 'Ginger root used as a spice', 'user_id' => 1]); - Ingredient::create(['name' => 'Chilli Pepper', 'ingredient_category_id' => $fruits->id, 'strength' => 0.0, 'description' => 'Hot pepper', 'user_id' => 1]); - - // Misc - Ingredient::create(['name' => 'White Peach Puree', 'ingredient_category_id' => $uncategorized->id, 'strength' => 0.0, 'description' => 'A purée (or mash) is cooked food, usually vegetables, fruits or legumes, that has been ground, pressed, blended or sieved to the consistency of a creamy paste or liquid.', 'user_id' => 1]); - Ingredient::create(['name' => 'Cream', 'ingredient_category_id' => $uncategorized->id, 'strength' => 0.0, 'description' => 'Cream is a dairy product composed of the higher-fat layer skimmed from the top of milk before homogenization.', 'user_id' => 1]); - $salt = Ingredient::create(['name' => 'Salt', 'ingredient_category_id' => $uncategorized->id, 'strength' => 0.0, 'description' => 'Salt', 'user_id' => 1]); - $pepper = Ingredient::create(['name' => 'Pepper', 'ingredient_category_id' => $uncategorized->id, 'strength' => 0.0, 'description' => 'Black pepper', 'user_id' => 1]); - Ingredient::create(['name' => 'Tabasco', 'ingredient_category_id' => $uncategorized->id, 'strength' => 0.0, 'description' => 'Hot sauce made from vinegar, tabasco peppers, and salt.', 'user_id' => 1]); - Ingredient::create(['name' => 'Worcestershire Sauce', 'ingredient_category_id' => $uncategorized->id, 'strength' => 0.0, 'description' => 'Fermented liquid condiment created in the city of Worcester', 'user_id' => 1]); - $sugar = Ingredient::create(['name' => 'Sugar', 'ingredient_category_id' => $uncategorized->id, 'strength' => 0.0, 'description' => 'White sugar', 'user_id' => 1]); - $eggWhite = Ingredient::create(['name' => 'Egg White', 'ingredient_category_id' => $uncategorized->id, 'strength' => 0.0, 'description' => 'Chicken egg without yolk.', 'user_id' => 1]); - $eggYolk = Ingredient::create(['name' => 'Egg Yolk', 'ingredient_category_id' => $uncategorized->id, 'strength' => 0.0, 'description' => 'Yolk from chicken egg', 'user_id' => 1]); - Ingredient::create(['name' => 'Coconut Cream', 'ingredient_category_id' => $uncategorized->id, 'strength' => 0.0, 'description' => 'Opaque, milky-white liquid extracted from the grated pulp of mature coconuts.', 'user_id' => 1]); - Ingredient::create(['name' => 'Vanilla Extract', 'ingredient_category_id' => $uncategorized->id, 'strength' => 0.0, 'description' => 'Solution made by macerating and percolating vanilla pods in a solution of ethanol and water.', 'user_id' => 1]); - - // Bitters - Ingredient::create(['name' => 'Orange bitters', 'ingredient_category_id' => $bitters->id, 'strength' => 28.0, 'color' => '#ed8300', 'description' => 'Orange bitters is a form of bitters, a cocktail flavoring made from such ingredients as the peels of Seville oranges, cardamom, caraway seed, coriander, anise, and burnt sugar in an alcohol base.', 'origin' => 'Worldwide', 'user_id' => 1]); - $ango = Ingredient::create(['name' => 'Angostura aromatic bitters', 'ingredient_category_id' => $bitters->id, 'strength' => 44.7, 'color' => '#e95310', 'description' => 'Angostura bitters is a concentrated bitters (herbal alcoholic preparation) based on gentian, herbs, and spices, by House of Angostura in Trinidad and Tobago.', 'origin' => 'Trinidad & Tobago', 'user_id' => 1]); - Ingredient::create(['name' => 'Peach bitters', 'ingredient_category_id' => $bitters->id, 'strength' => 35.0, 'color' => '#ca7c00', 'description' => 'Peach bitters flavored with peaches and herbs.', 'origin' => 'Worldwide', 'user_id' => 1]); - Ingredient::create(['name' => 'Angostura cocoa bitters', 'ingredient_category_id' => $bitters->id, 'strength' => 38.0, 'color' => '#894c36', 'description' => 'Top notes of rich bitter, floral, nutty cocoa with a bold infusion of aromatic botanicals provide endless possibilities to remix classic cocktails.', 'origin' => 'Trinidad & Tobago', 'user_id' => 1]); - Ingredient::create(['name' => 'Peychauds Bitters', 'ingredient_category_id' => $bitters->id, 'strength' => 35.0, 'color' => '#622426', 'description' => 'It is a gentian-based bitters, comparable to Angostura bitters, but with a predominant anise aroma combined with a background of mint.', 'origin' => 'North America', 'user_id' => 1]); - - // Liqueurs - $campari = Ingredient::create(['name' => 'Campari', 'ingredient_category_id' => $liqueurs->id, 'strength' => 25.0, 'description' => 'Italian alcoholic liqueur obtained from the infusion of herbs and fruit.', 'color' => '#ca101e', 'origin' => 'Italy', 'user_id' => 1]); - Ingredient::create(['name' => 'Aperol', 'ingredient_category_id' => $liqueurs->id, 'strength' => 11.0, 'description' => 'Italian bitter apéritif made of gentian, rhubarb and cinchona, among other ingredients.', 'color' => '#fa4321', 'origin' => 'Italy', 'user_id' => 1]); - $kahlua = Ingredient::create(['name' => 'Kahlua coffee liqueur', 'ingredient_category_id' => $liqueurs->id, 'strength' => 20.0, 'description' => 'Coffee liqueur made with rum, sugar and arabica coffee.', 'color' => '#1a0d0a', 'origin' => 'Mexico', 'user_id' => 1]); - Ingredient::create(['name' => 'Amaretto', 'ingredient_category_id' => $liqueurs->id, 'strength' => 24.0, 'description' => 'Sweet almond-flavored liqueur', 'color' => '#d62b0e', 'origin' => 'Italy', 'user_id' => 1]); - Ingredient::create(['name' => 'Dark Crème de Cacao', 'ingredient_category_id' => $liqueurs->id, 'strength' => 25.0, 'description' => 'Dark brown creamy chocolate-flavored liqueur made from cacao seed.', 'color' => '#0b0504', 'origin' => 'France', 'user_id' => 1]); - Ingredient::create(['name' => 'White Crème de Cacao', 'ingredient_category_id' => $liqueurs->id, 'strength' => 25.0, 'description' => 'Milk chocolate flavored liqueur with a hint of vanilla.', 'color' => '#ffffff', 'origin' => 'France', 'user_id' => 1]); - Ingredient::create(['name' => 'Menthe Crème de Cacao', 'ingredient_category_id' => $liqueurs->id, 'strength' => 25.0, 'description' => 'Mint flavored chocolate liqueur.', 'color' => '#88ad91', 'origin' => 'France', 'user_id' => 1]); - Ingredient::create(['name' => 'Crème de cassis (blackcurrant liqueur)', 'ingredient_category_id' => $liqueurs->id, 'strength' => 25.0, 'description' => 'It is made from blackcurrants that are crushed and soaked in alcohol, with sugar subsequently added.', 'color' => '#282722', 'origin' => 'France', 'user_id' => 1]); - Ingredient::create(['name' => 'Crème de Violette', 'ingredient_category_id' => $liqueurs->id, 'strength' => 16.0, 'description' => 'Crème de violette is a delicate, barely-sweet liqueur made from violet flower petals.', 'color' => '#a5a2fd', 'origin' => 'Worldwide', 'user_id' => 1]); - Ingredient::create(['name' => 'Crème de mûre (blackberry liqueur)', 'ingredient_category_id' => $liqueurs->id, 'strength' => 42.3, 'description' => 'Crème de mûre is a liqueur made with fresh blackberries.', 'color' => '#5f1933', 'origin' => 'France', 'user_id' => 1]); - Ingredient::create(['name' => 'Grand Marnier', 'ingredient_category_id' => $liqueurs->id, 'strength' => 40.0, 'description' => 'Orange-flavored liqueur made from a blend of Cognac brandy, distilled essence of bitter orange, and sugar.', 'color' => '#f34e02', 'origin' => 'France', 'user_id' => 1]); - Ingredient::create(['name' => 'Suze', 'ingredient_category_id' => $liqueurs->id, 'strength' => 15.0, 'description' => 'Bitter flavored drink made with the roots of the plant gentian.', 'color' => '#ffffff', 'origin' => 'Switzerland', 'user_id' => 1]); - Ingredient::create(['name' => 'Maraschino', 'ingredient_category_id' => $liqueurs->id, 'strength' => 32.0, 'description' => 'Liqueur obtained from the distillation of Marasca cherries. The small, slightly sour fruit of the Tapiwa cherry tree, which grows wild along parts of the Dalmatian coast in Croatia, lends the liqueur its unique aroma.', 'color' => '#ffffff', 'origin' => 'Croatia', 'user_id' => 1]); - Ingredient::create(['name' => 'Galliano', 'ingredient_category_id' => $liqueurs->id, 'strength' => 42.3, 'description' => 'Galliano is sweet with vanilla-anise flavour and subtle citrus and woodsy herbal undernotes.', 'color' => '#caa701', 'origin' => 'Italy', 'user_id' => 1]); - Ingredient::create(['name' => 'Chambord', 'ingredient_category_id' => $liqueurs->id, 'strength' => 16.5, 'description' => 'Raspberry liqueur modelled after a liqueur produced in the Loire Valley of France during the late 17th century.', 'color' => '#6f1123', 'origin' => 'Worldwide', 'user_id' => 1]); - Ingredient::create(['name' => 'Falernum', 'ingredient_category_id' => $liqueurs->id, 'strength' => 11.0, 'description' => 'Liqueur with flavors of ginger, lime, and almond, and frequently cloves or allspice. It may be thought of as a spicier version of orgeat syrup.', 'color' => '#f4f2e5', 'origin' => 'Caribbean', 'user_id' => 1]); - $gChar = Ingredient::create(['name' => 'Green Chartreuse', 'ingredient_category_id' => $liqueurs->id, 'strength' => 55.0, 'description' => 'Green Chartreuse is a naturally green liqueur made from 130 herbs and other plants macerated in alcohol and steeped for about eight hours.', 'color' => '#85993a', 'origin' => 'France', 'user_id' => 1]); - Ingredient::create(['name' => 'Yellow Chartreuse', 'ingredient_category_id' => $liqueurs->id, 'strength' => 40.0, 'description' => 'Yellow Chartreuse has a milder and sweeter flavor and aroma than Green Chartreuse, and is lower in alcohol content.', 'color' => '#fbfb4b', 'origin' => 'France', 'user_id' => 1]); - Ingredient::create(['name' => 'Amaro Nonino', 'ingredient_category_id' => $liqueurs->id, 'strength' => 35.0, 'description' => 'Sweet amaro', 'color' => '#c16e4b', 'origin' => 'Italy', 'user_id' => 1]); - Ingredient::create(['name' => 'Drambuie', 'ingredient_category_id' => $liqueurs->id, 'strength' => 40.0, 'description' => 'Liqueur made from Scotch whisky, heather honey, herbs and spices.', 'color' => '#ea7e00', 'origin' => 'Scotland', 'user_id' => 1]); - Ingredient::create(['name' => 'Bénédictine', 'ingredient_category_id' => $liqueurs->id, 'strength' => 40.0, 'description' => 'Herbal liqueur flavored with twenty-seven flowers, berries, herbs, roots, and spices.', 'color' => '#f39100', 'origin' => 'France', 'user_id' => 1]); - Ingredient::create(['name' => 'Pernod', 'ingredient_category_id' => $liqueurs->id, 'strength' => 40.0, 'description' => 'Anise flavored liqueur', 'color' => '#c6c0a0', 'origin' => 'France', 'user_id' => 1]); - Ingredient::create(['name' => 'Pelinkovac', 'ingredient_category_id' => $liqueurs->id, 'strength' => 32.0, 'description' => 'Pelinkovac is a liqueur based on wormwood, it has a very bitter taste, resembling that of Jägermeister.', 'color' => '#573f42', 'origin' => 'Southeast Europe', 'user_id' => 1]); - Ingredient::create(['name' => 'Ouzo', 'ingredient_category_id' => $liqueurs->id, 'strength' => 35.0, 'description' => 'Dry anise-flavored aperitif that is widely consumed in Greece.', 'color' => '#ffffff', 'origin' => 'Greece', 'user_id' => 1]); - Ingredient::create(['name' => 'Passoã', 'ingredient_category_id' => $liqueurs->id, 'strength' => 17.0, 'description' => 'Liqueur with passion fruit being the main ingredient.', 'color' => '#ea5f4c', 'origin' => 'France', 'user_id' => 1]); - Ingredient::create(['name' => 'Fernet Branca', 'ingredient_category_id' => $liqueurs->id, 'strength' => 39.0, 'description' => 'Fernet Branca is a bittersweet, herbal liqueur made with a number of different herbs and spices, including myrrh, rhubarb, chamomile, cardamom, aloe, and gentian root.', 'origin' => 'Italy', 'user_id' => 1]); - Ingredient::create(['name' => 'Baileys Irish Cream', 'ingredient_category_id' => $liqueurs->id, 'strength' => 17.0, 'description' => 'Baileys Irish Cream is an Irish cream liqueur, an alcoholic drink flavoured with cream, cocoa and Irish whiskey. It is made by Diageo at Nangor Road, in Dublin, Ireland and in Mallusk, Northern Ireland. It is the original Irish cream, invented by a team headed by Tom Jago in 1971 for Gilbeys of Ireland.', 'origin' => 'Ireland', 'user_id' => 1]); - Ingredient::create(['name' => 'St-Germain', 'ingredient_category_id' => $liqueurs->id, 'strength' => 20.0, 'description' => 'St-Germain is an elderflower liqueur It is made using the petals of Sambucus nigra from the Savoie region in France, and each bottle is numbered with the year the petals were collected. Petals are collected annually in the spring over a period of three to four weeks, and are often transported by bicycle to collection points to avoid damaging the petals and impacting the flavour.', 'color' => '#f8e888', 'origin' => 'France', 'user_id' => 1]); - - $curacao = Ingredient::create(['name' => 'Orange Curaçao', 'ingredient_category_id' => $liqueurs->id, 'strength' => 20.0, 'description' => 'Liqueur flavored with the dried peel of the bitter orange laraha, a citrus fruit grown on the Dutch island of Curaçao. Curaçao is used by liqueur makers overt the world as a generic name for orange-flavoured liqueurs.', 'color' => '#edaa53', 'origin' => 'Netherlands', 'user_id' => 1]); - Ingredient::create(['name' => 'Dry Curaçao', 'parent_ingredient_id' => $curacao->id, 'ingredient_category_id' => $liqueurs->id, 'strength' => 40.0, 'color' => '#ffc613', 'description' => 'While Curaçao and sweet oranges are the main ingredients, vanilla, prunes and lemon peel are amongst the other botanicals called for in the old recipe.', 'origin' => 'Italy', 'user_id' => 1]); - Ingredient::create(['name' => 'Blue Curaçao', 'parent_ingredient_id' => $curacao->id, 'ingredient_category_id' => $liqueurs->id, 'strength' => 20.0, 'description' => 'Curaçao with added blue dye.', 'color' => '#0192fe', 'origin' => 'Netherlands', 'user_id' => 1]); - $cointreau = Ingredient::create(['name' => 'Cointreau', 'parent_ingredient_id' => $curacao->id, 'ingredient_category_id' => $liqueurs->id, 'strength' => 40.0, 'description' => 'Orange-flavoured triple sec liqueur, it was originally called Curaçao Blanco Triple Sec. Usually more dry tasting than Orange Curaçao.', 'color' => '#ffffff', 'origin' => 'France', 'user_id' => 1]); - Ingredient::create(['name' => 'Triple Sec', 'parent_ingredient_id' => $curacao->id, 'ingredient_category_id' => $liqueurs->id, 'strength' => 40.0, 'description' => 'Triple sec is usually made from orange peels steeped in a spirit derived from sugar beet due to its neutral flavor.', 'color' => '#ffffff', 'origin' => 'France', 'user_id' => 1]); - - // Juices - $lemonJuice = Ingredient::create(['name' => 'Lemon juice', 'ingredient_category_id' => $juices->id, 'strength' => 0.0, 'description' => 'Freshly squeezed lemon juice.', 'color' => '#f3efda', 'user_id' => 1]); - $limeJuice = Ingredient::create(['name' => 'Lime juice', 'ingredient_category_id' => $juices->id, 'strength' => 0.0, 'description' => 'Freshly squeezed lime juice.', 'color' => '#e9f1d7', 'user_id' => 1]); - Ingredient::create(['name' => 'Orange juice', 'ingredient_category_id' => $juices->id, 'strength' => 0.0, 'description' => 'Freshly squeezed orange juice.', 'color' => '#ff9518', 'user_id' => 1]); - Ingredient::create(['name' => 'Grapefruit juice', 'ingredient_category_id' => $juices->id, 'strength' => 0.0, 'description' => 'Freshly squeezed grapefruit juice.', 'color' => '#ed7500', 'user_id' => 1]); - Ingredient::create(['name' => 'Cranberry juice', 'ingredient_category_id' => $juices->id, 'strength' => 0.0, 'description' => 'Juice made from cranberries.', 'color' => '#9c0024', 'user_id' => 1]); - Ingredient::create(['name' => 'Tomato juice', 'ingredient_category_id' => $juices->id, 'strength' => 0.0, 'description' => 'Juice made from tomatoes.', 'color' => '#f16624', 'user_id' => 1]); - Ingredient::create(['name' => 'Pineapple juice', 'ingredient_category_id' => $juices->id, 'strength' => 0.0, 'description' => 'Juice from pineapple fruit.', 'color' => '#eadb34', 'user_id' => 1]); - Ingredient::create(['name' => 'Elderflower Cordial', 'ingredient_category_id' => $juices->id, 'strength' => 0.0, 'description' => 'Herbal juice made from elderflower.', 'color' => '#d9cfae', 'user_id' => 1]); - Ingredient::create(['name' => 'Chamomile cordial', 'ingredient_category_id' => $juices->id, 'strength' => 0.0, 'description' => 'Herbal juice made from chamomile.', 'color' => '#e2dccc', 'user_id' => 1]); - - // Beverages - $water = Ingredient::create(['name' => 'Water', 'ingredient_category_id' => $beverages->id, 'strength' => 0.0, 'color' => '#fff', 'description' => 'It\'s water.', 'origin' => 'Worldwide', 'user_id' => 1]); - $clubSoda = Ingredient::create(['name' => 'Club soda', 'ingredient_category_id' => $beverages->id, 'strength' => 0.0, 'color' => '#fff', 'description' => 'Club soda is a manufactured form of carbonated water, commonly used as a drink mixer.', 'origin' => 'Worldwide', 'user_id' => 1]); - $tonic = Ingredient::create(['name' => 'Tonic', 'ingredient_category_id' => $beverages->id, 'strength' => 0.0, 'color' => '#fff', 'description' => 'Tonic water (or Indian tonic water) is a carbonated soft drink in which quinine is dissolved.', 'origin' => 'Worldwide', 'user_id' => 1]); - $cola = Ingredient::create(['name' => 'Cola', 'ingredient_category_id' => $beverages->id, 'strength' => 0.0, 'color' => '#411919', 'description' => 'Cola is a carbonated soft drink flavored with vanilla, cinnamon, citrus oils and other flavorings.', 'origin' => 'Worldwide', 'user_id' => 1]); - Ingredient::create(['name' => 'Ginger beer', 'ingredient_category_id' => $beverages->id, 'strength' => 0.0, 'color' => '#fff', 'description' => 'Ginger beer is a sweetened and carbonated, usually non-alcoholic beverage.', 'origin' => 'Worldwide', 'user_id' => 1]); - Ingredient::create(['name' => 'Espresso', 'ingredient_category_id' => $beverages->id, 'strength' => 0.0, 'color' => '#fff', 'description' => 'Espresso is generally thicker than coffee brewed by other methods, with a viscosity similar to that of warm honey.', 'origin' => 'Italy', 'user_id' => 1]); - $coffee = Ingredient::create(['name' => 'Coffee', 'ingredient_category_id' => $beverages->id, 'strength' => 0.0, 'color' => '#fff', 'description' => 'Coffee is a drink prepared from roasted coffee beans.', 'origin' => 'Africa', 'user_id' => 1]); - Ingredient::create(['name' => 'Orange Flower Water', 'ingredient_category_id' => $beverages->id, 'strength' => 0.0, 'color' => '#fff', 'description' => 'Clear aromatic by-product of the distillation of fresh bitter-orange blossoms for their essential oil.', 'origin' => 'Mediterranean', 'user_id' => 1]); - - // Spirits - $gin = Ingredient::create(['name' => 'Gin', 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#ffffff', 'description' => 'Distilled alcoholic drink that derives its flavour from juniper berries.', 'origin' => 'Worldwide', 'user_id' => 1]); - Ingredient::create(['name' => 'Sloe Gin', 'parent_ingredient_id' => $gin->id, 'ingredient_category_id' => $liqueurs->id, 'strength' => 40.0, 'color' => '#d74536', 'description' => 'Sloe gin is a red liqueur made with gin and sloes. Sloes are the fruit (drupe) of Prunus spinosa, the blackthorn plant, a relative of the plum.', 'origin' => 'UK', 'user_id' => 1]); - Ingredient::create(['name' => 'Old Tom Gin', 'parent_ingredient_id' => $gin->id, 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#ffffff', 'description' => ' It is slightly sweeter than London Dry, but slightly drier than the Dutch Jenever, thus is sometimes called "the missing link".', 'origin' => 'UK', 'user_id' => 1]); - - $vodka = Ingredient::create(['name' => 'Vodka', 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#ffffff', 'description' => 'Clear alcoholic beverage distilled from cereal grains and potatos.', 'origin' => 'Russia', 'user_id' => 1]); - Ingredient::create(['name' => 'Vanilla Vodka', 'parent_ingredient_id' => $vodka->id, 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#ffffff', 'description' => 'Vodka with added vanilla essence.', 'origin' => 'Russia', 'user_id' => 1]); - Ingredient::create(['name' => 'Vodka Citron', 'parent_ingredient_id' => $vodka->id, 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#ffffff', 'description' => 'Vodka with added lemon essence.', 'origin' => 'Sweden', 'user_id' => 1]); - - $whiskey = Ingredient::create(['name' => 'Whiskey', 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#d54a06', 'description' => 'Distilled alcoholic beverage made from fermented grain mash.', 'origin' => 'Worldwide', 'user_id' => 1]); - Ingredient::create(['name' => 'Bourbon Whiskey', 'parent_ingredient_id' => $whiskey->id, 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#d54a06', 'description' => 'Barrel-aged distilled liquor made primarily from corn.', 'origin' => 'North America', 'user_id' => 1]); - Ingredient::create(['name' => 'Rye whiskey', 'parent_ingredient_id' => $whiskey->id, 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#d54a06', 'description' => 'Whiskey made with at least 51 percent rye grain.', 'origin' => 'North America', 'user_id' => 1]); - Ingredient::create(['name' => 'Scotch whiskey', 'parent_ingredient_id' => $whiskey->id, 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#d54a06', 'description' => 'Malt whisky or grain whisky (or a blend of the two), made in Scotland.', 'origin' => 'Scotland', 'user_id' => 1]); - Ingredient::create(['name' => 'Islay Scotch', 'parent_ingredient_id' => $whiskey->id, 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#d54a06', 'description' => 'Scotch whisky made on Islay island.', 'origin' => 'Scotland', 'user_id' => 1]); - Ingredient::create(['name' => 'Irish whiskey', 'parent_ingredient_id' => $whiskey->id, 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#d54a06', 'description' => 'Whiskey made on the island of Ireland.', 'origin' => 'Ireland', 'user_id' => 1]); - - $brandy = Ingredient::create(['name' => 'Brandy', 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#e66500', 'description' => 'Liquor produced by distilling wine.', 'origin' => 'Worldwide', 'user_id' => 1]); - Ingredient::create(['name' => 'Cognac', 'parent_ingredient_id' => $brandy->id, 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#7b1c0a', 'description' => 'A variety of brandy named after the commune of Cognac, France.', 'origin' => 'France', 'user_id' => 1]); - Ingredient::create(['name' => 'Apricot Brandy', 'parent_ingredient_id' => $brandy->id, 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#ca5210', 'description' => 'Liquor distilled from fermented apricot juice or a liqueur made from apricot flesh and kernels.', 'origin' => 'Worldwide', 'user_id' => 1]); - Ingredient::create(['name' => 'Calvados', 'parent_ingredient_id' => $brandy->id, 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#ca5210', 'description' => 'Brandy made from apples or pears.', 'origin' => 'France', 'user_id' => 1]); - - $tequila = Ingredient::create(['name' => 'Tequila', 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#ffffff', 'description' => 'Distilled beverage made from the blue agave plant.', 'origin' => 'Mexico', 'user_id' => 1]); - Ingredient::create(['name' => 'Mezcal', 'parent_ingredient_id' => $tequila->id, 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#ffffff', 'description' => 'Distilled alcoholic beverage made from any type of agave.', 'origin' => 'Mexico', 'user_id' => 1]); - Ingredient::create(['name' => 'Tequila Reposado', 'parent_ingredient_id' => $tequila->id, 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#d8cca6', 'description' => 'Tequila aged a minimum of two months, but less than a year in oak barrels of any size.', 'origin' => 'Mexico', 'user_id' => 1]); - Ingredient::create(['name' => 'Tequila Añejo', 'parent_ingredient_id' => $tequila->id, 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#f5d58a', 'description' => 'Tequila aged a minimum of one year, but less than three years in small oak barrels.', 'origin' => 'Mexico', 'user_id' => 1]); - Ingredient::create(['name' => 'Tequila Extra Añejo', 'parent_ingredient_id' => $tequila->id, 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#e8a934', 'description' => 'Tequila aged a minimum of three years in oak barrels.', 'origin' => 'Mexico', 'user_id' => 1]); - - $rum = Ingredient::create(['name' => 'White Rum', 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#ffffff', 'description' => 'Liquor made by fermenting and then distilling sugarcane molasses or sugarcane juice.', 'origin' => 'Caribbean', 'user_id' => 1]); - Ingredient::create(['name' => 'Gold Rum', 'parent_ingredient_id' => $rum->id, 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#c79141', 'description' => 'Medium-bodied rum aged in wooden barrels.', 'user_id' => 1]); - Ingredient::create(['name' => 'Demerara Rum', 'parent_ingredient_id' => $rum->id, 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#ca5210', 'description' => 'Rum made with demerara sugar', 'origin' => 'Caribbean', 'user_id' => 1]); - Ingredient::create(['name' => 'Dark Rum', 'parent_ingredient_id' => $rum->id, 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#ca5210', 'description' => 'Rum made from caramelized sugar or molasses.', 'origin' => 'Caribbean', 'user_id' => 1]); - Ingredient::create(['name' => 'Jamaican Rum', 'parent_ingredient_id' => $rum->id, 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#ca5210', 'description' => 'Rum made in Jamaica.', 'origin' => 'Jamaica', 'user_id' => 1]); - Ingredient::create(['name' => 'Rhum agricole', 'parent_ingredient_id' => $rum->id, 'ingredient_category_id' => $spirits->id, 'strength' => 50.0, 'color' => '#ffffff', 'description' => 'Rum distilled from freshly squeezed sugarcane juice rather than molasses.', 'origin' => 'Caribbean', 'user_id' => 1]); - Ingredient::create(['name' => 'Overproof Rum', 'parent_ingredient_id' => $rum->id, 'ingredient_category_id' => $spirits->id, 'strength' => 50.0, 'color' => '#5d201a', 'description' => 'Rum much higher than the standard 40% ABV (80 proof), with many as high as 75% (150 proof) to 80% (160 proof) available.', 'origin' => 'Caribbean', 'user_id' => 1]); - - Ingredient::create(['name' => 'Cachaça', 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#ffffff', 'description' => 'Distilled spirit made from fermented sugarcane juice.', 'origin' => 'Brazil', 'user_id' => 1]); - Ingredient::create(['name' => 'Pisco', 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#ffffff', 'description' => 'Made by distilling fermented grape juice into a high-proof spirit.', 'origin' => 'South America', 'user_id' => 1]); - Ingredient::create(['name' => 'Peach Schnapps', 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#ffffff', 'description' => 'Schnapps made from peaches.', 'origin' => 'Worldwide', 'user_id' => 1]); - Ingredient::create(['name' => 'Grappa', 'ingredient_category_id' => $spirits->id, 'strength' => 50.0, 'color' => '#ffffff', 'description' => 'Fragrant, grape-based pomace brandy.', 'origin' => 'Italy', 'user_id' => 1]); - Ingredient::create(['name' => 'Absinthe', 'ingredient_category_id' => $spirits->id, 'strength' => 40.0, 'color' => '#b7ca8e', 'description' => 'Anise-flavoured spirit derived from several plants, including wormwood.', 'origin' => 'France', 'user_id' => 1]); - - // Syrups - $simpleSyrup = Ingredient::create(['name' => 'Simple Syrup', 'ingredient_category_id' => $syrups->id, 'description' => 'Syrup made with sugar and water. Usually in 1:1 or 2:1 ratio.', 'color' => '#e6dfcc', 'user_id' => 1]); - Ingredient::create(['name' => 'Gomme Syrup', 'ingredient_category_id' => $syrups->id, 'description' => 'A thicker simple syrup mixed with arabica gum powder.', 'color' => '#e6dfcc', 'user_id' => 1]); - Ingredient::create(['name' => 'Orgeat Syrup', 'ingredient_category_id' => $syrups->id, 'description' => 'Sweet syrup made from almonds, sugar, and rose water or orange flower water.', 'color' => '#d9ca9f', 'user_id' => 1]); - Ingredient::create(['name' => 'Honey Syrup', 'ingredient_category_id' => $syrups->id, 'description' => 'Syrup made from dissolving honey in water.', 'color' => '#f2a900', 'user_id' => 1]); - Ingredient::create(['name' => 'Raspberry Syrup', 'ingredient_category_id' => $syrups->id, 'description' => 'Fruit syrup made from raspberries.', 'color' => '#b71f23', 'user_id' => 1]); - Ingredient::create(['name' => 'Grenadine Syrup', 'ingredient_category_id' => $syrups->id, 'description' => 'Fruit syrup made from pomegranates.', 'color' => '#bb0014', 'user_id' => 1]); - Ingredient::create(['name' => 'Agave Syrup', 'ingredient_category_id' => $syrups->id, 'description' => 'Syrup made from agave.', 'color' => '#deca3f', 'user_id' => 1]); - Ingredient::create(['name' => 'Donn\'s Mix', 'ingredient_category_id' => $syrups->id, 'description' => '2 parts fresh yellow grapefruit and 1 part cinnamon syrup', 'color' => '#c6972c', 'user_id' => 1]); - Ingredient::create(['name' => 'Oleo Saccharum', 'ingredient_category_id' => $syrups->id, 'description' => 'Oil extracted from citrus peels by using sugar.', 'color' => '#c6972c', 'user_id' => 1]); - Ingredient::create(['name' => 'Ginger syrup', 'ingredient_category_id' => $syrups->id, 'description' => 'Syrup made from ginger root.', 'color' => '#c6972c', 'user_id' => 1]); - - // Wines - $sweetVer = Ingredient::create(['name' => 'Sweet Vermouth', 'ingredient_category_id' => $wines->id, 'strength' => 18.0, 'description' => 'Aromatized fortified wine.', 'color' => '#8e4201', 'user_id' => 1, 'origin' => 'Worldwide']); - $dryVer = Ingredient::create(['name' => 'Dry Vermouth', 'ingredient_category_id' => $wines->id, 'strength' => 18.0, 'description' => 'Aromatized fortified wine.', 'color' => '#ffffff', 'user_id' => 1, 'origin' => 'Worldwide']); - $wWine = Ingredient::create(['name' => 'White wine', 'ingredient_category_id' => $wines->id, 'strength' => 11.0, 'description' => 'Wine is an alcoholic drink typically made from fermented grapes.', 'color' => '#f6e1b0', 'user_id' => 1, 'origin' => 'Worldwide']); - $rWine = Ingredient::create(['name' => 'Red wine', 'ingredient_category_id' => $wines->id, 'strength' => 11.0, 'description' => 'Red wine is a type of wine made from dark-colored grape varieties.', 'color' => '#801212', 'user_id' => 1, 'origin' => 'Worldwide']); - $prosecco = Ingredient::create(['name' => 'Prosecco', 'ingredient_category_id' => $wines->id, 'strength' => 11.0, 'description' => 'Sparkling wine made from Prosecco grapes.', 'color' => '#a57600', 'user_id' => 1, 'origin' => 'Italy']); - Ingredient::create(['name' => 'Champagne', 'ingredient_category_id' => $wines->id, 'strength' => 12.0, 'description' => 'Sparkling wine.', 'color' => '#f6e1b0', 'user_id' => 1, 'origin' => 'France']); - Ingredient::create(['name' => 'Lillet Blanc', 'ingredient_category_id' => $wines->id, 'strength' => 17.0, 'description' => 'Aromatized sweet wine.', 'color' => '#f7ec77', 'user_id' => 1, 'origin' => 'France']); - Ingredient::create(['name' => 'Dry Sherry', 'ingredient_category_id' => $wines->id, 'strength' => 17.0, 'description' => 'Fortified wine made from white grapes that are grown near the city of Jerez de la Frontera in Andalusia, Spain.', 'color' => '#8c4122', 'user_id' => 1, 'origin' => 'Spain']); - - $this->info('Copying images...'); - - $baDisk = Storage::disk('bar-assistant'); - $baDisk->makeDirectory('cocktails'); - $baDisk->makeDirectory('ingredients'); - $baDisk->makeDirectory('temp'); - - foreach (glob(resource_path('data/cocktails/*')) as $pathFrom) { - copy($pathFrom, $baDisk->path('cocktails/' . basename($pathFrom))); - } - - foreach (glob(resource_path('data/ingredients/*')) as $pathFrom) { - copy($pathFrom, $baDisk->path('ingredients/' . basename($pathFrom))); - } - - $this->info('Attaching images to ingredients...'); - - // Create image for every ingredient - $ingredients = Ingredient::all(); - foreach ($ingredients as $ing) { - $filepath = 'ingredients/' . Str::slug($ing->name) . '.png'; - - if (Storage::disk('bar-assistant')->missing($filepath)) { - continue; - } - - $image = new Image(); - $image->file_path = $filepath; - $image->file_extension = 'png'; - $image->copyright = null; - $image->user_id = 1; - $ing->images()->save($image); - - // Update site search index - $ing->refresh(); - $ing->save(); - } - - $this->info('Importing cocktail recipes...'); - - $this->importCocktailsFromJson(resource_path('/data/iba_cocktails.yml')); - $this->importCocktailsFromJson(resource_path('/data/popular_cocktails.yml')); - - Artisan::call('scout:import', ['model' => "Kami\Cocktail\Models\Cocktail"]); - Artisan::call('scout:import', ['model' => "Kami\Cocktail\Models\Ingredient"]); - - $this->info('Selecting standard ingredients...'); - $defaultUser = \Kami\Cocktail\Models\User::find(2); - $defaultUserIngredients = [ - $limeFruit, - $lemonFruit, - $salt, - $pepper, - $sugar, - $eggWhite, - $eggYolk, - $lemonJuice, - $limeJuice, - $water, - $clubSoda, - $cola, - $coffee, - $simpleSyrup, - ]; - - foreach ($defaultUserIngredients as $dui) { - $defaultUser->shelfIngredients()->save( - new \Kami\Cocktail\Models\UserIngredient([ - 'ingredient_id' => $dui->id - ]) - ); - } - - if (App::environment('demo')) { - $this->info('Adding demo data...'); - $defaultUser->favorites()->saveMany([ - new \Kami\Cocktail\Models\CocktailFavorite(['cocktail_id' => 32]), - new \Kami\Cocktail\Models\CocktailFavorite(['cocktail_id' => 18]), - new \Kami\Cocktail\Models\CocktailFavorite(['cocktail_id' => 5]), - new \Kami\Cocktail\Models\CocktailFavorite(['cocktail_id' => 73]), - new \Kami\Cocktail\Models\CocktailFavorite(['cocktail_id' => 11]), - new \Kami\Cocktail\Models\CocktailFavorite(['cocktail_id' => 22]), - new \Kami\Cocktail\Models\CocktailFavorite(['cocktail_id' => 25]), - ]); - $defaultUser->shoppingList()->saveMany([ - new \Kami\Cocktail\Models\UserShoppingList(['ingredient_id' => $ango->id]), - new \Kami\Cocktail\Models\UserShoppingList(['ingredient_id' => $campari->id]), - new \Kami\Cocktail\Models\UserShoppingList(['ingredient_id' => $kahlua->id]), - new \Kami\Cocktail\Models\UserShoppingList(['ingredient_id' => $gChar->id]), - new \Kami\Cocktail\Models\UserShoppingList(['ingredient_id' => $cointreau->id]), - new \Kami\Cocktail\Models\UserShoppingList(['ingredient_id' => $gin->id]), - new \Kami\Cocktail\Models\UserShoppingList(['ingredient_id' => $vodka->id]), - new \Kami\Cocktail\Models\UserShoppingList(['ingredient_id' => $whiskey->id]), - new \Kami\Cocktail\Models\UserShoppingList(['ingredient_id' => $brandy->id]), - ]); - $defaultUser->shelfIngredients()->saveMany([ - new \Kami\Cocktail\Models\UserIngredient(['ingredient_id' => $ango->id]), - new \Kami\Cocktail\Models\UserIngredient(['ingredient_id' => $campari->id]), - new \Kami\Cocktail\Models\UserIngredient(['ingredient_id' => $kahlua->id]), - new \Kami\Cocktail\Models\UserIngredient(['ingredient_id' => $gChar->id]), - new \Kami\Cocktail\Models\UserIngredient(['ingredient_id' => $cointreau->id]), - new \Kami\Cocktail\Models\UserIngredient(['ingredient_id' => $gin->id]), - new \Kami\Cocktail\Models\UserIngredient(['ingredient_id' => $vodka->id]), - new \Kami\Cocktail\Models\UserIngredient(['ingredient_id' => $whiskey->id]), - new \Kami\Cocktail\Models\UserIngredient(['ingredient_id' => $brandy->id]), - new \Kami\Cocktail\Models\UserIngredient(['ingredient_id' => $tequila->id]), - new \Kami\Cocktail\Models\UserIngredient(['ingredient_id' => $rum->id]), - new \Kami\Cocktail\Models\UserIngredient(['ingredient_id' => $sweetVer->id]), - new \Kami\Cocktail\Models\UserIngredient(['ingredient_id' => $dryVer->id]), - new \Kami\Cocktail\Models\UserIngredient(['ingredient_id' => $wWine->id]), - new \Kami\Cocktail\Models\UserIngredient(['ingredient_id' => $rWine->id]), - new \Kami\Cocktail\Models\UserIngredient(['ingredient_id' => $prosecco->id]), - new \Kami\Cocktail\Models\UserIngredient(['ingredient_id' => $tonic->id]), - ]); - } - - Model::reguard(); - - $this->info('You are ready to serve!'); - - return Command::SUCCESS; - } - - private function importCocktailsFromJson(string $sourcePath): void - { - $this->line('Importing from: ' . $sourcePath); - - $source = Yaml::parseFile($sourcePath); - - $dbIngredients = DB::table('ingredients')->select(DB::raw('LOWER(name) as name, id'))->get(); - $dbGlasses = DB::table('glasses')->select(DB::raw('LOWER(name) as name, id'))->get(); - $dbMethods = DB::table('cocktail_methods')->select(['name', 'id'])->get(); - - $cocktailBar = $this->output->createProgressBar(count($source)); - $cocktailBar->start(); - - foreach ($source as $sCocktail) { - DB::beginTransaction(); - try { - $imageDTO = new ImageDTO( - ImageProcessor::make( - Storage::disk('bar-assistant')->path('cocktails/' . Str::slug($sCocktail['name']) . '.jpg') - ), - $sCocktail['images'][0]['copyright'] ?? null, - ); - $image = $this->imageService->uploadAndSaveImages([$imageDTO], 1)[0]; - - $ingredients = []; - $sort = 1; - foreach ($sCocktail['ingredients'] as $sIngredient) { - if (!$dbIngredients->contains('name', strtolower($sIngredient['name']))) { - $this->info('Adding ' . $sIngredient['name'] . ' to uncategorized ingredients.'); - $newIngredient = $this->ingredientService->createIngredient($sIngredient['name'], 1, 1); - $newIngredient->name = strtolower($newIngredient->name); - $dbIngredients->push($newIngredient); - } - - $substituteIngredientIds = []; - if (isset($sIngredient['substitutes'])) { - foreach ($sIngredient['substitutes'] ?? [] as $subName) { - $substituteIngredientId = $dbIngredients->filter(fn ($item) => $item->name === strtolower($subName))->first()->id ?? null; - if (!$substituteIngredientId) { - $this->info('Adding ' . $subName . ' to uncategorized ingredients.'); - $substituteIngredientId = $this->ingredientService->createIngredient($subName, 1, 1)->id; - } - - $substituteIngredientIds[] = $substituteIngredientId; - } - } - - $ingredients[] = new IngredientDTO( - $dbIngredients->filter(fn ($item) => $item->name === strtolower($sIngredient['name']))->first()->id, - $sIngredient['name'], - floatval($sIngredient['amount']), - strtolower($sIngredient['units']), - $sort, - $sIngredient['optional'], - $substituteIngredientIds - ); - - $sort++; - } - - $this->cocktailService->createCocktail(new CocktailDTO( - $sCocktail['name'], - $sCocktail['instructions'], - 1, - $sCocktail['description'], - $sCocktail['source'], - $sCocktail['garnish'], - $dbGlasses->filter(fn ($item) => $item->name === strtolower($sCocktail['glass']))->first()->id ?? null, - $dbMethods->filter(fn ($item) => $item->name === $sCocktail['method'])->first()->id ?? null, - $sCocktail['tags'], - $ingredients, - [$image->id] - )); - $cocktailBar->advance(); - } catch (Throwable $e) { - $this->info($e->getMessage()); - DB::rollBack(); - } - DB::commit(); - } - - $cocktailBar->finish(); - $this->newLine(); - } -} diff --git a/app/Console/Commands/BarRefreshUserSearchKeys.php b/app/Console/Commands/BarRefreshUserSearchKeys.php deleted file mode 100644 index aa61946a..00000000 --- a/app/Console/Commands/BarRefreshUserSearchKeys.php +++ /dev/null @@ -1,47 +0,0 @@ -getActions(); - - $key = $searchActions->getPublicApiKey(); - - DB::transaction(function () use ($key) { - DB::table('users')->where('id', '<>', 1)->update(['search_api_key' => $key]); - }); - - $this->info('Updated API keys!'); - - return Command::SUCCESS; - } -} diff --git a/app/Console/Commands/BarScrape.php b/app/Console/Commands/BarScrape.php deleted file mode 100644 index d0ab16c5..00000000 --- a/app/Console/Commands/BarScrape.php +++ /dev/null @@ -1,80 +0,0 @@ -argument('url')); - } catch (Throwable $e) { - $this->error($e->getMessage()); - - return Command::FAILURE; - } - - $scrapedData = $scraper->toArray(); - - if ($this->option('skip-ingredients')) { - $scrapedData['ingredients'] = []; - } - - if ($this->option('tags')) { - $scrapedData['tags'] = explode(',', $this->option('tags')); - } - - if ($this->option('name')) { - $scrapedData['name'] = $this->option('name'); - } - - if ($this->option('dump')) { - dump($scrapedData); - - return Command::SUCCESS; - } - - try { - $this->importService->importCocktailFromArray($scrapedData); - - $this->info('Cocktail imported successfully, do not forget to check the imported data for errors.'); - } catch (Throwable $e) { - $this->error($e->getMessage()); - - return Command::FAILURE; - } - - return Command::SUCCESS; - } -} diff --git a/app/Console/Commands/BarSearchRefresh.php b/app/Console/Commands/BarSearchRefresh.php index 706151fa..3771c911 100644 --- a/app/Console/Commands/BarSearchRefresh.php +++ b/app/Console/Commands/BarSearchRefresh.php @@ -1,5 +1,7 @@ option('clear')) { $this->info('Flushing site search, cocktails and ingredients index...'); - Artisan::call('scout:flush', ['model' => "Kami\Cocktail\Models\Cocktail"]); - Artisan::call('scout:flush', ['model' => "Kami\Cocktail\Models\Ingredient"]); + Artisan::call('scout:flush', ['model' => Cocktail::class]); + Artisan::call('scout:flush', ['model' => Ingredient::class]); } // Update settings diff --git a/app/DataObjects/Cocktail/Cocktail.php b/app/DataObjects/Cocktail/Cocktail.php index 14a46afe..3f6f7897 100644 --- a/app/DataObjects/Cocktail/Cocktail.php +++ b/app/DataObjects/Cocktail/Cocktail.php @@ -16,6 +16,7 @@ public function __construct( public readonly string $name, public readonly string $instructions, public readonly int $userId, + public readonly int $barId, public readonly ?string $description = null, public readonly ?string $source = null, public readonly ?string $garnish = null, diff --git a/app/DataObjects/Cocktail/Ingredient.php b/app/DataObjects/Cocktail/Ingredient.php index 6646ca56..e04c71a1 100644 --- a/app/DataObjects/Cocktail/Ingredient.php +++ b/app/DataObjects/Cocktail/Ingredient.php @@ -7,7 +7,7 @@ class Ingredient { /** - * @param array $substitutes + * @param array $substitutes */ public function __construct( public readonly int $id, @@ -17,6 +17,8 @@ public function __construct( public readonly int $sort = 0, public readonly bool $optional = false, public readonly array $substitutes = [], + public readonly ?float $amountMax = null, + public readonly ?string $note = null ) { } } diff --git a/app/DataObjects/Cocktail/Substitute.php b/app/DataObjects/Cocktail/Substitute.php new file mode 100644 index 00000000..ccc28d9b --- /dev/null +++ b/app/DataObjects/Cocktail/Substitute.php @@ -0,0 +1,16 @@ + $images + */ + public function __construct( + public readonly int $barId, + public readonly string $name, + public readonly int $userId, + public readonly ?int $ingredientCategoryId = null, + public readonly float $strength = 0.0, + public readonly ?string $description = null, + public readonly ?string $origin = null, + public readonly ?string $color = null, + public readonly ?int $parentIngredientId = null, + public readonly array $images = [] + ) { + } +} diff --git a/app/Enums/CocktailMethodEnum.php b/app/Enums/CocktailMethodEnum.php deleted file mode 100644 index ba20731a..00000000 --- a/app/Enums/CocktailMethodEnum.php +++ /dev/null @@ -1,15 +0,0 @@ -renderable(function (NotFoundHttpException $e, $request) { return response()->json([ 'type' => 'api_error', - 'message' => $e->getMessage() == "" ? 'Resource record not found.' : $e->getMessage(), + 'message' => $e->getMessage() === '' ? 'Resource not found.' : $e->getMessage(), ], 404); }); diff --git a/app/Export/BarToZip.php b/app/Export/BarToZip.php new file mode 100644 index 00000000..b5dd57f7 --- /dev/null +++ b/app/Export/BarToZip.php @@ -0,0 +1,52 @@ + $version, + ]; + + $zip = new ZipArchive(); + + // TODO: Query + + $filename = storage_path(sprintf('bar-assistant/backups/%s_%s_%s.zip', Carbon::now()->format('Ymdhi'), 'bass', implode('-', $barIds))); + if ($exportPath) { + $filename = $exportPath; + } + + if ($zip->open($filename, ZipArchive::CREATE) !== true) { + $message = sprintf('Error creating zip archive with filepath "%s"', $filename); + $this->log->error($message); + + throw new ExportException($message); + } + + $zip->addGlob(storage_path('bar-assistant/uploads/*/[' . implode(',', $barIds) . ']/*'), options: ['remove_path' => storage_path('bar-assistant')]); + + if ($metaContent = json_encode($meta)) { + $zip->addFromString('_meta.json', $metaContent); + } + + $zip->close(); + + return $filename; + } +} diff --git a/app/Export/FullBackupToZip.php b/app/Export/FullBackupToZip.php new file mode 100644 index 00000000..10b218f1 --- /dev/null +++ b/app/Export/FullBackupToZip.php @@ -0,0 +1,56 @@ + $version, + 'date' => Carbon::now()->toJSON(), + 'called_from' => __CLASS__, + ]; + + $zip = new ZipArchive(); + + File::ensureDirectoryExists(storage_path('bar-assistant/backups')); + + $filename = storage_path(sprintf('bar-assistant/backups/%s_%s.zip', Carbon::now()->format('Ymdhi'), 'bass-backup')); + if ($exportPath) { + $filename = $exportPath; + } + + if ($zip->open($filename, ZipArchive::CREATE) !== true) { + $message = sprintf('Error creating zip archive with filepath "%s"', $filename); + $this->log->error($message); + + throw new ExportException($message); + } + + $zip->addGlob(storage_path('bar-assistant/*.sqlite'), options: ['remove_path' => storage_path('bar-assistant')]); + $zip->addGlob(storage_path('bar-assistant/uploads/*/*/*'), options: ['remove_path' => storage_path('bar-assistant')]); + + if ($metaContent = json_encode($meta)) { + $zip->addFromString('_meta.json', $metaContent); + } + + $zip->close(); + + return $filename; + } +} diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index eae5d5de..90bf0ae1 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -9,13 +9,14 @@ use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; -use Kami\Cocktail\Search\SearchActionsAdapter; +use Kami\Cocktail\Http\Resources\TokenResource; +use Illuminate\Http\Resources\Json\JsonResource; use Kami\Cocktail\Http\Requests\RegisterRequest; use Kami\Cocktail\Http\Resources\ProfileResource; class AuthController extends Controller { - public function authenticate(Request $request): JsonResponse + public function authenticate(Request $request): JsonResource { $credentials = $request->validate([ 'email' => ['required', 'email'], @@ -25,22 +26,22 @@ public function authenticate(Request $request): JsonResponse if (Auth::attempt($credentials)) { $token = $request->user()->createToken('web_app_login'); - return response()->json(['token' => $token->plainTextToken]); + return new TokenResource($token); } - abort(404, 'User not found. Check your username and password and try again.'); + abort(404, 'Unable to authenticate. Check your login credentials and try again.'); } public function logout(Request $request): JsonResponse { $request->user()->tokens()->delete(); - return response()->json(['data' => ['success' => true]]); + return response()->json(status: 204); } - public function register(SearchActionsAdapter $search, RegisterRequest $req): JsonResponse + public function register(RegisterRequest $req): JsonResource { - if (config('bar-assistant.allow_registration') == false) { + if (config('bar-assistant.allow_registration') === false) { abort(404, 'Registrations are closed.'); } @@ -49,12 +50,8 @@ public function register(SearchActionsAdapter $search, RegisterRequest $req): Js $user->password = Hash::make($req->post('password')); $user->email = $req->post('email'); $user->email_verified_at = now(); - $user->search_api_key = $search->getActions()->getPublicApiKey(); $user->save(); - return (new ProfileResource( - $user->load('favorites', 'shelfIngredients', 'shoppingList'), - app(SearchActionsAdapter::class), - ))->response()->setStatusCode(200); + return new ProfileResource($user); } } diff --git a/app/Http/Controllers/BarController.php b/app/Http/Controllers/BarController.php new file mode 100644 index 00000000..08fcb1bc --- /dev/null +++ b/app/Http/Controllers/BarController.php @@ -0,0 +1,162 @@ +join('bar_memberships', 'bar_memberships.bar_id', '=', 'bars.id') + ->where('bar_memberships.user_id', $request->user()->id) + ->with('createdUser') + ->get(); + + return BarResource::collection($bars); + } + + public function show(Request $request, int $id): JsonResource + { + $bar = Bar::findOrFail($id); + + if ($request->user()->cannot('show', $bar)) { + abort(403); + } + + $bar->load('createdUser', 'updatedUser'); + + return new BarResource($bar); + } + + public function store(BarRequest $request): JsonResponse + { + if ($request->user()->cannot('create', Bar::class)) { + abort(403, 'You can not create anymore bars'); + } + + $inviteEnabled = (bool) $request->post('enable_invites', '1'); + $barOptions = $request->post('options', []); + + $bar = new Bar(); + $bar->name = $request->post('name'); + $bar->subtitle = $request->post('subtitle'); + $bar->description = $request->post('description'); + $bar->created_user_id = $request->user()->id; + $bar->invite_code = $inviteEnabled ? (string) new Ulid() : null; + $bar->save(); + + $request->user()->joinBarAs($bar, UserRoleEnum::Admin); + + SetupBar::dispatch($bar, $request->user(), $barOptions); + + return (new BarResource($bar)) + ->response() + ->setStatusCode(201) + ->header('Location', route('bars.show', $bar->id)); + } + + public function update(int $id, BarRequest $request): JsonResource + { + $bar = Bar::findOrFail($id); + + if ($request->user()->cannot('edit', $bar)) { + abort(403); + } + + Cache::forget('ba:bar:' . $bar->id); + + $inviteEnabled = (bool) $request->post('enable_invites', '1'); + if ($inviteEnabled && $bar->invite_code === null) { + $bar->invite_code = (string) new Ulid(); + } else { + $bar->invite_code = null; + } + + $bar->name = $request->post('name'); + $bar->description = $request->post('description'); + $bar->subtitle = $request->post('subtitle'); + $bar->updated_user_id = $request->user()->id; + $bar->updated_at = now(); + $bar->save(); + + return new BarResource($bar); + } + + public function delete(Request $request, int $id): Response + { + $bar = Bar::findOrFail($id); + + if ($request->user()->cannot('delete', $bar)) { + abort(403); + } + + Cache::forget('ba:bar:' . $bar->id); + + $bar->delete(); + + return response(null, 204); + } + + public function join(Request $request): JsonResource + { + $barToJoin = Bar::where('invite_code', $request->post('invite_code'))->firstOrFail(); + + $request->user()->joinBarAs($barToJoin, UserRoleEnum::General); + + return new BarResource($barToJoin); + } + + public function leave(Request $request, int $id): Response + { + $bar = Bar::findOrFail($id); + + $request->user()->leaveBar($bar); + + return response(status: 204); + } + + public function removeMembership(Request $request, int $id, int $userId): Response + { + $bar = Bar::findOrFail($id); + + if ($request->user()->cannot('deleteMembership', $bar)) { + abort(403); + } + + if ((int) $request->user()->id === (int) $userId) { + abort(400, 'You cannot remove your own bar membership.'); + } + + $bar->memberships()->where('user_id', $userId)->delete(); + + return response(status: 204); + } + + public function memberships(Request $request, int $id): JsonResource + { + $bar = Bar::findOrFail($id); + + if ($request->user()->cannot('show', $bar)) { + abort(403); + } + + $bar->load('memberships'); + + return BarMembershipResource::collection($bar->memberships); + } +} diff --git a/app/Http/Controllers/CocktailController.php b/app/Http/Controllers/CocktailController.php index 00245f0f..6c588eba 100644 --- a/app/Http/Controllers/CocktailController.php +++ b/app/Http/Controllers/CocktailController.php @@ -17,16 +17,13 @@ use Kami\Cocktail\Http\Resources\CocktailResource; use Kami\Cocktail\Http\Filters\CocktailQueryFilter; use Spatie\QueryBuilder\Exceptions\InvalidFilterQuery; -use Kami\Cocktail\Http\Resources\SuccessActionResource; use Kami\Cocktail\Http\Resources\CocktailPublicResource; use Kami\Cocktail\DataObjects\Cocktail\Cocktail as CocktailDTO; use Kami\Cocktail\DataObjects\Cocktail\Ingredient as IngredientDTO; +use Kami\Cocktail\DataObjects\Cocktail\Substitute as SubstituteDTO; class CocktailController extends Controller { - /** - * List all cocktails - */ public function index(CocktailService $cocktailService, Request $request): JsonResource { try { @@ -40,9 +37,6 @@ public function index(CocktailService $cocktailService, Request $request): JsonR return CocktailResource::collection($cocktails); } - /** - * Show a single cocktail by it's id or URL slug - */ public function show(int|string $idOrSlug, Request $request): JsonResource { $cocktail = Cocktail::where('slug', $idOrSlug) @@ -51,18 +45,33 @@ public function show(int|string $idOrSlug, Request $request): JsonResource ->firstOrFail() ->load(['ingredients.ingredient', 'images' => function ($query) { $query->orderBy('sort'); - }, 'tags', 'glass', 'ingredients.substitutes', 'method', 'notes', 'user', 'collections', 'utensils']); + }, 'tags', 'glass', 'ingredients.substitutes', 'method', 'createdUser', 'updatedUser', 'collections', 'utensils']); + + if ($request->user()->cannot('show', $cocktail)) { + abort(403); + } return new CocktailResource($cocktail); } - /** - * Create a new cocktail - */ public function store(CocktailService $cocktailService, CocktailRequest $request): JsonResponse { + if ($request->user()->cannot('create', Cocktail::class)) { + abort(403); + } + $ingredients = []; foreach ($request->post('ingredients', []) as $formIngredient) { + $substitutes = []; + foreach ($formIngredient['substitutes'] ?? [] as $sub) { + $substitutes[] = new SubstituteDTO( + $sub['id'], + ($sub['amount'] ?? null) !== null ? (float) $sub['amount'] : null, + ($sub['amount_max'] ?? null) !== null ? (float) $sub['amount_max'] : null, + $sub['units'] ?? null, + ); + } + $ingredient = new IngredientDTO( (int) $formIngredient['ingredient_id'], null, @@ -70,7 +79,9 @@ public function store(CocktailService $cocktailService, CocktailRequest $request $formIngredient['units'], (int) $formIngredient['sort'], $formIngredient['optional'] ?? false, - $formIngredient['substitutes'] ?? [], + $substitutes, + ($formIngredient['amount_max'] ?? null) !== null ? (float) $formIngredient['amount_max'] : null, + $formIngredient['note'] ?? null ); $ingredients[] = $ingredient; } @@ -79,6 +90,7 @@ public function store(CocktailService $cocktailService, CocktailRequest $request $request->post('name'), $request->post('instructions'), $request->user()->id, + bar()->id, $request->post('description'), $request->post('source'), $request->post('garnish'), @@ -96,7 +108,9 @@ public function store(CocktailService $cocktailService, CocktailRequest $request abort(500, $e->getMessage()); } - $cocktail->load('ingredients.ingredient', 'images', 'tags', 'glass', 'ingredients.substitutes', 'utensils'); + $cocktail->load(['ingredients.ingredient', 'images' => function ($query) { + $query->orderBy('sort'); + }, 'tags', 'glass', 'ingredients.substitutes', 'method', 'createdUser', 'updatedUser', 'collections', 'utensils']); return (new CocktailResource($cocktail)) ->response() @@ -104,9 +118,6 @@ public function store(CocktailService $cocktailService, CocktailRequest $request ->header('Location', route('cocktails.show', $cocktail->id)); } - /** - * Update a single cocktail by id - */ public function update(CocktailService $cocktailService, CocktailRequest $request, int $id): JsonResource { $cocktail = Cocktail::findOrFail($id); @@ -117,6 +128,16 @@ public function update(CocktailService $cocktailService, CocktailRequest $reques $ingredients = []; foreach ($request->post('ingredients', []) as $formIngredient) { + $substitutes = []; + foreach ($formIngredient['substitutes'] ?? [] as $sub) { + $substitutes[] = new SubstituteDTO( + (int) $sub['id'], + ($sub['amount'] ?? null) !== null ? (float) $sub['amount'] : null, + ($sub['amount_max'] ?? null) !== null ? (float) $sub['amount_max'] : null, + $sub['units'] ?? null, + ); + } + $ingredient = new IngredientDTO( (int) $formIngredient['ingredient_id'], null, @@ -124,7 +145,9 @@ public function update(CocktailService $cocktailService, CocktailRequest $reques $formIngredient['units'], (int) $formIngredient['sort'], $formIngredient['optional'] ?? false, - $formIngredient['substitutes'] ?? [], + $substitutes, + ($formIngredient['amount_max'] ?? null) !== null ? (float) $formIngredient['amount_max'] : null, + $formIngredient['note'] ?? null ); $ingredients[] = $ingredient; } @@ -133,6 +156,7 @@ public function update(CocktailService $cocktailService, CocktailRequest $reques $request->post('name'), $request->post('instructions'), $request->user()->id, + $cocktail->bar_id, $request->post('description'), $request->post('source'), $request->post('garnish'), @@ -150,14 +174,13 @@ public function update(CocktailService $cocktailService, CocktailRequest $reques abort(500, $e->getMessage()); } - $cocktail->load('ingredients.ingredient', 'images', 'tags', 'glass', 'ingredients.substitutes', 'utensils'); + $cocktail->load(['ingredients.ingredient', 'images' => function ($query) { + $query->orderBy('sort'); + }, 'tags', 'glass', 'ingredients.substitutes', 'method', 'createdUser', 'updatedUser', 'collections', 'utensils']); return new CocktailResource($cocktail); } - /** - * Delete a single cocktail by id - */ public function delete(Request $request, int $id): Response { $cocktail = Cocktail::findOrFail($id); @@ -171,22 +194,25 @@ public function delete(Request $request, int $id): Response return response(null, 204); } - /** - * Favorite a cocktail by id - */ - public function toggleFavorite(CocktailService $cocktailService, Request $request, int $id): JsonResource + public function toggleFavorite(CocktailService $cocktailService, Request $request, int $id): JsonResponse { - $isFavorite = $cocktailService->toggleFavorite($request->user(), $id); + $userFavorite = $cocktailService->toggleFavorite($request->user(), $id); - return new SuccessActionResource((object) ['id' => $id, 'is_favorited' => $isFavorite]); + return response()->json([ + 'data' => ['id' => $id, 'is_favorited' => $userFavorite !== null] + ]); } - public function makePublic(int|string $idOrSlug): JsonResource + public function makePublic(Request $request, int|string $idOrSlug): JsonResource { $cocktail = Cocktail::where('id', $idOrSlug) ->orWhere('slug', $idOrSlug) ->firstOrFail(); + if ($request->user()->cannot('edit', $cocktail)) { + abort(403); + } + if ($cocktail->public_id) { return new CocktailPublicResource($cocktail); } @@ -196,12 +222,16 @@ public function makePublic(int|string $idOrSlug): JsonResource return new CocktailPublicResource($cocktail); } - public function makePrivate(int|string $idOrSlug): Response + public function makePrivate(Request $request, int|string $idOrSlug): Response { $cocktail = Cocktail::where('id', $idOrSlug) ->orWhere('slug', $idOrSlug) ->firstOrFail(); + if ($request->user()->cannot('edit', $cocktail)) { + abort(403); + } + $cocktail = $cocktail->makePrivate(); return response(null, 204); @@ -209,8 +239,6 @@ public function makePrivate(int|string $idOrSlug): Response public function share(Request $request, int|string $idOrSlug): Response { - $type = $request->get('type', 'json'); - $cocktail = Cocktail::where('id', $idOrSlug) ->orWhere('slug', $idOrSlug) ->firstOrFail() @@ -218,6 +246,12 @@ public function share(Request $request, int|string $idOrSlug): Response $query->orderBy('sort'); }, 'ingredients.substitutes', 'ingredients.ingredient.category']); + if ($request->user()->cannot('show', $cocktail)) { + abort(403); + } + + $type = $request->get('type', 'json'); + $data = $cocktail->toShareableArray(); if ($type === 'json') { @@ -249,15 +283,20 @@ public function share(Request $request, int|string $idOrSlug): Response public function similar(Request $request, int|string $idOrSlug): JsonResource { - $limitTotal = $request->get('limit', 5); - $cocktail = Cocktail::where('id', $idOrSlug)->orWhere('slug', $idOrSlug)->with('ingredients')->firstOrFail(); + + if ($request->user()->cannot('show', $cocktail)) { + abort(403); + } + + $limitTotal = $request->get('limit', 5); $ingredients = $cocktail->ingredients->filter(fn ($ci) => $ci->optional === false)->pluck('ingredient_id'); $relatedCocktails = collect(); while ($ingredients->count() > 0) { $ingredients->pop(); $possibleRelatedCocktails = Cocktail::where('cocktails.id', '<>', $cocktail->id) + ->where('bar_id', $cocktail->bar_id) ->with('ingredients') ->whereIn('cocktails.id', function ($query) use ($ingredients) { $query->select('ci.cocktail_id') diff --git a/app/Http/Controllers/CocktailMethodController.php b/app/Http/Controllers/CocktailMethodController.php index 8cff0e3c..2eb0454e 100644 --- a/app/Http/Controllers/CocktailMethodController.php +++ b/app/Http/Controllers/CocktailMethodController.php @@ -16,27 +16,32 @@ class CocktailMethodController extends Controller { public function index(): JsonResource { - $methods = CocktailMethod::orderBy('id')->withCount('cocktails')->get(); + $methods = CocktailMethod::orderBy('id')->withCount('cocktails')->filterByBar()->get(); return CocktailMethodResource::collection($methods); } - public function show(int $id): JsonResource + public function show(Request $request, int $id): JsonResource { $method = CocktailMethod::withCount('cocktails')->findOrFail($id); + if ($request->user()->cannot('show', $method)) { + abort(403); + } + return new CocktailMethodResource($method); } public function store(CocktailMethodRequest $request): JsonResponse { - if (!$request->user()->isAdmin()) { + if ($request->user()->cannot('create', CocktailMethod::class)) { abort(403); } $method = new CocktailMethod(); $method->name = $request->post('name'); $method->dilution_percentage = (int) $request->post('dilution_percentage'); + $method->bar_id = bar()->id; $method->save(); return (new CocktailMethodResource($method)) @@ -47,27 +52,29 @@ public function store(CocktailMethodRequest $request): JsonResponse public function update(CocktailMethodRequest $request, int $id): JsonResource { - if (!$request->user()->isAdmin()) { + $method = CocktailMethod::findOrFail($id); + + if ($request->user()->cannot('edit', $method)) { abort(403); } - $method = CocktailMethod::findOrFail($id); $method->name = $request->post('name'); $method->dilution_percentage = (int) $request->post('dilution_percentage'); + $method->updated_at = now(); $method->save(); - // TODO: Update index abv - return new CocktailMethodResource($method); } public function delete(Request $request, int $id): Response { - if (!$request->user()->isAdmin()) { + $method = CocktailMethod::findOrFail($id); + + if ($request->user()->cannot('delete', $method)) { abort(403); } - CocktailMethod::findOrFail($id)->delete(); + $method->delete(); return response(null, 204); } diff --git a/app/Http/Controllers/CollectionController.php b/app/Http/Controllers/CollectionController.php index e38e20bf..d585bc4f 100644 --- a/app/Http/Controllers/CollectionController.php +++ b/app/Http/Controllers/CollectionController.php @@ -9,6 +9,7 @@ use Illuminate\Http\Response; use Symfony\Component\Yaml\Yaml; use Illuminate\Http\JsonResponse; +use Illuminate\Support\Facades\DB; use Kami\Cocktail\Models\Cocktail; use Illuminate\Http\Resources\Json\JsonResource; use Kami\Cocktail\Http\Requests\CollectionRequest; @@ -18,9 +19,9 @@ class CollectionController extends Controller { - public function index(Request $request): JsonResource + public function index(): JsonResource { - $collections = (new CollectionQueryFilter())->where('user_id', $request->user()->id)->get(); + $collections = (new CollectionQueryFilter())->get(); return CollectionResource::collection($collections); } @@ -38,14 +39,27 @@ public function show(Request $request, int $id): JsonResource public function store(CollectionRequest $request): JsonResponse { + if ($request->user()->cannot('create', CocktailCollection::class)) { + abort(403); + } + + $barMembership = $request->user()->getBarMembership(bar()->id); + $collection = new CocktailCollection(); $collection->name = $request->post('name'); $collection->description = $request->post('description'); - $collection->user_id = $request->user()->id; + $collection->bar_membership_id = $barMembership->id; $collection->save(); $cocktailIds = $request->post('cocktails', []); - $collection->cocktails()->attach($cocktailIds); + if (!empty($cocktailIds)) { + $cocktails = DB::table('cocktails') + ->select('id') + ->where('bar_id', $barMembership->bar_id) + ->whereIn('id', $cocktailIds) + ->pluck('id'); + $collection->cocktails()->attach($cocktails); + } return (new CollectionResource($collection)) ->response() @@ -63,6 +77,7 @@ public function update(CollectionRequest $request, int $id): JsonResource $collection->name = $request->post('name'); $collection->description = $request->post('description'); + $collection->updated_at = now(); $collection->save(); return new CollectionResource($collection); @@ -76,8 +91,19 @@ public function cocktails(Request $request, int $id): JsonResource abort(403); } + $cocktailIds = $request->post('cocktails', []); + try { - $collection->cocktails()->syncWithoutDetaching($request->post('cocktails', [])); + if (!empty($cocktailIds)) { + $cocktails = DB::table('cocktails') + ->select('id') + ->where('bar_id', $collection->barMembership->bar_id) + ->whereIn('id', $cocktailIds) + ->pluck('id'); + $collection->cocktails()->syncWithoutDetaching($cocktails); + $collection->updated_at = now(); + $collection->save(); + } } catch (Throwable) { abort(500, 'Unable to add cocktails to collection!'); } @@ -93,10 +119,12 @@ public function cocktail(Request $request, int $id, int $cocktailId): JsonResour abort(403); } - $cocktail = Cocktail::findOrFail($cocktailId); + $cocktail = Cocktail::where('id', $cocktailId)->where('bar_id', $collection->barMembership->bar_id)->firstOrFail(); try { $cocktail->addToCollection($collection); + $collection->updated_at = now(); + $collection->save(); } catch (Throwable) { abort(500, 'Unable to add cocktail to collection!'); } @@ -127,6 +155,8 @@ public function deleteResourceFromCollection(Request $request, int $id, int $coc try { $collection->cocktails()->detach($cocktailId); + $collection->updated_at = now(); + $collection->save(); } catch (Throwable $e) { abort(500, 'Unable to remove cocktail from collection!'); } diff --git a/app/Http/Controllers/GlassController.php b/app/Http/Controllers/GlassController.php index fb497624..636cf49e 100644 --- a/app/Http/Controllers/GlassController.php +++ b/app/Http/Controllers/GlassController.php @@ -11,32 +11,38 @@ use Kami\Cocktail\Http\Requests\GlassRequest; use Kami\Cocktail\Http\Resources\GlassResource; use Illuminate\Http\Resources\Json\JsonResource; +use Kami\Cocktail\Http\Filters\GlassQueryFilter; class GlassController extends Controller { public function index(): JsonResource { - $glasses = Glass::orderBy('name')->withCount('cocktails')->get(); + $glasses = (new GlassQueryFilter())->get(); return GlassResource::collection($glasses); } - public function show(int $id): JsonResource + public function show(Request $request, int $id): JsonResource { $glass = Glass::withCount('cocktails')->findOrFail($id); + if ($request->user()->cannot('show', $glass)) { + abort(403); + } + return new GlassResource($glass); } public function store(GlassRequest $request): JsonResponse { - if (!$request->user()->isAdmin()) { + if ($request->user()->cannot('create', Glass::class)) { abort(403); } $glass = new Glass(); $glass->name = $request->post('name'); $glass->description = $request->post('description'); + $glass->bar_id = bar()->id; $glass->save(); return (new GlassResource($glass)) @@ -47,13 +53,15 @@ public function store(GlassRequest $request): JsonResponse public function update(int $id, GlassRequest $request): JsonResource { - if (!$request->user()->isAdmin()) { + $glass = Glass::findOrFail($id); + + if ($request->user()->cannot('edit', $glass)) { abort(403); } - $glass = Glass::findOrFail($id); $glass->name = $request->post('name'); $glass->description = $request->post('description'); + $glass->updated_at = now(); $glass->save(); $glass->cocktails->each(fn ($cocktail) => $cocktail->searchable()); @@ -63,21 +71,14 @@ public function update(int $id, GlassRequest $request): JsonResource public function delete(Request $request, int $id): Response { - if (!$request->user()->isAdmin()) { + $glass = Glass::findOrFail($id); + + if ($request->user()->cannot('delete', $glass)) { abort(403); } - Glass::findOrFail($id)->delete(); + $glass->delete(); return response(null, 204); } - - public function find(Request $request): JsonResource - { - $name = $request->get('name'); - - $glass = Glass::whereRaw('lower(name) = ?', [strtolower($name)])->firstOrFail(); - - return new GlassResource($glass); - } } diff --git a/app/Http/Controllers/ImageController.php b/app/Http/Controllers/ImageController.php index 666d4688..0a0b62f3 100644 --- a/app/Http/Controllers/ImageController.php +++ b/app/Http/Controllers/ImageController.php @@ -21,21 +21,14 @@ class ImageController extends Controller { - public function index(Request $request): JsonResource + public function show(Request $request, int $id): JsonResource { - if (!$request->user()->isAdmin()) { + $image = Image::findOrFail($id); + + if ($request->user()->cannot('show', $image)) { abort(403); } - $images = Image::orderBy('created_at')->paginate($request->get('per_page', 15)); - - return ImageResource::collection($images); - } - - public function show(int $id): JsonResource - { - $image = Image::findOrFail($id); - return new ImageResource($image); } @@ -81,7 +74,7 @@ public function update(int $id, ImageService $imageservice, ImageUpdateRequest $ $request->has('sort') ? (int) $request->input('sort') : null, ); - $image = $imageservice->updateImage($id, $imageDTO); + $image = $imageservice->updateImage($id, $imageDTO, $request->user()->id); return new ImageResource($image); } diff --git a/app/Http/Controllers/ImportController.php b/app/Http/Controllers/ImportController.php index 6e99784d..15b5a08e 100644 --- a/app/Http/Controllers/ImportController.php +++ b/app/Http/Controllers/ImportController.php @@ -8,20 +8,22 @@ use Symfony\Component\Yaml\Yaml; use Illuminate\Http\JsonResponse; use Kami\Cocktail\Scraper\Manager; -use Kami\Cocktail\Services\ImportService; +use Kami\Cocktail\Import\FromArray; +use Kami\Cocktail\Jobs\ImportCollection; use Kami\Cocktail\Http\Requests\ImportRequest; +use Kami\Cocktail\Import\DuplicateActionsEnum; use Illuminate\Http\Resources\Json\JsonResource; use Kami\Cocktail\Http\Resources\CocktailResource; -use Kami\Cocktail\Http\Resources\CollectionResource; class ImportController extends Controller { - public function cocktail(ImportRequest $request, ImportService $importService): JsonResponse|JsonResource + public function cocktail(ImportRequest $request, FromArray $arrayImporter): JsonResponse|JsonResource { $dataToImport = []; $type = $request->get('type', 'url'); $save = $request->get('save', false); $source = $request->post('source'); + $duplicateAction = DuplicateActionsEnum::from((int) $request->post('duplicate_actions', '0')); if ($type === 'url') { $request->validate(['source' => 'url']); @@ -36,7 +38,7 @@ public function cocktail(ImportRequest $request, ImportService $importService): if ($type === 'json') { if (!is_array($source)) { - if (!$source = json_decode($source)) { + if (!$source = json_decode($source, true)) { abort(400, 'Unable to parse the JSON string'); } } @@ -63,13 +65,19 @@ public function cocktail(ImportRequest $request, ImportService $importService): abort(400, sprintf('No cocktails found')); } - $collection = $importService->importCocktailCollection($source, $request->user()->id); + ImportCollection::dispatch($source, $request->user()->id, bar()->id, $duplicateAction); - return new CollectionResource($collection); + return response()->json([ + 'data' => ['status' => 'started'] + ]); } if ($save) { - $dataToImport = new CocktailResource($importService->importCocktailFromArray($dataToImport, $request->user()->id)); + $cocktail = $arrayImporter->process($dataToImport, $request->user()->id, bar()->id); + $cocktail->load(['ingredients.ingredient', 'images' => function ($query) { + $query->orderBy('sort'); + }, 'tags', 'glass', 'ingredients.substitutes', 'method', 'createdUser', 'updatedUser', 'collections', 'utensils']); + $dataToImport = new CocktailResource($cocktail); } return response()->json([ diff --git a/app/Http/Controllers/IngredientCategoryController.php b/app/Http/Controllers/IngredientCategoryController.php index cc34fd3a..fbfa3525 100644 --- a/app/Http/Controllers/IngredientCategoryController.php +++ b/app/Http/Controllers/IngredientCategoryController.php @@ -16,27 +16,32 @@ class IngredientCategoryController extends Controller { public function index(): JsonResource { - $categories = IngredientCategory::orderBy('name')->get(); + $categories = IngredientCategory::orderBy('name')->withCount('ingredients')->filterByBar()->get(); return IngredientCategoryResource::collection($categories); } - public function show(int $id): JsonResource + public function show(Request $request, int $id): JsonResource { $category = IngredientCategory::findOrFail($id); + if ($request->user()->cannot('show', $category)) { + abort(403); + } + return new IngredientCategoryResource($category); } public function store(IngredientCategoryRequest $request): JsonResponse { - if (!$request->user()->isAdmin()) { + if ($request->user()->cannot('create', IngredientCategory::class)) { abort(403); } $category = new IngredientCategory(); $category->name = $request->post('name'); $category->description = $request->post('description'); + $category->bar_id = bar()->id; $category->save(); return (new IngredientCategoryResource($category)) @@ -47,13 +52,15 @@ public function store(IngredientCategoryRequest $request): JsonResponse public function update(IngredientCategoryRequest $request, int $id): JsonResource { - if (!$request->user()->isAdmin()) { + $category = IngredientCategory::findOrFail($id); + + if ($request->user()->cannot('edit', $category)) { abort(403); } - $category = IngredientCategory::findOrFail($id); $category->name = $request->post('name'); $category->description = $request->post('description'); + $category->updated_at = now(); $category->save(); return new IngredientCategoryResource($category); @@ -61,11 +68,13 @@ public function update(IngredientCategoryRequest $request, int $id): JsonResourc public function delete(Request $request, int $id): Response { - if (!$request->user()->isAdmin()) { + $category = IngredientCategory::findOrFail($id); + + if ($request->user()->cannot('delete', $category)) { abort(403); } - IngredientCategory::findOrFail($id)->delete(); + $category->delete(); return response(null, 204); } diff --git a/app/Http/Controllers/IngredientController.php b/app/Http/Controllers/IngredientController.php index 07323c7b..b01ec4e4 100644 --- a/app/Http/Controllers/IngredientController.php +++ b/app/Http/Controllers/IngredientController.php @@ -16,13 +16,14 @@ use Kami\Cocktail\Http\Resources\IngredientResource; use Kami\Cocktail\Http\Filters\IngredientQueryFilter; use Spatie\QueryBuilder\Exceptions\InvalidFilterQuery; +use Kami\Cocktail\DataObjects\Ingredient\Ingredient as IngredientDTO; class IngredientController extends Controller { public function index(IngredientService $ingredientService, Request $request): JsonResource { try { - $ingredients = (new IngredientQueryFilter($ingredientService))->paginate($request->get('per_page', 50)); + $ingredients = (new IngredientQueryFilter($ingredientService))->paginate($request->get('per_page', 50))->withQueryString(); } catch (InvalidFilterQuery $e) { abort(400, $e->getMessage()); } @@ -30,30 +31,42 @@ public function index(IngredientService $ingredientService, Request $request): J return IngredientResource::collection($ingredients); } - public function show(int|string $id): JsonResource + public function show(Request $request, int|string $id): JsonResource { - $ingredient = Ingredient::with('cocktails', 'images', 'varieties', 'parentIngredient') + $ingredient = Ingredient::with('cocktails', 'images', 'varieties', 'parentIngredient', 'createdUser', 'updatedUser') + ->withCount('cocktails') ->where('id', $id) ->orWhere('slug', $id) ->firstOrFail(); + if ($request->user()->cannot('show', $ingredient)) { + abort(403); + } + return new IngredientResource($ingredient); } public function store(IngredientService $ingredientService, IngredientRequest $request): JsonResponse { - $ingredient = $ingredientService->createIngredient( + if ($request->user()->cannot('create', Ingredient::class)) { + abort(403); + } + + $ingredientDTO = new IngredientDTO( + bar()->id, $request->post('name'), - (int) $request->post('ingredient_category_id'), auth()->user()->id, + $request->post('ingredient_category_id') ? (int) $request->post('ingredient_category_id') : null, floatval($request->post('strength', '0')), $request->post('description'), $request->post('origin'), $request->post('color'), $request->post('parent_ingredient_id') ? (int) $request->post('parent_ingredient_id') : null, - $request->post('images', []) + $request->post('images', []), ); + $ingredient = $ingredientService->createIngredient($ingredientDTO); + return (new IngredientResource($ingredient)) ->response() ->setStatusCode(201) @@ -68,19 +81,21 @@ public function update(IngredientService $ingredientService, IngredientRequest $ abort(403); } - $ingredient = $ingredientService->updateIngredient( - $id, + $ingredientDTO = new IngredientDTO( + $ingredient->bar_id, $request->post('name'), - (int) $request->post('ingredient_category_id'), auth()->user()->id, + $request->post('ingredient_category_id') ? (int) $request->post('ingredient_category_id') : null, floatval($request->post('strength', '0')), $request->post('description'), $request->post('origin'), $request->post('color'), $request->post('parent_ingredient_id') ? (int) $request->post('parent_ingredient_id') : null, - $request->post('images', []) + $request->post('images', []), ); + $ingredient = $ingredientService->updateIngredient($id, $ingredientDTO); + return new IngredientResource($ingredient); } @@ -99,15 +114,21 @@ public function delete(Request $request, int $id): Response public function extra(Request $request, CocktailService $cocktailService, int $id): JsonResponse { - $currentShelfIngredients = $request->user()->shelfIngredients->pluck('ingredient_id'); - $currentShelfCocktails = $cocktailService->getCocktailsByUserIngredients($currentShelfIngredients->toArray())->values(); - $extraShelfCocktails = $cocktailService->getCocktailsByUserIngredients($currentShelfIngredients->push($id)->toArray())->values(); + $ingredient = Ingredient::findOrFail($id); + + if ($request->user()->cannot('show', $ingredient)) { + abort(403); + } + + $currentShelfIngredients = $request->user()->getShelfIngredients($ingredient->bar_id)->pluck('ingredient_id'); + $currentShelfCocktails = $cocktailService->getCocktailsByIngredients($currentShelfIngredients->toArray())->values(); + $extraShelfCocktails = $cocktailService->getCocktailsByIngredients($currentShelfIngredients->push($ingredient->id)->toArray())->values(); if ($currentShelfCocktails->count() === $extraShelfCocktails->count()) { return response()->json(['data' => []]); } - $extraCocktails = Cocktail::whereIn('id', $extraShelfCocktails->diff($currentShelfCocktails)->values())->get(); + $extraCocktails = Cocktail::whereIn('id', $extraShelfCocktails->diff($currentShelfCocktails)->values())->where('bar_id', '=', $ingredient->bar_id)->get(); return response()->json([ 'data' => $extraCocktails->map(function (Cocktail $cocktail) { diff --git a/app/Http/Controllers/NoteController.php b/app/Http/Controllers/NoteController.php index cc004802..86b19200 100644 --- a/app/Http/Controllers/NoteController.php +++ b/app/Http/Controllers/NoteController.php @@ -11,10 +11,18 @@ use Kami\Cocktail\Models\Cocktail; use Kami\Cocktail\Http\Requests\NoteRequest; use Kami\Cocktail\Http\Resources\NoteResource; +use Kami\Cocktail\Http\Filters\NoteQueryFilter; use Illuminate\Http\Resources\Json\JsonResource; class NoteController extends Controller { + public function index(Request $request): JsonResource + { + $notes = (new NoteQueryFilter())->paginate($request->get('per_page', 100))->withQueryString(); + + return NoteResource::collection($notes); + } + public function show(Request $request, int $id): JsonResource { $note = Note::findOrFail($id); diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php index b061a344..f66847be 100644 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/ProfileController.php @@ -6,7 +6,6 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; -use Kami\Cocktail\Search\SearchActionsAdapter; use Illuminate\Http\Resources\Json\JsonResource; use Kami\Cocktail\Http\Resources\ProfileResource; use Kami\Cocktail\Http\Requests\UpdateUserRequest; @@ -15,14 +14,12 @@ class ProfileController extends Controller { public function show(Request $request): JsonResource { - return new ProfileResource( - $request->user()->load('favorites', 'shelfIngredients', 'shoppingList'), - app(SearchActionsAdapter::class), - ); + return new ProfileResource($request->user()); } public function update(UpdateUserRequest $request): JsonResource { + $barId = $request->post('bar_id', null); $currentUser = $request->user(); $currentUser->name = $request->post('name'); $currentUser->email = $request->post('email'); @@ -33,11 +30,17 @@ public function update(UpdateUserRequest $request): JsonResource $currentUser->tokens()->delete(); } + // If there is a bar context + if ($barId !== null) { + $barMembership = $currentUser->getBarMembership((int) $barId); + if ($barMembership) { + $barMembership->is_shelf_public = (bool) $request->post('is_shelf_public'); + $barMembership->save(); + } + } + $currentUser->save(); - return new ProfileResource( - $request->user()->load('favorites', 'shelfIngredients', 'shoppingList'), - app(SearchActionsAdapter::class), - ); + return new ProfileResource($request->user()); } } diff --git a/app/Http/Controllers/RatingController.php b/app/Http/Controllers/RatingController.php index ba0fb09c..bf1ee970 100644 --- a/app/Http/Controllers/RatingController.php +++ b/app/Http/Controllers/RatingController.php @@ -17,6 +17,10 @@ public function rateCocktail(RatingRequest $request, int $cocktailId): JsonResou { $cocktail = Cocktail::findOrFail($cocktailId); + if ($request->user()->cannot('rate', $cocktail)) { + abort(403); + } + $rating = $cocktail->rate( (int) $request->post('rating'), $request->user()->id @@ -31,6 +35,10 @@ public function deleteCocktailRating(Request $request, int $cocktailId): Respons { $cocktail = Cocktail::findOrFail($cocktailId); + if ($request->user()->cannot('rate', $cocktail)) { + abort(403); + } + $cocktail->deleteUserRating($request->user()->id); $cocktail->searchable(); diff --git a/app/Http/Controllers/ServerController.php b/app/Http/Controllers/ServerController.php index a44ba73a..649daf04 100644 --- a/app/Http/Controllers/ServerController.php +++ b/app/Http/Controllers/ServerController.php @@ -23,7 +23,6 @@ public function version(SearchActionsAdapter $searchAdapter): JsonResponse return response()->json([ 'data' => [ - 'name' => config('app.name'), 'version' => config('bar-assistant.version'), 'type' => config('app.env'), 'search_host' => $search->getHost(), diff --git a/app/Http/Controllers/ShelfController.php b/app/Http/Controllers/ShelfController.php index bd19c3d8..826b3f67 100644 --- a/app/Http/Controllers/ShelfController.php +++ b/app/Http/Controllers/ShelfController.php @@ -8,19 +8,22 @@ use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Http\JsonResponse; +use Illuminate\Support\Facades\DB; use Kami\Cocktail\Models\UserIngredient; +use Kami\Cocktail\Models\CocktailFavorite; use Kami\Cocktail\Models\UserShoppingList; use Kami\Cocktail\Services\CocktailService; use Illuminate\Http\Resources\Json\JsonResource; +use Kami\Cocktail\Http\Requests\IngredientsBatchRequest; use Kami\Cocktail\Http\Resources\UserIngredientResource; -use Kami\Cocktail\Http\Requests\UserIngredientBatchRequest; class ShelfController extends Controller { public function ingredients(Request $request): JsonResource { - $userIngredients = $request->user() - ->shelfIngredients + $barMembership = $request->user()->getBarMembership(bar()->id); + $userIngredients = $barMembership + ->userIngredients ->sortBy('ingredient.name') ->load('ingredient'); @@ -29,10 +32,11 @@ public function ingredients(Request $request): JsonResource public function cocktails(CocktailService $cocktailService, Request $request): JsonResponse { + $barMembership = $request->user()->getBarMembership(bar()->id); $limit = $request->has('limit') ? (int) $request->get('limit') : null; - $cocktailIds = $cocktailService->getCocktailsByUserIngredients( - $request->user()->shelfIngredients->pluck('ingredient_id')->toArray(), + $cocktailIds = $cocktailService->getCocktailsByIngredients( + $barMembership->userIngredients->pluck('ingredient_id')->toArray(), $limit ); @@ -41,48 +45,54 @@ public function cocktails(CocktailService $cocktailService, Request $request): J ]); } - public function save(Request $request, int $ingredientId): JsonResponse + public function favorites(Request $request): JsonResponse { - // Remove ingredient from the shopping list if it exists - UserShoppingList::where('ingredient_id', $ingredientId)->delete(); + $barMembership = $request->user()->getBarMembership(bar()->id); - // Check if ingredient is already in the shelf, and add it if it's not - if (!$request->user()->shelfIngredients->contains('ingredient_id', $ingredientId)) { - $userIngredient = new UserIngredient(); - $userIngredient->ingredient_id = $ingredientId; - $shelfIngredient = $request->user()->shelfIngredients()->save($userIngredient); - } else { - $shelfIngredient = $request->user()->shelfIngredients->where('ingredient_id', $ingredientId)->first(); - } + $cocktailIds = CocktailFavorite::where('bar_membership_id', $barMembership->id)->pluck('cocktail_id'); - return (new UserIngredientResource($shelfIngredient))->response()->setStatusCode(200); + return response()->json([ + 'data' => $cocktailIds + ]); } - public function batch(UserIngredientBatchRequest $request): JsonResource + public function batchStore(IngredientsBatchRequest $request): JsonResource { - $ingredientIds = $request->post('ingredient_ids'); + $barMembership = $request->user()->getBarMembership(bar()->id); + + $ingredients = DB::table('ingredients') + ->select('id') + ->where('bar_id', $barMembership->bar_id) + ->whereIn('id', $request->post('ingredient_ids')) + ->pluck('id'); // Let's remove ingredients from shopping list since they are on our shelf now - UserShoppingList::whereIn('ingredient_id', $ingredientIds)->delete(); + UserShoppingList::whereIn('ingredient_id', $ingredients)->delete(); $models = []; - foreach ($ingredientIds as $ingId) { + foreach ($ingredients as $dbIngredientId) { $userIngredient = new UserIngredient(); - $userIngredient->ingredient_id = $ingId; + $userIngredient->ingredient_id = $dbIngredientId; $models[] = $userIngredient; } - $si = $request->user()->shelfIngredients()->saveMany($models); + $shelfIngredients = $barMembership->userIngredients()->saveMany($models); - return UserIngredientResource::collection($si); + return UserIngredientResource::collection($shelfIngredients); } - public function delete(Request $request, int $ingredientId): Response + public function batchDelete(Request $request): Response { + $barMembership = $request->user()->getBarMembership(bar()->id); + + $ingredients = DB::table('ingredients') + ->select('id') + ->where('bar_id', $barMembership->bar_id) + ->whereIn('id', $request->post('ingredient_ids')) + ->pluck('id'); + try { - UserIngredient::where('user_id', $request->user()->id) - ->where('ingredient_id', $ingredientId) - ->delete(); + $barMembership->userIngredients()->whereIn('ingredient_id', $ingredients)->delete(); } catch (Throwable $e) { abort(500, $e->getMessage()); } diff --git a/app/Http/Controllers/ShoppingListController.php b/app/Http/Controllers/ShoppingListController.php index 7e6e58a7..46aa16de 100644 --- a/app/Http/Controllers/ShoppingListController.php +++ b/app/Http/Controllers/ShoppingListController.php @@ -7,9 +7,10 @@ use Throwable; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Support\Facades\DB; use Kami\Cocktail\Models\UserShoppingList; use Illuminate\Http\Resources\Json\JsonResource; -use Kami\Cocktail\Http\Resources\SuccessActionResource; +use Kami\Cocktail\Http\Requests\IngredientsBatchRequest; use Kami\Cocktail\Http\Resources\UserShoppingListResource; class ShoppingListController extends Controller @@ -17,20 +18,27 @@ class ShoppingListController extends Controller public function index(Request $request): JsonResource { return UserShoppingListResource::collection( - $request->user()->shoppingList->load('ingredient') + $request->user()->getBarMembership(bar()->id)->shoppingListIngredients->load('ingredient') ); } - public function batchStore(Request $request): JsonResource + public function batchStore(IngredientsBatchRequest $request): JsonResource { - $ingredientIds = $request->post('ingredient_ids'); + $barMembership = $request->user()->getBarMembership(bar()->id); + + $ingredients = DB::table('ingredients') + ->select('id') + ->where('bar_id', $barMembership->bar_id) + ->whereIn('id', $request->post('ingredient_ids')) + ->pluck('id'); $models = []; - foreach ($ingredientIds as $ingId) { + foreach ($ingredients as $ingId) { $usl = new UserShoppingList(); $usl->ingredient_id = $ingId; + $usl->bar_membership_id = $barMembership->id; try { - $models[] = $request->user()->shoppingList()->save($usl); + $models[] = $barMembership->shoppingListIngredients()->save($usl); } catch (Throwable) { } } @@ -38,25 +46,32 @@ public function batchStore(Request $request): JsonResource return UserShoppingListResource::collection($models); } - public function batchDelete(Request $request): JsonResource + public function batchDelete(IngredientsBatchRequest $request): Response { - $ingredientIds = $request->post('ingredient_ids'); + $barMembership = $request->user()->getBarMembership(bar()->id); + + $ingredients = DB::table('ingredients') + ->select('id') + ->where('bar_id', $barMembership->bar_id) + ->whereIn('id', $request->post('ingredient_ids')) + ->pluck('id'); try { - $request->user()->shoppingList()->whereIn('ingredient_id', $ingredientIds)->delete(); + $barMembership->shoppingListIngredients()->whereIn('ingredient_id', $ingredients)->delete(); } catch (Throwable $e) { abort(500, $e->getMessage()); } - return new SuccessActionResource((object) ['ingredient_ids' => $ingredientIds]); + return response(null, 204); } public function share(Request $request): Response { + $barMembership = $request->user()->getBarMembership(bar()->id); $type = $request->get('type', 'markdown'); - $shoppingListIngredients = $request->user() - ->shoppingList + $shoppingListIngredients = $barMembership + ->shoppingListIngredients ->load('ingredient.category') ->groupBy('ingredient.category.name'); diff --git a/app/Http/Controllers/StatsController.php b/app/Http/Controllers/StatsController.php index c4b205e7..756443ed 100644 --- a/app/Http/Controllers/StatsController.php +++ b/app/Http/Controllers/StatsController.php @@ -10,6 +10,7 @@ use Kami\Cocktail\Models\Cocktail; use Kami\Cocktail\Models\Ingredient; use Kami\Cocktail\Models\UserIngredient; +use Kami\Cocktail\Models\CocktailFavorite; use Kami\Cocktail\Services\CocktailService; use Kami\Cocktail\Models\Collection as CocktailCollection; @@ -17,11 +18,15 @@ class StatsController extends Controller { public function index(CocktailService $cocktailService, Request $request): JsonResponse { + $bar = bar(); + $barMembership = $request->user()->getBarMembership(bar()->id); $limit = $request->get('limit', 5); $stats = []; $popularIngredientIds = DB::table('cocktail_ingredients') ->select('ingredient_id', DB::raw('COUNT(ingredient_id) AS cocktails_count')) + ->join('cocktails', 'cocktails.id', '=', 'cocktail_ingredients.cocktail_id') + ->where('cocktails.bar_id', $bar->id) ->groupBy('ingredient_id') ->orderBy('cocktails_count', 'desc') ->limit($limit) @@ -29,7 +34,9 @@ public function index(CocktailService $cocktailService, Request $request): JsonR $topRatedCocktailIds = DB::table('ratings') ->select('rateable_id AS cocktail_id', DB::raw('AVG(rating) AS avg_rating'), DB::raw('COUNT(*) AS votes')) + ->join('cocktails', 'cocktails.id', '=', 'ratings.rateable_id') ->where('rateable_type', Cocktail::class) + ->where('cocktails.bar_id', $bar->id) ->groupBy('rateable_id') ->orderBy('avg_rating', 'desc') ->orderBy('votes', 'desc') @@ -38,8 +45,8 @@ public function index(CocktailService $cocktailService, Request $request): JsonR $userFavoriteIngredients = DB::table('cocktail_ingredients') ->selectRaw('ingredient_id, ingredients.name, COUNT(cocktail_id) AS cocktails_count') - ->whereIn('cocktail_id', function ($query) use ($request) { - $query->from('cocktail_favorites')->select('cocktail_id')->where('user_id', $request->user()->id); + ->whereIn('cocktail_id', function ($query) use ($barMembership) { + $query->from('cocktail_favorites')->select('cocktail_id')->where('bar_membership_id', $barMembership->id); }) ->join('ingredients', 'ingredients.id', '=', 'cocktail_ingredients.ingredient_id') ->groupBy('ingredient_id') @@ -47,15 +54,16 @@ public function index(CocktailService $cocktailService, Request $request): JsonR ->limit($limit) ->get(); - $stats['total_cocktails'] = Cocktail::count(); - $stats['total_ingredients'] = Ingredient::count(); - $stats['total_shelf_cocktails'] = $cocktailService->getCocktailsByUserIngredients( - $request->user()->shelfIngredients->pluck('ingredient_id')->toArray() + $stats['total_cocktails'] = Cocktail::where('bar_id', $bar->id)->count(); + $stats['total_ingredients'] = Ingredient::where('bar_id', $bar->id)->count(); + $stats['total_favorited_cocktails'] = CocktailFavorite::where('bar_membership_id', $barMembership->id)->count(); + $stats['total_shelf_cocktails'] = $cocktailService->getCocktailsByIngredients( + $barMembership->userIngredients->pluck('ingredient_id')->toArray() )->count(); - $stats['total_shelf_ingredients'] = UserIngredient::where('user_id', $request->user()->id)->count(); + $stats['total_shelf_ingredients'] = UserIngredient::where('bar_membership_id', $barMembership->id)->count(); $stats['most_popular_ingredients'] = $popularIngredientIds; $stats['top_rated_cocktails'] = $topRatedCocktailIds; - $stats['total_collections'] = CocktailCollection::where('user_id', $request->user()->id)->count(); + $stats['total_collections'] = CocktailCollection::where('bar_membership_id', $barMembership->id)->count(); $stats['your_top_ingredients'] = $userFavoriteIngredients; return response()->json(['data' => $stats]); diff --git a/app/Http/Controllers/TagController.php b/app/Http/Controllers/TagController.php index 98cbd364..5b835bd1 100644 --- a/app/Http/Controllers/TagController.php +++ b/app/Http/Controllers/TagController.php @@ -18,26 +18,31 @@ class TagController extends Controller { public function index(): JsonResource { - $tags = Tag::orderBy('name')->withCount('cocktails')->get(); + $tags = Tag::orderBy('name')->withCount('cocktails')->filterByBar()->get(); return TagResource::collection($tags); } - public function show(int $id): JsonResource + public function show(Request $request, int $id): JsonResource { $tag = Tag::withCount('cocktails')->findOrFail($id); + if ($request->user()->cannot('show', $tag)) { + abort(403); + } + return new TagResource($tag); } public function store(TagRequest $request): JsonResponse { - if (!$request->user()->isAdmin()) { + if ($request->user()->cannot('create', Tag::class)) { abort(403); } $tag = new Tag(); $tag->name = $request->post('name'); + $tag->bar_id = bar()->id; $tag->save(); return (new TagResource($tag)) @@ -48,11 +53,12 @@ public function store(TagRequest $request): JsonResponse public function update(TagRequest $request, int $id): JsonResource { - if (!$request->user()->isAdmin()) { + $tag = Tag::findOrFail($id); + + if ($request->user()->cannot('edit', $tag)) { abort(403); } - $tag = Tag::findOrFail($id); $tag->name = $request->post('name'); $tag->save(); @@ -64,12 +70,14 @@ public function update(TagRequest $request, int $id): JsonResource public function delete(Request $request, int $id): Response { - if (!$request->user()->isAdmin()) { + $tag = Tag::findOrFail($id); + + if ($request->user()->cannot('delete', $tag)) { abort(403); } $cocktailIds = DB::table('cocktail_tag')->select('cocktail_id')->where('tag_id', $id)->pluck('cocktail_id'); - Tag::findOrFail($id)->delete(); + $tag->delete(); Cocktail::find($cocktailIds)->each(fn ($cocktail) => $cocktail->searchable()); return response(null, 204); diff --git a/app/Http/Controllers/UsersController.php b/app/Http/Controllers/UsersController.php index 4a86fa2b..97766907 100644 --- a/app/Http/Controllers/UsersController.php +++ b/app/Http/Controllers/UsersController.php @@ -11,47 +11,59 @@ use Illuminate\Support\Facades\Hash; use Kami\Cocktail\Http\Requests\UserRequest; use Kami\Cocktail\Http\Resources\UserResource; -use Kami\Cocktail\Search\SearchActionsAdapter; use Illuminate\Http\Resources\Json\JsonResource; class UsersController extends Controller { public function index(Request $request): JsonResource { - if (!$request->user()->isAdmin()) { + if ($request->user()->cannot('list', User::class)) { abort(403); } - $users = User::orderBy('id')->get(); + $users = User::orderBy('name') + ->select('users.*') + ->join('bar_memberships', 'bar_memberships.user_id', '=', 'users.id') + ->where('bar_memberships.bar_id', bar()->id) + ->get(); return UserResource::collection($users); } public function show(Request $request, int $id): JsonResource { - if (!$request->user()->isAdmin()) { + $user = User::select('users.*') + ->join('bar_memberships', 'bar_memberships.user_id', '=', 'users.id') + ->where('bar_memberships.bar_id', bar()->id) + ->where('bar_memberships.user_id', $id) + ->firstOrFail(); + + if ($request->user()->cannot('show', $user)) { abort(403); } - $user = User::findOrFail($id); - return new UserResource($user); } - public function store(SearchActionsAdapter $search, UserRequest $request): JsonResponse + public function store(UserRequest $request): JsonResponse { - if (!$request->user()->isAdmin()) { + if ($request->user()->cannot('create', User::class)) { abort(403); } - $user = new User(); - $user->name = $request->post('name'); - $user->email = $request->post('email'); - $user->email_verified_at = now(); - $user->password = Hash::make($request->post('password')); - $user->is_admin = (bool) $request->post('is_admin'); - $user->search_api_key = $search->getActions()->getPublicApiKey(); - $user->save(); + $roleId = $request->post('role_id'); + $email = $request->post('email'); + + $user = User::where('email', $email)->first(); + if ($user === null) { + $user = new User(); + $user->name = $request->post('name'); + $user->email = $request->post('email'); + $user->password = Hash::make($request->post('password')); + $user->save(); + } + + bar()->users()->save($user, ['user_role_id' => $roleId]); return (new UserResource($user)) ->response() @@ -61,18 +73,18 @@ public function store(SearchActionsAdapter $search, UserRequest $request): JsonR public function update(int $id, UserRequest $request): JsonResource { - if (!$request->user()->isAdmin() || $id === 1) { + $user = User::findOrFail($id); + + if ($request->user()->cannot('edit', $user)) { abort(403); } - $user = User::findOrFail($id); $user->name = $request->post('name'); - $user->email = $request->post('email'); - $user->email_verified_at = now(); - $user->is_admin = (bool) $request->post('is_admin'); - if ($request->has('password')) { - $user->password = Hash::make($request->post('password')); + if ($request->has('role_id') && $user->isBarAdmin(bar()->id)) { + $barMembership = $user->getBarMembership(bar()->id); + $barMembership->user_role_id = $request->post('role_id'); + $barMembership->save(); } $user->save(); @@ -82,11 +94,14 @@ public function update(int $id, UserRequest $request): JsonResource public function delete(Request $request, int $id): Response { - if (!$request->user()->isAdmin() || $id === 1) { + $user = User::findOrFail($id); + + if ($request->user()->cannot('delete', $user)) { abort(403); } - User::findOrFail($id)->delete(); + $user->tokens()->delete(); + $user->delete(); return response(null, 204); } diff --git a/app/Http/Controllers/UtensilsController.php b/app/Http/Controllers/UtensilsController.php index 085b9f1e..c0a27cae 100644 --- a/app/Http/Controllers/UtensilsController.php +++ b/app/Http/Controllers/UtensilsController.php @@ -16,27 +16,32 @@ class UtensilsController extends Controller { public function index(): JsonResource { - $utensils = Utensil::orderBy('name')->get(); + $utensils = Utensil::orderBy('name')->filterByBar()->get(); return UtensilResource::collection($utensils); } - public function show(int $id): JsonResource + public function show(Request $request, int $id): JsonResource { $utensil = Utensil::findOrFail($id); + if ($request->user()->cannot('show', $utensil)) { + abort(403); + } + return new UtensilResource($utensil); } public function store(UtensilRequest $request): JsonResponse { - if (!$request->user()->isAdmin()) { + if ($request->user()->cannot('create', Utensil::class)) { abort(403); } $utensil = new Utensil(); $utensil->name = $request->post('name'); $utensil->description = $request->post('description'); + $utensil->bar_id = bar()->id; $utensil->save(); return (new UtensilResource($utensil)) @@ -47,13 +52,15 @@ public function store(UtensilRequest $request): JsonResponse public function update(int $id, UtensilRequest $request): JsonResource { - if (!$request->user()->isAdmin()) { + $utensil = Utensil::findOrFail($id); + + if ($request->user()->cannot('edit', $utensil)) { abort(403); } - $utensil = Utensil::findOrFail($id); $utensil->name = $request->post('name'); $utensil->description = $request->post('description'); + $utensil->updated_at = now(); $utensil->save(); return new UtensilResource($utensil); @@ -61,11 +68,13 @@ public function update(int $id, UtensilRequest $request): JsonResource public function delete(Request $request, int $id): Response { - if (!$request->user()->isAdmin()) { + $utensil = Utensil::findOrFail($id); + + if ($request->user()->cannot('delete', $utensil)) { abort(403); } - Utensil::findOrFail($id)->delete(); + $utensil->delete(); return response(null, 204); } diff --git a/app/Http/Filters/CocktailQueryFilter.php b/app/Http/Filters/CocktailQueryFilter.php index dba3edbc..254e4c28 100644 --- a/app/Http/Filters/CocktailQueryFilter.php +++ b/app/Http/Filters/CocktailQueryFilter.php @@ -4,6 +4,7 @@ namespace Kami\Cocktail\Http\Filters; +use Illuminate\Support\Facades\DB; use Kami\Cocktail\Models\Cocktail; use Spatie\QueryBuilder\AllowedSort; use Spatie\QueryBuilder\QueryBuilder; @@ -19,6 +20,8 @@ public function __construct(CocktailService $cocktailService) { parent::__construct(Cocktail::query()); + $barMembership = $this->request->user()->getBarMembership(bar()->id); + $this ->allowedFilters([ AllowedFilter::exact('id'), @@ -27,27 +30,44 @@ public function __construct(CocktailService $cocktailService) AllowedFilter::exact('ingredient_id', 'ingredients.ingredient.id'), AllowedFilter::exact('tag_id', 'tags.id'), AllowedFilter::exact('collection_id', 'collections.id'), - AllowedFilter::exact('user_id'), + AllowedFilter::exact('created_user_id'), AllowedFilter::exact('glass_id'), AllowedFilter::exact('cocktail_method_id'), - AllowedFilter::callback('favorites', function ($query, $value) { + AllowedFilter::callback('favorites', function ($query, $value) use ($barMembership) { if ($value === true) { - $query->userFavorites($this->request->user()->id); + $query->userFavorites($barMembership->id); } }), AllowedFilter::callback('on_shelf', function ($query, $value) use ($cocktailService) { if ($value === true) { - $query->whereIn('cocktails.id', $cocktailService->getCocktailsByUserIngredients( - $this->request->user()->shelfIngredients->pluck('ingredient_id')->toArray() + $query->whereIn('cocktails.id', $cocktailService->getCocktailsByIngredients( + $this->request->user()->getShelfIngredients(bar()->id)->pluck('ingredient_id')->toArray() )); } }), + AllowedFilter::callback('user_shelves', function ($query, $value) use ($cocktailService) { + if (!is_array($value)) { + $value = [$value]; + } + + $ingredients = DB::table('bar_memberships') + ->select('user_ingredients.ingredient_id') + ->join('user_ingredients', 'user_ingredients.bar_membership_id', '=', 'bar_memberships.id') + ->whereIn('bar_memberships.user_id', $value) + ->where('bar_memberships.bar_id', bar()->id) + ->where('bar_memberships.is_shelf_public', true) + ->get(); + + $query->whereIn('cocktails.id', $cocktailService->getCocktailsByIngredients( + $ingredients->pluck('ingredient_id')->toArray() + )); + }), AllowedFilter::callback('shelf_ingredients', function ($query, $value) use ($cocktailService) { if (!is_array($value)) { $value = [$value]; } - $query->whereIn('cocktails.id', $cocktailService->getCocktailsByUserIngredients($value)); + $query->whereIn('cocktails.id', $cocktailService->getCocktailsByIngredients($value)); }), AllowedFilter::callback('is_public', function ($query, $value) { if ($value === true) { @@ -60,6 +80,12 @@ public function __construct(CocktailService $cocktailService) AllowedFilter::callback('user_rating_max', function ($query, $value) { $query->where('user_rating', '<=', (int) $value); }), + AllowedFilter::callback('average_rating_min', function ($query, $value) { + $query->where('average_rating', '>=', (int) $value); + }), + AllowedFilter::callback('average_rating_max', function ($query, $value) { + $query->where('average_rating', '<=', (int) $value); + }), AllowedFilter::callback('abv_min', function ($query, $value) { $query->where('abv', '>=', $value); }), @@ -86,22 +112,23 @@ public function __construct(CocktailService $cocktailService) 'abv', 'total_ingredients', 'missing_ingredients', - AllowedSort::callback('favorited_at', function ($query, bool $descending) { + AllowedSort::callback('favorited_at', function ($query, bool $descending) use ($barMembership) { $direction = $descending ? 'DESC' : 'ASC'; $query->leftJoin('cocktail_favorites AS cf', 'cf.cocktail_id', '=', 'cocktails.id') - ->where('cf.user_id', $this->request->user()->id) + ->where('cf.bar_membership_id', $barMembership->id) ->orderBy('cf.updated_at', $direction); }), ]) - ->allowedIncludes(['glass', 'method', 'user', 'collections', 'notes', 'navigation']) - ->with('ingredients.ingredient', 'images', 'tags') + ->allowedIncludes(['glass', 'method', 'user', 'navigation', 'utensils', 'createdUser', 'updatedUser']) + ->with('ingredients.ingredient', 'images', 'tags', 'ratings') ->selectRaw('cocktails.*, COUNT(ci.cocktail_id) AS total_ingredients, COUNT(ci.ingredient_id) - COUNT(ui.ingredient_id) AS missing_ingredients') ->leftJoin('cocktail_ingredients AS ci', 'ci.cocktail_id', '=', 'cocktails.id') - ->leftJoin('user_ingredients AS ui', function ($query) { - $query->on('ui.ingredient_id', '=', 'ci.ingredient_id')->where('ui.user_id', $this->request->user()->id); + ->leftJoin('user_ingredients AS ui', function ($query) use ($barMembership) { + $query->on('ui.ingredient_id', '=', 'ci.ingredient_id')->where('ui.bar_membership_id', $barMembership->id); }) ->groupBy('cocktails.id') + ->filterByBar() ->withRatings($this->request->user()->id); } } diff --git a/app/Http/Filters/CollectionQueryFilter.php b/app/Http/Filters/CollectionQueryFilter.php index 8503eebd..b943a791 100644 --- a/app/Http/Filters/CollectionQueryFilter.php +++ b/app/Http/Filters/CollectionQueryFilter.php @@ -14,6 +14,8 @@ public function __construct() { parent::__construct(ItemsCollection::query()); + $barMembership = $this->request->user()->getBarMembership(bar()->id); + $this ->allowedFilters([ AllowedFilter::exact('id'), @@ -22,6 +24,7 @@ public function __construct() ]) ->defaultSort('name') ->allowedSorts('name', 'created_at') - ->with('cocktails'); + ->with('cocktails') + ->where('bar_membership_id', $barMembership->id); } } diff --git a/app/Http/Filters/GlassQueryFilter.php b/app/Http/Filters/GlassQueryFilter.php new file mode 100644 index 00000000..b64d3e08 --- /dev/null +++ b/app/Http/Filters/GlassQueryFilter.php @@ -0,0 +1,29 @@ +allowedFilters([ + AllowedFilter::partial('name'), + ]) + ->defaultSort('name') + ->allowedSorts('name', 'created_at') + ->withCount('cocktails') + ->filterByBar(); + } +} diff --git a/app/Http/Filters/IngredientQueryFilter.php b/app/Http/Filters/IngredientQueryFilter.php index 0cb538fb..5aa96fe1 100644 --- a/app/Http/Filters/IngredientQueryFilter.php +++ b/app/Http/Filters/IngredientQueryFilter.php @@ -10,12 +10,17 @@ use Spatie\QueryBuilder\AllowedFilter; use Kami\Cocktail\Services\IngredientService; +/** + * @mixin \Kami\Cocktail\Models\Ingredient + */ final class IngredientQueryFilter extends QueryBuilder { public function __construct(IngredientService $ingredientService) { parent::__construct(Ingredient::query()); + $barMembership = $this->request->user()->getBarMembership(bar()->id); + $this ->allowedFilters([ AllowedFilter::exact('id'), @@ -23,14 +28,19 @@ public function __construct(IngredientService $ingredientService) AllowedFilter::beginsWithStrict('name_exact', 'name'), AllowedFilter::exact('category_id', 'ingredient_category_id'), AllowedFilter::partial('origin'), - AllowedFilter::exact('user_id'), - AllowedFilter::callback('on_shopping_list', function ($query) { - $usersList = $this->request->user()->shoppingList->pluck('ingredient_id'); - $query->whereIn('id', $usersList); + AllowedFilter::exact('created_user_id'), + AllowedFilter::callback('on_shopping_list', function ($query, $value) use ($barMembership) { + if ($value === true) { + $query + ->join('user_shopping_lists', 'user_shopping_lists.ingredient_id', '=', 'ingredients.id') + ->where('user_shopping_lists.bar_membership_id', $barMembership->id); + } }), - AllowedFilter::callback('on_shelf', function ($query, $value) { + AllowedFilter::callback('on_shelf', function ($query, $value) use ($barMembership) { if ($value === true) { - $query->join('user_ingredients', 'user_ingredients.ingredient_id', '=', 'ingredients.id')->where('user_ingredients.user_id', $this->request->user()->id); + $query + ->join('user_ingredients', 'user_ingredients.ingredient_id', '=', 'ingredients.id') + ->where('user_ingredients.bar_membership_id', $barMembership->id); } }), AllowedFilter::callback('strength_min', function ($query, $value) { @@ -41,7 +51,7 @@ public function __construct(IngredientService $ingredientService) }), AllowedFilter::callback('main_ingredients', function ($query, $value) use ($ingredientService) { if ($value === true) { - $query->whereIn('ingredients.id', $ingredientService->getMainIngredientsInCocktails()->pluck('ingredient_id')); + $query->whereIn('ingredients.id', $ingredientService->getMainIngredientsInCocktails(bar()->id)->pluck('ingredient_id')); } }), ]) @@ -62,6 +72,7 @@ public function __construct(IngredientService $ingredientService) ]) ->allowedIncludes(['parentIngredient', 'varieties', 'cocktails', 'cocktailIngredientSubstitutes']) ->with('category', 'images') - ->withCount('cocktails'); + ->withCount('cocktails') + ->filterByBar(); } } diff --git a/app/Http/Filters/NoteQueryFilter.php b/app/Http/Filters/NoteQueryFilter.php new file mode 100644 index 00000000..20f41411 --- /dev/null +++ b/app/Http/Filters/NoteQueryFilter.php @@ -0,0 +1,29 @@ +allowedFilters([ + AllowedFilter::callback('cocktail_id', function ($query, $value) { + $query + ->where('noteable_type', \Kami\Cocktail\Models\Cocktail::class) + ->where('noteable_id', $value); + }), + ]) + ->defaultSort('created_at') + ->allowedSorts('created_at') + ->where('user_id', $this->request->user()->id); + } +} diff --git a/app/Http/Middleware/EnsureRequestHasBarQuery.php b/app/Http/Middleware/EnsureRequestHasBarQuery.php new file mode 100644 index 00000000..adde7911 --- /dev/null +++ b/app/Http/Middleware/EnsureRequestHasBarQuery.php @@ -0,0 +1,41 @@ +get('bar_id', null); + + if (!$barId) { + abort(400, sprintf("Missing required '%s' parameter while requesting '%s'", 'bar_id', $request->path())); + } + + $bar = Cache::remember('ba:bar:' . $barId, 60 * 60 * 24, function () use ($barId) { + return Bar::findOrFail($barId); + }); + + if (!$request->user()->hasBarMembership($bar->id)) { + abort(403); + } + + app()->singleton(BarContext::class, function () use ($bar) { + return new BarContext($bar); + }); + + return $next($request); + } +} diff --git a/app/Http/Requests/BarRequest.php b/app/Http/Requests/BarRequest.php new file mode 100644 index 00000000..991ab6d5 --- /dev/null +++ b/app/Http/Requests/BarRequest.php @@ -0,0 +1,34 @@ + + */ + public function rules() + { + return [ + 'name' => 'required', + 'enable_invites' => 'boolean', + 'options' => 'array', + ]; + } +} diff --git a/app/Http/Requests/IngredientRequest.php b/app/Http/Requests/IngredientRequest.php index c5b4d549..652e138c 100644 --- a/app/Http/Requests/IngredientRequest.php +++ b/app/Http/Requests/IngredientRequest.php @@ -27,8 +27,7 @@ public function rules() { return [ 'name' => 'required', - 'ingredient_category_id' => 'required', - 'strength' => 'required|numeric', + 'strength' => 'numeric', ]; } } diff --git a/app/Http/Requests/UserIngredientBatchRequest.php b/app/Http/Requests/IngredientsBatchRequest.php similarity index 80% rename from app/Http/Requests/UserIngredientBatchRequest.php rename to app/Http/Requests/IngredientsBatchRequest.php index 8f1c4d97..aa82574c 100644 --- a/app/Http/Requests/UserIngredientBatchRequest.php +++ b/app/Http/Requests/IngredientsBatchRequest.php @@ -6,7 +6,7 @@ use Illuminate\Foundation\Http\FormRequest; -class UserIngredientBatchRequest extends FormRequest +class IngredientsBatchRequest extends FormRequest { /** * Determine if the user is authorized to make this request. @@ -27,7 +27,6 @@ public function rules() { return [ 'ingredient_ids' => 'required|array', - 'ingredient_ids.*' => 'unique:user_ingredients,ingredient_id', ]; } } diff --git a/app/Http/Requests/RatingRequest.php b/app/Http/Requests/RatingRequest.php index a065b939..03d703c4 100644 --- a/app/Http/Requests/RatingRequest.php +++ b/app/Http/Requests/RatingRequest.php @@ -4,7 +4,6 @@ namespace Kami\Cocktail\Http\Requests; -use Illuminate\Validation\Rule; use Illuminate\Foundation\Http\FormRequest; class RatingRequest extends FormRequest diff --git a/app/Http/Requests/UpdateUserRequest.php b/app/Http/Requests/UpdateUserRequest.php index d25d2c53..7999ac24 100644 --- a/app/Http/Requests/UpdateUserRequest.php +++ b/app/Http/Requests/UpdateUserRequest.php @@ -30,6 +30,7 @@ public function rules() 'email' => ['required', Rule::unique('users')->ignore($this->user()->id)], 'name' => 'required', 'password' => 'confirmed|nullable', + 'is_shelf_public' => 'boolean', ]; } } diff --git a/app/Http/Requests/UserRequest.php b/app/Http/Requests/UserRequest.php index b0d1eaea..d3459852 100644 --- a/app/Http/Requests/UserRequest.php +++ b/app/Http/Requests/UserRequest.php @@ -6,6 +6,8 @@ use Kami\Cocktail\Models\User; use Illuminate\Validation\Rule; +use Illuminate\Validation\Rules\Enum; +use Kami\Cocktail\Models\UserRoleEnum; use Illuminate\Foundation\Http\FormRequest; class UserRequest extends FormRequest @@ -31,11 +33,11 @@ public function rules() $rules = [ 'name' => 'required', + 'role_id' => ['required', new Enum(UserRoleEnum::class)], 'email' => [ 'required', 'email', ], - 'is_admin' => 'required|boolean', ]; if ($this->isMethod('POST')) { diff --git a/app/Http/Resources/BarMembershipResource.php b/app/Http/Resources/BarMembershipResource.php new file mode 100644 index 00000000..e622806c --- /dev/null +++ b/app/Http/Resources/BarMembershipResource.php @@ -0,0 +1,29 @@ + + */ + public function toArray($request) + { + return [ + 'user_id' => $this->user->id, + 'user_name' => $this->user->name, + 'bar_id' => $this->bar_id, + 'is_shelf_public' => $this->is_shelf_public, + ]; + } +} diff --git a/app/Http/Resources/BarResource.php b/app/Http/Resources/BarResource.php new file mode 100644 index 00000000..ef5c6763 --- /dev/null +++ b/app/Http/Resources/BarResource.php @@ -0,0 +1,44 @@ + + */ + public function toArray($request) + { + $search = app(SearchActionsAdapter::class); + + return [ + 'id' => $this->id, + 'name' => $this->name, + 'subtitle' => $this->subtitle, + 'description' => $this->description, + 'invite_code' => $this->invite_code, + 'search_driver_host' => $search->getActions()->getHost(), + 'search_driver_api_key' => $search->getActions()->getBarSearchApiKey($this->id), + 'created_at' => $this->created_at->toJson(), + 'updated_at' => $this->updated_at?->toJson() ?? null, + 'created_user' => new UserBasicResource($this->whenLoaded('createdUser')), + 'updated_user' => new UserBasicResource($this->whenLoaded('updatedUser')), + 'access' => [ + 'role_id' => $this->memberships->where('user_id', $request->user()->id)->first()->user_role_id, + 'can_edit' => $request->user()->can('edit', $this->resource), + 'can_delete' => $request->user()->can('delete', $this->resource), + ] + ]; + } +} diff --git a/app/Http/Resources/CocktailIngredientResource.php b/app/Http/Resources/CocktailIngredientResource.php index 98079114..966159d1 100644 --- a/app/Http/Resources/CocktailIngredientResource.php +++ b/app/Http/Resources/CocktailIngredientResource.php @@ -22,12 +22,14 @@ public function toArray($request) return [ 'sort' => $this->sort, 'amount' => $this->amount, + 'amount_max' => $this->amount_max, 'units' => $this->units, 'optional' => (bool) $this->optional, 'ingredient_id' => $this->ingredient_id, 'name' => $this->ingredient->name, 'ingredient_slug' => $this->ingredient->slug, 'substitutes' => CocktailIngredientSubstituteResource::collection($this->whenLoaded('substitutes')), + 'note' => $this->note, ]; } } diff --git a/app/Http/Resources/CocktailIngredientSubstituteResource.php b/app/Http/Resources/CocktailIngredientSubstituteResource.php index 84ba7368..d48861a7 100644 --- a/app/Http/Resources/CocktailIngredientSubstituteResource.php +++ b/app/Http/Resources/CocktailIngredientSubstituteResource.php @@ -23,6 +23,9 @@ public function toArray($request) 'id' => $this->ingredient_id, 'slug' => $this->ingredient->slug, 'name' => $this->ingredient->name, + 'amount' => $this->amount, + 'amount_max' => $this->amount_max, + 'units' => $this->units, ]; } } diff --git a/app/Http/Resources/CocktailResource.php b/app/Http/Resources/CocktailResource.php index 55f8482b..364f10b2 100644 --- a/app/Http/Resources/CocktailResource.php +++ b/app/Http/Resources/CocktailResource.php @@ -29,8 +29,8 @@ public function toArray($request) 'garnish' => e($this->garnish), 'description' => e($this->description), 'source' => $this->source, - 'has_public_link' => $this->public_id !== null, 'public_id' => $this->public_id, + 'public_at' => $this->public_at?->toJson() ?? null, 'main_image_id' => $this->images->sortBy('sort')->first()->id ?? null, 'images' => ImageResource::collection($this->images), 'tags' => $this->tags->map(function ($tag) { @@ -39,17 +39,26 @@ public function toArray($request) 'name' => $tag->name, ]; }), - 'user_rating' => $this->user_rating ?? null, - 'average_rating' => (int) round($this->average_rating ?? 0), + 'rating' => [ + 'user' => $this->user_rating ?? null, + 'average' => (int) round($this->average_rating ?? 0), + 'total_votes' => $this->totalRatedCount(), + ], 'glass' => new GlassResource($this->whenLoaded('glass')), 'utensils' => UtensilResource::collection($this->whenLoaded('utensils')), 'ingredients' => CocktailIngredientResource::collection($this->whenLoaded('ingredients')), - 'created_at' => $this->created_at->toDateTimeString(), + 'created_at' => $this->created_at->toJson(), + 'updated_at' => $this->updated_at->toJson(), 'method' => new CocktailMethodResource($this->whenLoaded('method')), - 'collections' => CocktailCollectionResource::collection($this->whenLoaded('collections')), 'abv' => $this->abv, - 'notes' => NoteResource::collection($this->whenLoaded('notes')), - 'user' => new UserBasicResource($this->whenLoaded('user')), + 'created_user' => new UserBasicResource($this->whenLoaded('createdUser')), + 'updated_user' => new UserBasicResource($this->whenLoaded('updatedUser')), + 'access' => [ + 'can_edit' => $request->user()->can('edit', $this->resource), + 'can_delete' => $request->user()->can('delete', $this->resource), + 'can_rate' => $request->user()->can('rate', $this->resource), + 'can_add_note' => $request->user()->can('addNote', $this->resource), + ], 'navigation' => $this->when($loadNavigation, function () { return [ 'prev' => $this->getPrevSlug(), diff --git a/app/Http/Resources/ExploreCocktailResource.php b/app/Http/Resources/ExploreCocktailResource.php index 8b4b37a5..7e6a2440 100644 --- a/app/Http/Resources/ExploreCocktailResource.php +++ b/app/Http/Resources/ExploreCocktailResource.php @@ -36,6 +36,7 @@ public function toArray($request) return [ 'name' => $cocktailIngredient->ingredient->name, 'amount' => $cocktailIngredient->amount, + 'amount_max' => $cocktailIngredient->amount_max, 'units' => $cocktailIngredient->units, 'optional' => (bool) $cocktailIngredient->optional, 'substitutes' => $cocktailIngredient->substitutes->pluck('ingredient.name') diff --git a/app/Http/Resources/IngredientCategoryResource.php b/app/Http/Resources/IngredientCategoryResource.php index 50c4eca3..eb9f22dc 100644 --- a/app/Http/Resources/IngredientCategoryResource.php +++ b/app/Http/Resources/IngredientCategoryResource.php @@ -23,6 +23,7 @@ public function toArray($request) 'id' => $this->id, 'name' => $this->name, 'description' => $this->description, + 'ingredients_count' => $this->whenCounted('ingredients'), ]; } } diff --git a/app/Http/Resources/IngredientResource.php b/app/Http/Resources/IngredientResource.php index 15c0b5f0..a7198ca6 100644 --- a/app/Http/Resources/IngredientResource.php +++ b/app/Http/Resources/IngredientResource.php @@ -55,7 +55,13 @@ public function toArray($request) 'name' => $c->name, ]; })->sortBy('name')->toArray(); - }) + }), + 'created_user' => new UserBasicResource($this->whenLoaded('createdUser')), + 'updated_user' => new UserBasicResource($this->whenLoaded('updatedUser')), + 'access' => [ + 'can_edit' => $request->user()->can('edit', $this->resource), + 'can_delete' => $request->user()->can('delete', $this->resource), + ], ]; } } diff --git a/app/Http/Resources/ProfileResource.php b/app/Http/Resources/ProfileResource.php index aa087137..7b5035eb 100644 --- a/app/Http/Resources/ProfileResource.php +++ b/app/Http/Resources/ProfileResource.php @@ -4,7 +4,6 @@ namespace Kami\Cocktail\Http\Resources; -use Kami\Cocktail\Search\SearchActionsAdapter; use Illuminate\Http\Resources\Json\JsonResource; /** @@ -12,11 +11,6 @@ */ class ProfileResource extends JsonResource { - public function __construct($resource, private SearchActionsAdapter $adapter) - { - parent::__construct($resource); - } - /** * Transform the resource into an array. * @@ -29,12 +23,7 @@ public function toArray($request) 'id' => $this->id, 'name' => $this->name, 'email' => $this->email, - 'is_admin' => $this->isAdmin(), - 'search_host' => $this->adapter->getActions()->getHost(), - 'search_api_key' => $this->search_api_key, - 'favorite_cocktails' => $this->favorites->pluck('cocktail_id'), - 'shelf_ingredients' => $this->shelfIngredients->pluck('ingredient_id'), - 'shopping_lists' => $this->shoppingList->pluck('ingredient_id'), + 'memberships' => BarMembershipResource::collection($this->memberships), ]; } } diff --git a/app/Http/Resources/RatingResource.php b/app/Http/Resources/RatingResource.php index a78621c2..8d26a8a7 100644 --- a/app/Http/Resources/RatingResource.php +++ b/app/Http/Resources/RatingResource.php @@ -20,7 +20,6 @@ class RatingResource extends JsonResource public function toArray($request) { return [ - 'id' => $this->id, 'rateable_id' => $this->rateable_id, 'user_id' => $this->user_id, 'rating' => $this->rating, diff --git a/app/Http/Resources/SuccessActionResource.php b/app/Http/Resources/SuccessActionResource.php index 036d424a..f6b4b3a7 100644 --- a/app/Http/Resources/SuccessActionResource.php +++ b/app/Http/Resources/SuccessActionResource.php @@ -16,6 +16,6 @@ class SuccessActionResource extends JsonResource */ public function toArray($request) { - return array_merge((array) $this->resource, ['success' => true]); + return array_merge((array) $this->resource, []); } } diff --git a/app/Http/Resources/TokenResource.php b/app/Http/Resources/TokenResource.php new file mode 100644 index 00000000..4f608c19 --- /dev/null +++ b/app/Http/Resources/TokenResource.php @@ -0,0 +1,26 @@ + + */ + public function toArray($request) + { + return [ + 'token' => $this->plainTextToken, + ]; + } +} diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 55026cb5..7f9e7cd8 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -4,6 +4,7 @@ namespace Kami\Cocktail\Http\Resources; +use Kami\Cocktail\Models\BarMembership; use Illuminate\Http\Resources\Json\JsonResource; /** @@ -19,12 +20,19 @@ class UserResource extends JsonResource */ public function toArray($request) { + $bar = bar(); + return [ 'id' => $this->id, 'name' => $this->name, 'email' => $this->email, - 'is_admin' => (bool) $this->is_admin, - 'search_api_key' => $this->search_api_key, + 'role' => $this->memberships->where('bar_id', $bar->id)->map(function (BarMembership $membership) { + return [ + 'bar_id' => $membership->bar_id, + 'role_id' => $membership->role->id ?? null, + 'role_name' => $membership->role->name ?? null, + ]; + })->first(), ]; } } diff --git a/app/Http/Resources/UserShoppingListResource.php b/app/Http/Resources/UserShoppingListResource.php index 1ee59104..d9c9ca5c 100644 --- a/app/Http/Resources/UserShoppingListResource.php +++ b/app/Http/Resources/UserShoppingListResource.php @@ -20,13 +20,9 @@ class UserShoppingListResource extends JsonResource public function toArray($request) { return [ - 'id' => $this->id, - 'user_id' => $this->user_id, - 'ingredient' => [ - 'id' => $this->ingredient_id, - 'slug' => $this->ingredient->slug, - 'name' => $this->ingredient->name, - ] + 'ingredient_id' => $this->ingredient_id, + 'ingredient_slug' => $this->ingredient->slug, + 'ingredient_name' => $this->ingredient->name, ]; } } diff --git a/app/Import/DuplicateActionsEnum.php b/app/Import/DuplicateActionsEnum.php new file mode 100644 index 00000000..f75e1ddd --- /dev/null +++ b/app/Import/DuplicateActionsEnum.php @@ -0,0 +1,12 @@ + $sourceData + */ + public function process( + array $sourceData, + int $userId, + int $barId, + DuplicateActionsEnum $duplicateAction = DuplicateActionsEnum::None + ): Cocktail { + if ($duplicateAction === DuplicateActionsEnum::Skip) { + $existingCocktail = Cocktail::whereRaw('LOWER(name) = ?', [strtolower($sourceData['name'])])->first(); + if ($existingCocktail !== null) { + return $existingCocktail; + } + } + + $dbIngredients = DB::table('ingredients')->select('id', DB::raw('LOWER(name) AS name'))->where('bar_id', $barId)->get()->keyBy('name'); + $dbGlasses = DB::table('glasses')->select('id', DB::raw('LOWER(name) AS name'))->where('bar_id', $barId)->get()->keyBy('name'); + $dbMethods = DB::table('cocktail_methods')->select('id', DB::raw('LOWER(name) AS name'))->where('bar_id', $barId)->get()->keyBy('name'); + + $defaultDescription = 'Created from "' . $sourceData['source'] . '"'; + + // Add images + $cocktailImages = []; + foreach ($sourceData['images'] ?? [] as $image) { + $imageSource = null; + if (array_key_exists('url', $image)) { + $imageSource = $image['url']; + } + + if ($imageSource) { + try { + $imageDTO = new Image( + ImageProcessor::make($imageSource), + $image['copyright'] ?? null + ); + + $cocktailImages[] = $this->imageService->uploadAndSaveImages([$imageDTO], 1)[0]->id; + } catch (Throwable $e) { + Log::error($e->getMessage()); + } + } + } + + // Match glass + $glassId = null; + if ($sourceData['glass']) { + $glassNameLower = strtolower($sourceData['glass']); + if ($dbGlasses->has($glassNameLower)) { + $glassId = $dbGlasses->get($glassNameLower)->id; + } elseif ($sourceData['glass'] !== null) { + $newGlass = new Glass(); + $newGlass->name = ucfirst($sourceData['glass']); + $newGlass->description = $defaultDescription; + $newGlass->bar_id = $barId; + $newGlass->save(); + $dbGlasses->put($glassNameLower, $newGlass->id); + $glassId = $newGlass->id; + } + } + + // Match method + $methodId = null; + if ($sourceData['method']) { + $methodNameLower = strtolower($sourceData['method']); + if ($dbMethods->has($methodNameLower)) { + $methodId = $dbMethods->get($methodNameLower)->id; + } + } + + // Match ingredients + $ingredients = []; + $sort = 1; + foreach ($sourceData['ingredients'] as $scrapedIngredient) { + if ($dbIngredients->has(strtolower($scrapedIngredient['name']))) { + $ingredientId = $dbIngredients->get(strtolower($scrapedIngredient['name']))->id; + } else { + $ingredientDTO = new IngredientDTO( + $barId, + ucfirst($scrapedIngredient['name']), + $userId, + null, + $scrapedIngredient['strength'] ?? 0.0, + $scrapedIngredient['description'] ?? $defaultDescription, + $scrapedIngredient['origin'] ?? null + ); + $newIngredient = $this->ingredientService->createIngredient($ingredientDTO); + $dbIngredients->put(strtolower($scrapedIngredient['name']), $newIngredient); + $ingredientId = $newIngredient->id; + } + + $substitutes = []; + if (array_key_exists('substitutes', $scrapedIngredient) && !empty($scrapedIngredient['substitutes'])) { + foreach ($scrapedIngredient['substitutes'] as $substituteName) { + if ($dbIngredients->has(strtolower($substituteName))) { + $substitutes[] = new SubstituteDTO($dbIngredients->get(strtolower($substituteName))->id); + } else { + $ingredientDTO = new IngredientDTO( + $barId, + ucfirst($substituteName), + $userId, + ); + $newIngredient = $this->ingredientService->createIngredient($ingredientDTO); + $dbIngredients->put(strtolower($substituteName), $newIngredient); + $substitutes[] = new SubstituteDTO($newIngredient->id); + } + } + } + + $ingredient = new CocktailIngredientDTO( + $ingredientId, + $scrapedIngredient['name'], + $scrapedIngredient['amount'], + $scrapedIngredient['units'], + $sort, + $scrapedIngredient['optional'] ?? false, + $substitutes, + $scrapedIngredient['amount_max'] ?? null, + $scrapedIngredient['note'] ?? null, + ); + + $ingredients[] = $ingredient; + $sort++; + } + + $cocktailDTO = new CocktailDTO( + $sourceData['name'], + $sourceData['instructions'], + $userId, + $barId, + $sourceData['description'], + $sourceData['source'], + $sourceData['garnish'], + $glassId, + $methodId, + $sourceData['tags'], + $ingredients, + $cocktailImages, + ); + + if ($duplicateAction === DuplicateActionsEnum::Overwrite) { + $existingCocktail = DB::table('cocktails')->select('id')->whereRaw('LOWER(name) = ?', [strtolower($sourceData['name'])])->first(); + if ($existingCocktail !== null) { + return $this->cocktailService->updateCocktail($existingCocktail->id, $cocktailDTO); + } + } + + return $this->cocktailService->createCocktail($cocktailDTO); + } +} diff --git a/app/Import/FromCollection.php b/app/Import/FromCollection.php new file mode 100644 index 00000000..63dce595 --- /dev/null +++ b/app/Import/FromCollection.php @@ -0,0 +1,33 @@ +where('user_id', $userId)->firstOrFail(); + + $collection = new CocktailCollection(); + $collection->name = $sourceData['name']; + $collection->description = $sourceData['description']; + $collection->bar_membership_id = $barMembership->id; + $collection->save(); + + foreach ($sourceData['cocktails'] as $cocktail) { + $cocktail = $this->fromArrayImporter->process($cocktail, $userId, $barId, $duplicateAction); + $cocktail->addToCollection($collection); + } + + return $collection; + } +} diff --git a/app/Import/FromLocalData.php b/app/Import/FromLocalData.php new file mode 100644 index 00000000..3fd214ac --- /dev/null +++ b/app/Import/FromLocalData.php @@ -0,0 +1,225 @@ +importBaseData('glasses', resource_path('/data/base_glasses.yml'), $bar->id); + $this->importBaseData('cocktail_methods', resource_path('/data/base_methods.yml'), $bar->id); + $this->importBaseData('utensils', resource_path('/data/base_utensils.yml'), $bar->id); + $this->importBaseData('ingredient_categories', resource_path('/data/base_ingredient_categories.yml'), $bar->id); + + if (in_array('ingredients', $flags)) { + $this->importIngredients(resource_path('/data/base_ingredients.yml'), $bar, $user); + } + + if (in_array('ingredients', $flags) && in_array('cocktails', $flags)) { + $this->importBaseCocktails(resource_path('/data/base_cocktails.yml'), $bar, $user); + } + + /** @phpstan-ignore-next-line */ + Ingredient::where('bar_id', $bar->id)->searchable(); + /** @phpstan-ignore-next-line */ + Cocktail::where('bar_id', $bar->id)->searchable(); + + return true; + } + + private function importBaseData(string $tableName, string $filepath, int $barId): void + { + $data = Cache::remember('ba:data-import:' . $filepath, 60 * 60 * 24 * 7, function () use ($filepath) { + return Yaml::parseFile($filepath); + }); + + $importData = array_map(function (array $item) use ($barId) { + $item['bar_id'] = $barId; + $item['created_at'] = now(); + $item['updated_at'] = now(); + + return $item; + }, $data); + + DB::table($tableName)->insert($importData); + } + + private function importIngredients(string $filepath, Bar $bar, User $user): void + { + $ingredients = Cache::remember('ba:data-import:' . $filepath, 60 * 60 * 24 * 7, function () use ($filepath) { + return Yaml::parseFile($filepath); + }); + + $categories = DB::table('ingredient_categories')->select('id', 'name')->where('bar_id', $bar->id)->get(); + + $ingredientsToInsert = []; + $imagesToInsert = []; + $imagesBasePath = 'ingredients/' . $bar->id . '/'; + + foreach ($ingredients as $ingredient) { + $category = $categories->firstWhere('name', $ingredient['category']); + $slug = Str::slug($ingredient['name']) . '-' . $bar->id; + $ingredientsToInsert[] = [ + 'bar_id' => $bar->id, + 'slug' => $slug, + 'name' => $ingredient['name'], + 'ingredient_category_id' => $category->id ?? null, + 'strength' => $ingredient['strength'], + 'description' => $ingredient['description'], + 'origin' => $ingredient['origin'], + 'color' => $ingredient['color'], + 'created_user_id' => $user->id, + 'created_at' => now(), + 'updated_at' => now(), + ]; + + // For performance, manually copy the files and create image references + if (isset($ingredient['images'][0]['resource_path'])) { + $fullImagePath = resource_path('data/' . $ingredient['images'][0]['resource_path']); + if (!file_exists($fullImagePath)) { + continue; + } + + $disk = Storage::disk('bar-assistant'); + + $disk->makeDirectory($imagesBasePath); + + $imageFilePath = $imagesBasePath . $slug . '_' . Str::random(6) . '.png'; + copy( + $fullImagePath, + $disk->path($imageFilePath) + ); + + $imagesToInsert[$slug] = [ + 'copyright' => $ingredient['images'][0]['copyright'] ?? null, + 'file_path' => $imageFilePath, + 'file_extension' => 'png', + 'created_user_id' => $user->id, + 'sort' => 1, + 'placeholder_hash' => $ingredient['images'][0]['placeholder_hash'] ?? null, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + } + + DB::table('ingredients')->insert($ingredientsToInsert); + + $ingredients = DB::table('ingredients')->where('bar_id', $bar->id)->get(); + foreach ($ingredients as $ingredient) { + if (array_key_exists($ingredient->slug, $imagesToInsert)) { + $imagesToInsert[$ingredient->slug]['imageable_type'] = \Kami\Cocktail\Models\Ingredient::class; + $imagesToInsert[$ingredient->slug]['imageable_id'] = $ingredient->id; + } + } + + DB::table('images')->insert(array_values($imagesToInsert)); + } + + private function importBaseCocktails(string $filepath, Bar $bar, User $user): void + { + $cocktails = Cache::remember('ba:data-import:' . $filepath, 60 * 60 * 24 * 7, function () use ($filepath) { + return Yaml::parseFile($filepath); + }); + + $dbIngredients = DB::table('ingredients')->select('id', DB::raw('LOWER(name) AS name'))->where('bar_id', $bar->id)->get()->keyBy('name')->map(fn ($row) => $row->id)->toArray(); + $dbGlasses = DB::table('glasses')->select('id', DB::raw('LOWER(name) AS name'))->where('bar_id', $bar->id)->get()->keyBy('name')->map(fn ($row) => $row->id)->toArray(); + $dbMethods = DB::table('cocktail_methods')->select('id', DB::raw('LOWER(name) AS name'))->where('bar_id', $bar->id)->get()->keyBy('name')->map(fn ($row) => $row->id)->toArray(); + + $cocktailIngredientsToInsert = []; + $imagesToInsert = []; + $tagsToInsert = []; + $imagesBasePath = 'cocktails/' . $bar->id . '/'; + + foreach ($cocktails as $cocktail) { + $slug = Str::slug($cocktail['name']) . '-' . $bar->id; + + $cocktailId = DB::table('cocktails')->insertGetId([ + 'slug' => $slug, + 'name' => $cocktail['name'], + 'instructions' => $cocktail['instructions'], + 'description' => $cocktail['description'] ?? null, + 'garnish' => $cocktail['garnish'] ?? null, + 'source' => $cocktail['source'] ?? null, + 'abv' => $cocktail['abv'] ?? null, + 'created_user_id' => $user->id, + 'glass_id' => $dbGlasses[strtolower($cocktail['glass'])] ?? null, + 'cocktail_method_id' => $dbMethods[strtolower($cocktail['method'])] ?? null, + 'bar_id' => $bar->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + foreach ($cocktail['tags'] as $tag) { + $tag = Tag::firstOrCreate([ + 'name' => trim($tag), + 'bar_id' => $bar->id, + ]); + $tagsToInsert[] = [ + 'tag_id' => $tag->id, + 'cocktail_id' => $cocktailId, + ]; + } + + $sort = 1; + foreach ($cocktail['ingredients'] as $cocktailIngredient) { + $cocktailIngredientsToInsert[] = [ + 'cocktail_id' => $cocktailId, + 'ingredient_id' => $dbIngredients[strtolower($cocktailIngredient['name'])] ?? null, + 'amount' => $cocktailIngredient['amount'], + 'units' => $cocktailIngredient['units'], + 'optional' => $cocktailIngredient['optional'], + 'sort' => $sort, + ]; + $sort++; + } + + // For performance, manually copy the files and create image references + if (isset($cocktail['images'][0]['resource_path'])) { + $fullImagePath = resource_path('data/' . $cocktail['images'][0]['resource_path']); + if (!file_exists($fullImagePath)) { + continue; + } + $disk = Storage::disk('bar-assistant'); + + $disk->makeDirectory($imagesBasePath); + + $imageFilePath = $imagesBasePath . $slug . '_' . Str::random(6) . '.jpg'; + copy( + $fullImagePath, + $disk->path($imageFilePath) + ); + + $imagesToInsert[$slug] = [ + 'imageable_type' => \Kami\Cocktail\Models\Cocktail::class, + 'imageable_id' => $cocktailId, + 'copyright' => $cocktail['images'][0]['copyright'] ?? null, + 'file_path' => $imageFilePath, + 'file_extension' => 'jpg', + 'created_user_id' => $user->id, + 'sort' => 1, + 'placeholder_hash' => $cocktail['images'][0]['placeholder_hash'] ?? null, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + } + + DB::table('cocktail_ingredients')->insert($cocktailIngredientsToInsert); + DB::table('images')->insert($imagesToInsert); + DB::table('cocktail_tag')->insert($tagsToInsert); + } +} diff --git a/app/Import/FromVersion2.php b/app/Import/FromVersion2.php new file mode 100644 index 00000000..8b602d80 --- /dev/null +++ b/app/Import/FromVersion2.php @@ -0,0 +1,368 @@ +table('users')->get(); + foreach ($oldUsers as $oldUser) { + if ($oldUser->id === 1) { + continue; + } + + $userId = DB::table('users')->insertGetId([ + 'name' => $oldUser->name, + 'email' => $oldUser->email, + 'email_verified_at' => $oldUser->email_verified_at, + 'password' => Hash::needsRehash($oldUser->password) ? null : $oldUser->password, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + if ($oldUser->is_admin && $newAdminId === null) { + $newAdminId = $userId; + } + + $newUsers[$oldUser->id] = $userId; + } + + // Create a new bar + $barId = DB::table('bars')->insertGetId([ + 'name' => 'My migrated bar', + 'description' => 'Bar with data migrated from Bar Assistant v2', + 'created_user_id' => $newAdminId, + 'is_active' => true, + 'created_at' => now(), + 'invite_code' => (string) new Ulid(), + ]); + + // Add new users to bar + $barMemberships = []; + foreach ($newUsers as $newUserId) { + $barMemberships[$newUserId] = DB::table('bar_memberships')->insertGetId([ + 'bar_id' => $barId, + 'user_id' => $newUserId, + 'user_role_id' => $newUserId === $newAdminId ? UserRoleEnum::Admin->value : UserRoleEnum::General->value, + 'created_at' => now(), + ]); + } + + // Migrate glasses + $newGlasses = []; + $oldGlasses = $backupDB->table('glasses')->get(); + foreach ($oldGlasses as $row) { + $newGlasses[$row->id] = DB::table('glasses')->insertGetId([ + 'bar_id' => $barId, + 'name' => $row->name, + 'description' => $row->description, + 'created_at' => $row->created_at ?? now(), + 'updated_at' => now(), + ]); + } + + // Migrate methods + $newMethods = []; + $oldMethods = $backupDB->table('cocktail_methods')->get(); + foreach ($oldMethods as $row) { + $newMethods[$row->id] = DB::table('cocktail_methods')->insertGetId([ + 'bar_id' => $barId, + 'name' => $row->name, + 'description' => $row->description, + 'dilution_percentage' => $row->dilution_percentage, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + // Migrate utensils + $newUtensils = []; + $oldUtensils = $backupDB->table('utensils')->get(); + foreach ($oldUtensils as $row) { + $newUtensils[$row->id] = DB::table('utensils')->insertGetId([ + 'bar_id' => $barId, + 'name' => $row->name, + 'description' => $row->description, + 'created_at' => $row->created_at ?? now(), + 'updated_at' => now(), + ]); + } + + // Migrate categories + $newCategories = []; + $oldCategories = $backupDB->table('ingredient_categories')->get(); + foreach ($oldCategories as $row) { + $newCategories[$row->id] = DB::table('ingredient_categories')->insertGetId([ + 'bar_id' => $barId, + 'name' => $row->name, + 'description' => $row->description, + 'created_at' => $row->created_at ?? now(), + 'updated_at' => now(), + ]); + } + + // Migrate tags + $newTags = []; + $oldTags = $backupDB->table('tags')->get(); + foreach ($oldTags as $row) { + $newTags[$row->id] = DB::table('tags')->insertGetId([ + 'bar_id' => $barId, + 'name' => $row->name, + ]); + } + + // Migrate ingredients + $newIngredients = []; + $oldIngredients = $backupDB->table('ingredients')->get(); + foreach ($oldIngredients as $row) { + $newIngredients[$row->id] = DB::table('ingredients')->insertGetId([ + 'bar_id' => $barId, + 'slug' => $row->slug . '-' . $barId, + 'name' => $row->name, + 'ingredient_category_id' => $newCategories[$row->ingredient_category_id] ?? null, + 'strength' => $row->strength, + 'description' => $row->description, + 'origin' => $row->origin, + 'color' => $row->color, + 'created_user_id' => $newUsers[$row->user_id] ?? $newAdminId, + 'created_at' => $row->created_at ?? now(), + 'updated_at' => now(), + ]); + } + $oldIngredientsWithParent = $backupDB->table('ingredients')->whereNotNull('parent_ingredient_id')->get(); + foreach ($oldIngredientsWithParent as $row) { + DB::table('ingredients') + ->where('id', $newIngredients[$row->id]) + ->update([ + 'parent_ingredient_id' => $newIngredients[$row->parent_ingredient_id] + ]); + } + + // Migrate cocktails + $newCocktails = []; + $newCocktailIngredients = []; + $oldCocktails = $backupDB->table('cocktails')->get(); + foreach ($oldCocktails as $row) { + $newCocktails[$row->id] = DB::table('cocktails')->insertGetId([ + 'bar_id' => $barId, + 'slug' => $row->slug . '-' . $barId, + 'name' => $row->name, + 'instructions' => $row->instructions, + 'description' => $row->description, + 'garnish' => $row->garnish, + 'source' => $row->source, + 'abv' => $row->abv, + 'public_id' => $row->public_id, + 'public_at' => $row->public_at, + 'public_expires_at' => $row->public_expires_at, + 'glass_id' => $newGlasses[$row->glass_id] ?? null, + 'cocktail_method_id' => $newMethods[$row->cocktail_method_id] ?? null, + 'created_user_id' => $newUsers[$row->user_id] ?? $newAdminId, + 'created_at' => $row->created_at ?? now(), + 'updated_at' => now(), + ]); + + $oldCocktailIngredients = $backupDB->table('cocktail_ingredients')->where('cocktail_id', $row->id)->get(); + foreach ($oldCocktailIngredients as $oldCocktailIngredientRow) { + $newCocktailIngredients[$oldCocktailIngredientRow->id] = DB::table('cocktail_ingredients')->insertGetId([ + 'cocktail_id' => $newCocktails[$row->id], + 'ingredient_id' => $newIngredients[$oldCocktailIngredientRow->ingredient_id], + 'amount' => $oldCocktailIngredientRow->amount, + 'units' => $oldCocktailIngredientRow->units, + 'optional' => $oldCocktailIngredientRow->optional, + 'sort' => $oldCocktailIngredientRow->sort, + ]); + } + } + + // Migrate substitutes + $newSubs = []; + $oldSubs = $backupDB->table('cocktail_ingredient_substitutes')->get(); + foreach ($oldSubs as $row) { + $newSubs[] = [ + 'cocktail_ingredient_id' => $newCocktailIngredients[$row->cocktail_ingredient_id], + 'ingredient_id' => $newIngredients[$row->ingredient_id], + 'created_at' => $row->created_at ?? now(), + 'updated_at' => now(), + ]; + } + DB::table('cocktail_ingredient_substitutes')->insert($newSubs); + + // Migrate cocktail tags + $newCocktailTags = []; + $oldCocktailTags = $backupDB->table('cocktail_tag')->get(); + foreach ($oldCocktailTags as $row) { + $newCocktailTags[] = [ + 'cocktail_id' => $newCocktails[$row->cocktail_id], + 'tag_id' => $newTags[$row->tag_id], + ]; + } + DB::table('cocktail_tag')->insert($newCocktailTags); + + // Migrate cocktail favorites + $newCocktailFavorites = []; + $oldCocktailFavorites = $backupDB->table('cocktail_favorites')->get(); + foreach ($oldCocktailFavorites as $row) { + $newCocktailFavorites[] = [ + 'bar_membership_id' => $barMemberships[$newUsers[$row->user_id]], + 'cocktail_id' => $newCocktails[$row->cocktail_id], + 'created_at' => $row->created_at ?? now(), + 'updated_at' => $row->updated_at ?? now(), + ]; + } + DB::table('cocktail_favorites')->insert($newCocktailFavorites); + + // Migrate shelf ingredients + $newShelfIngredients = []; + $oldShelfIngredients = $backupDB->table('user_ingredients')->get(); + foreach ($oldShelfIngredients as $row) { + $newShelfIngredients[] = [ + 'bar_membership_id' => $barMemberships[$newUsers[$row->user_id]], + 'ingredient_id' => $newIngredients[$row->ingredient_id], + ]; + } + DB::table('user_ingredients')->insert($newShelfIngredients); + + // Migrate shopping list + $newList = []; + $oldList = $backupDB->table('user_shopping_lists')->get(); + foreach ($oldList as $row) { + $newList[] = [ + 'bar_membership_id' => $barMemberships[$newUsers[$row->user_id]], + 'ingredient_id' => $newIngredients[$row->ingredient_id], + ]; + } + DB::table('user_shopping_lists')->insert($newList); + + // Migrate ratings + $newRatings = []; + $oldRatings = $backupDB->table('ratings')->get(); + foreach ($oldRatings as $row) { + $newRatings[] = [ + 'rateable_type' => $row->rateable_type, + 'rateable_id' => $newCocktails[$row->rateable_id], + 'user_id' => $newUsers[$row->user_id], + 'rating' => $row->rating, + 'created_at' => $row->created_at ?? now(), + 'updated_at' => now(), + ]; + } + DB::table('ratings')->insert($newRatings); + + // Migrate notes + $newNotes = []; + $oldNotes = $backupDB->table('notes')->get(); + foreach ($oldNotes as $row) { + $newNotes[] = [ + 'noteable_type' => $row->noteable_type, + 'noteable_id' => $newCocktails[$row->noteable_id], + 'user_id' => $newUsers[$row->user_id], + 'note' => $row->note, + 'created_at' => $row->created_at ?? now(), + 'updated_at' => now(), + ]; + } + DB::table('notes')->insert($newNotes); + + // Migrate collections + $newCollections = []; + $oldCollections = $backupDB->table('collections')->get(); + foreach ($oldCollections as $row) { + $newCollections[$row->id] = DB::table('collections')->insertGetId([ + 'bar_membership_id' => $barMemberships[$newUsers[$row->user_id]], + 'name' => $row->name, + 'description' => $row->description, + 'created_at' => $row->created_at ?? now(), + 'updated_at' => now(), + ]); + } + $newCollectionCocktails = []; + $oldCollectionCocktails = $backupDB->table('collections_cocktails')->get(); + foreach ($oldCollectionCocktails as $row) { + $newCollectionCocktails[] = [ + 'collection_id' => $newCollections[$row->collection_id], + 'cocktail_id' => $newCocktails[$row->cocktail_id], + ]; + } + DB::table('collections_cocktails')->insert($newCollectionCocktails); + + // Move images + $newImages = []; + $oldImages = $backupDB->table('images')->get(); + foreach ($oldImages as $row) { + $filepath = $row->file_path; + if (str_starts_with($row->file_path, 'ingredients')) { + $filepath = str_replace('ingredients/', 'ingredients/' . $barId . '/', $row->file_path); + } + if (str_starts_with($row->file_path, 'cocktails')) { + $filepath = str_replace('cocktails/', 'cocktails/' . $barId . '/', $row->file_path); + } + + $imageableId = $row->imageable_id; + if ($row->imageable_type === \Kami\Cocktail\Models\Ingredient::class) { + $imageableId = $newIngredients[$row->imageable_id]; + } + if ($row->imageable_type === \Kami\Cocktail\Models\Cocktail::class) { + $imageableId = $newCocktails[$row->imageable_id]; + } + + $newImages[$row->id] = DB::table('images')->insertGetId([ + 'imageable_type' => $row->imageable_type, + 'imageable_id' => $imageableId, + 'file_path' => $filepath, + 'file_extension' => $row->file_extension, + 'copyright' => $row->copyright, + 'placeholder_hash' => $row->placeholder_hash, + 'sort' => $row->sort, + 'created_user_id' => $newUsers[$row->user_id] ?? $newAdminId, + 'created_at' => $row->created_at ?? now(), + 'updated_at' => now(), + ]); + } + + // Backup old uploads + File::ensureDirectoryExists(storage_path('bar-assistant/backup_v2/')); + if (File::exists(storage_path($oldUploads . '/cocktails'))) { + File::move(storage_path($oldUploads . '/cocktails'), storage_path('bar-assistant/backup_v2/cocktails')); + } + if (File::exists(storage_path($oldUploads . '/ingredients'))) { + File::move(storage_path($oldUploads . '/ingredients'), storage_path('bar-assistant/backup_v2/ingredients')); + } + if (File::exists(storage_path($oldUploads . '/temp'))) { + File::move(storage_path($oldUploads . '/temp'), storage_path('bar-assistant/backup_v2/temp')); + } + + // Move to new strucutre + File::ensureDirectoryExists(storage_path('bar-assistant/uploads/cocktails')); + File::ensureDirectoryExists(storage_path('bar-assistant/uploads/ingredients')); + File::ensureDirectoryExists(storage_path('bar-assistant/uploads/temp')); + File::move(storage_path('bar-assistant/backup_v2/cocktails'), storage_path('bar-assistant/uploads/cocktails/' . $barId)); + File::move(storage_path('bar-assistant/backup_v2/ingredients'), storage_path('bar-assistant/uploads/ingredients/' . $barId)); + File::move(storage_path('bar-assistant/backup_v2/temp'), storage_path('bar-assistant/uploads/temp')); + + /** @phpstan-ignore-next-line */ + Ingredient::where('bar_id', $barId)->searchable(); + /** @phpstan-ignore-next-line */ + Cocktail::where('bar_id', $barId)->searchable(); + + File::move(storage_path('bar-assistant/database.sqlite'), storage_path('bar-assistant/_database.sqlite')); + }); + } +} diff --git a/app/Jobs/ImportCollection.php b/app/Jobs/ImportCollection.php new file mode 100644 index 00000000..ed749de0 --- /dev/null +++ b/app/Jobs/ImportCollection.php @@ -0,0 +1,32 @@ +process($this->source, $this->userId, $this->barId, $this->duplicateActions); + } +} diff --git a/app/Jobs/SetupBar.php b/app/Jobs/SetupBar.php new file mode 100644 index 00000000..84f7e8cd --- /dev/null +++ b/app/Jobs/SetupBar.php @@ -0,0 +1,40 @@ +process($this->bar, $this->user, $this->barOptions); + } +} diff --git a/app/Models/Bar.php b/app/Models/Bar.php new file mode 100644 index 00000000..8ada4a92 --- /dev/null +++ b/app/Models/Bar.php @@ -0,0 +1,60 @@ + + */ + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'bar_memberships') + ->withPivot('user_role_id') + ->withTimestamps(); + } + + /** + * @return HasMany + */ + public function memberships(): HasMany + { + return $this->hasMany(BarMembership::class); + } + + /** + * @return HasMany + */ + public function cocktails(): HasMany + { + return $this->hasMany(Cocktail::class); + } + + /** + * @return HasMany + */ + public function ingredients(): HasMany + { + return $this->hasMany(Ingredient::class); + } + + public function delete(): ?bool + { + /** @var ImageService */ + $imageService = app(ImageService::class); + $imageService->cleanBarImages($this); + + return parent::delete(); + } +} diff --git a/app/Models/BarMembership.php b/app/Models/BarMembership.php new file mode 100644 index 00000000..ec5d6a8d --- /dev/null +++ b/app/Models/BarMembership.php @@ -0,0 +1,64 @@ + 'boolean', + ]; + + /** + * @return BelongsTo + */ + public function bar(): BelongsTo + { + return $this->belongsTo(Bar::class); + } + + /** + * @return BelongsTo + */ + public function role(): BelongsTo + { + return $this->belongsTo(UserRole::class, 'user_role_id'); + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return HasMany + */ + public function userIngredients(): HasMany + { + return $this->hasMany(UserIngredient::class); + } + + /** + * @return HasMany + */ + public function shoppingListIngredients(): HasMany + { + return $this->hasMany(UserShoppingList::class); + } + + /** + * @return HasMany + */ + public function cocktailFavorites(): HasMany + { + return $this->hasMany(CocktailFavorite::class); + } +} diff --git a/app/Models/BarType.php b/app/Models/BarType.php new file mode 100644 index 00000000..0aa56f7c --- /dev/null +++ b/app/Models/BarType.php @@ -0,0 +1,11 @@ + 'datetime', ]; - private string $appImagesDir = 'cocktails/'; + public function getUploadPath(): string + { + return 'cocktails/' . $this->bar_id . '/'; + } public function getSlugOptions(): SlugOptions { return SlugOptions::create() - ->generateSlugsFrom('name') + ->generateSlugsFrom(['name', 'bar_id']) ->saveSlugsTo('slug'); } - /** - * @return BelongsTo - */ - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } - /** * @return BelongsTo */ @@ -176,15 +178,15 @@ public function addToCollection(CocktailCollection $collection): void * Only user favorites * * @param Builder $baseQuery - * @param int $userId + * @param int $barMembershipId * @return Builder */ - public function scopeUserFavorites(Builder $baseQuery, int $userId): Builder + public function scopeUserFavorites(Builder $baseQuery, int $barMembershipId): Builder { - return $baseQuery->whereIn('cocktails.id', function ($query) use ($userId) { + return $baseQuery->whereIn('cocktails.id', function ($query) use ($barMembershipId) { $query->select('cocktail_id') ->from('cocktail_favorites') - ->where('user_id', $userId); + ->where('bar_membership_id', $barMembershipId); }); } @@ -215,21 +217,11 @@ public function toSearchableArray(): array 'name' => $this->name, 'slug' => $this->slug, 'description' => $this->description, - 'garnish' => $this->garnish, 'image_url' => $this->getMainImageUrl(), - 'image_hash' => $this->getMainImage()?->placeholder_hash ?? null, - 'main_image_id' => $this->getMainImage()?->id ?? null, 'short_ingredients' => $this->ingredients->pluck('ingredient.name'), - 'user_id' => $this->user_id, 'tags' => $this->tags->pluck('name'), - // 'utensils' => $this->utensils->pluck('name'), 'date' => $this->updated_at->format('Y-m-d H:i:s'), - 'glass' => $this->glass->name ?? null, - 'average_rating' => (int) round($this->ratings()->avg('rating') ?? 0), - 'main_ingredient_name' => $this->getMainIngredient()?->ingredient->name ?? null, - 'calculated_abv' => $this->abv, - 'method' => $this->method->name ?? null, - 'has_public_link' => $this->public_id !== null, + 'bar_id' => $this->bar_id, ]; } @@ -244,6 +236,7 @@ public function toShareableArray(): array 'tags' => $this->tags->pluck('name')->toArray(), 'glass' => $this->glass?->name ?? null, 'method' => $this->method?->name ?? null, + 'abv' => $this->abv, 'images' => $this->images->map(function (Image $image) { return [ 'url' => $image->getImageUrl(), @@ -256,6 +249,8 @@ public function toShareableArray(): array 'sort' => $cIngredient->sort ?? 0, 'name' => $cIngredient->ingredient->name, 'amount' => $cIngredient->amount, + 'amount_max' => $cIngredient->amount_max, + 'note' => $cIngredient->note, 'units' => $cIngredient->units, 'optional' => (bool) $cIngredient->optional, 'category' => $cIngredient->ingredient->category->name, @@ -279,11 +274,11 @@ public function toText(): string public function getNextSlug(): ?string { - return $this->distinct()->orderBy('name')->limit(1)->where('name', '>', $this->name)->first()?->slug; + return $this->distinct()->where('bar_id', $this->bar_id)->orderBy('name')->limit(1)->where('name', '>', $this->name)->first()?->slug; } public function getPrevSlug(): ?string { - return $this->distinct()->orderBy('name', 'desc')->limit(1)->where('name', '<', $this->name)->first()?->slug; + return $this->distinct()->where('bar_id', $this->bar_id)->orderBy('name', 'desc')->limit(1)->where('name', '<', $this->name)->first()?->slug; } } diff --git a/app/Models/CocktailMethod.php b/app/Models/CocktailMethod.php index c30994c7..b03753f7 100644 --- a/app/Models/CocktailMethod.php +++ b/app/Models/CocktailMethod.php @@ -5,14 +5,15 @@ namespace Kami\Cocktail\Models; use Illuminate\Database\Eloquent\Model; +use Kami\Cocktail\Models\Concerns\HasAuthors; use Illuminate\Database\Eloquent\Relations\HasMany; +use Kami\Cocktail\Models\Concerns\HasBarAwareScope; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Factories\HasFactory; class CocktailMethod extends Model { - use HasFactory; - - public $timestamps = false; + use HasFactory, HasBarAwareScope, HasAuthors; /** * @return HasMany @@ -21,4 +22,12 @@ public function cocktails(): HasMany { return $this->hasMany(Cocktail::class); } + + /** + * @return BelongsTo + */ + public function bar(): BelongsTo + { + return $this->belongsTo(Bar::class); + } } diff --git a/app/Models/Collection.php b/app/Models/Collection.php index 15a16228..8257f46c 100644 --- a/app/Models/Collection.php +++ b/app/Models/Collection.php @@ -28,4 +28,12 @@ public function cocktails(): BelongsToMany { return $this->belongsToMany(Cocktail::class, 'collections_cocktails'); } + + /** + * @return BelongsTo + */ + public function barMembership(): BelongsTo + { + return $this->belongsTo(BarMembership::class); + } } diff --git a/app/Models/Concerns/HasAuthors.php b/app/Models/Concerns/HasAuthors.php new file mode 100644 index 00000000..180dde94 --- /dev/null +++ b/app/Models/Concerns/HasAuthors.php @@ -0,0 +1,27 @@ + + */ + public function createdUser(): BelongsTo + { + return $this->belongsTo(User::class, 'created_user_id'); + } + + /** + * @return BelongsTo + */ + public function updatedUser(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_user_id'); + } +} diff --git a/app/Models/Concerns/HasBarAwareScope.php b/app/Models/Concerns/HasBarAwareScope.php new file mode 100644 index 00000000..50ad7f87 --- /dev/null +++ b/app/Models/Concerns/HasBarAwareScope.php @@ -0,0 +1,21 @@ + $query + * @return Builder<\Illuminate\Database\Eloquent\Model> + */ + public function scopeFilterByBar(Builder $query): Builder + { + return $query->where('bar_id', bar()->id); + } +} diff --git a/app/Models/HasImages.php b/app/Models/Concerns/HasImages.php similarity index 86% rename from app/Models/HasImages.php rename to app/Models/Concerns/HasImages.php index aeede753..b409f235 100644 --- a/app/Models/HasImages.php +++ b/app/Models/Concerns/HasImages.php @@ -2,14 +2,13 @@ declare(strict_types=1); -namespace Kami\Cocktail\Models; +namespace Kami\Cocktail\Models\Concerns; -use LogicException; use Illuminate\Support\Str; +use Kami\Cocktail\Models\Image; use Illuminate\Support\Facades\Storage; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Relations\MorphMany; -use Illuminate\Database\Eloquent\InvalidCastException; trait HasImages { @@ -51,7 +50,7 @@ public function attachImages(Collection $images): void } $oldFilePath = $image->file_path; - $newFilePath = $this->appImagesDir . $this->slug . '_' . Str::random(6) . '.' . $image->file_extension; + $newFilePath = $this->getUploadPath() . $this->slug . '_' . Str::random(6) . '.' . $image->file_extension; if ($disk->exists($oldFilePath)) { $disk->move($oldFilePath, $newFilePath); diff --git a/app/Models/HasNotes.php b/app/Models/Concerns/HasNotes.php similarity index 95% rename from app/Models/HasNotes.php rename to app/Models/Concerns/HasNotes.php index 3c870cfa..9c89dd17 100644 --- a/app/Models/HasNotes.php +++ b/app/Models/Concerns/HasNotes.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Kami\Cocktail\Models; +namespace Kami\Cocktail\Models\Concerns; use Kami\Cocktail\Models\Note; use Illuminate\Database\Eloquent\Collection; diff --git a/app/Models/HasRating.php b/app/Models/Concerns/HasRating.php similarity index 88% rename from app/Models/HasRating.php rename to app/Models/Concerns/HasRating.php index dcb2dedc..3a4ca813 100644 --- a/app/Models/HasRating.php +++ b/app/Models/Concerns/HasRating.php @@ -2,8 +2,9 @@ declare(strict_types=1); -namespace Kami\Cocktail\Models; +namespace Kami\Cocktail\Models\Concerns; +use Kami\Cocktail\Models\Rating; use Illuminate\Database\Eloquent\Relations\MorphMany; trait HasRating @@ -34,7 +35,7 @@ public function rate(int $ratingValue, int $userId): Rating public function totalRatedCount(): int { - return $this->ratings()->count(); + return $this->ratings->count(); } public function deleteUserRating(int $userId): void diff --git a/app/Models/Glass.php b/app/Models/Glass.php index 0e2a241c..4a09cfbb 100644 --- a/app/Models/Glass.php +++ b/app/Models/Glass.php @@ -4,14 +4,16 @@ namespace Kami\Cocktail\Models; -use Illuminate\Support\Facades\DB; use Illuminate\Database\Eloquent\Model; +use Kami\Cocktail\Models\Concerns\HasAuthors; use Illuminate\Database\Eloquent\Relations\HasMany; +use Kami\Cocktail\Models\Concerns\HasBarAwareScope; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Factories\HasFactory; class Glass extends Model { - use HasFactory; + use HasFactory, HasBarAwareScope, HasAuthors; /** * @return HasMany @@ -21,12 +23,17 @@ public function cocktails(): HasMany return $this->hasMany(Cocktail::class); } + /** + * @return BelongsTo + */ + public function bar(): BelongsTo + { + return $this->belongsTo(Bar::class); + } + public function delete(): bool { - $cocktailIds = $this->cocktails->pluck('id'); - DB::table('cocktails')->where('glass_id', $this->id)->update(['glass_id' => null]); - /** @phpstan-ignore-next-line Laravel macro */ - Cocktail::find($cocktailIds)->searchable(); + $this->cocktails->each(fn ($cocktail) => $cocktail->searchable()); return parent::delete(); } diff --git a/app/Models/Ingredient.php b/app/Models/Ingredient.php index a0b17906..a18861c5 100644 --- a/app/Models/Ingredient.php +++ b/app/Models/Ingredient.php @@ -9,16 +9,17 @@ use Spatie\Sluggable\SlugOptions; use Illuminate\Support\Collection; use Illuminate\Database\Eloquent\Model; +use Kami\Cocktail\Models\Concerns\HasImages; +use Kami\Cocktail\Models\Concerns\HasAuthors; use Illuminate\Database\Eloquent\Relations\HasMany; +use Kami\Cocktail\Models\Concerns\HasBarAwareScope; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; class Ingredient extends Model { - use HasFactory, Searchable, HasImages, HasSlug; - - private string $appImagesDir = 'ingredients/'; + use HasFactory, Searchable, HasImages, HasSlug, HasBarAwareScope, HasAuthors; protected $fillable = [ 'name', @@ -31,10 +32,15 @@ class Ingredient extends Model 'parent_ingredient_id', ]; + public function getUploadPath(): string + { + return 'ingredients/' . $this->bar_id . '/'; + } + public function getSlugOptions(): SlugOptions { return SlugOptions::create() - ->generateSlugsFrom('name') + ->generateSlugsFrom(['name', 'bar_id']) ->saveSlugsTo('slug'); } @@ -126,10 +132,9 @@ public function toSearchableArray(): array 'name' => $this->name, 'image_url' => $this->getMainImageUrl(), 'description' => $this->description, - 'category' => $this->category->name, - 'strength_abv' => $this->strength, - 'color' => $this->color ?? 'No color', - 'origin' => $this->origin ?? 'No origin', + 'category' => $this->category?->name ?? null, + 'origin' => $this->origin, + 'bar_id' => $this->bar_id, ]; } } diff --git a/app/Models/IngredientCategory.php b/app/Models/IngredientCategory.php index 579d881c..1628f94d 100644 --- a/app/Models/IngredientCategory.php +++ b/app/Models/IngredientCategory.php @@ -5,9 +5,19 @@ namespace Kami\Cocktail\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Kami\Cocktail\Models\Concerns\HasBarAwareScope; use Illuminate\Database\Eloquent\Factories\HasFactory; class IngredientCategory extends Model { - use HasFactory; + use HasFactory, HasBarAwareScope; + + /** + * @return HasMany + */ + public function ingredients(): HasMany + { + return $this->hasMany(Ingredient::class); + } } diff --git a/app/Models/Tag.php b/app/Models/Tag.php index e343643b..cbec85d1 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -5,16 +5,18 @@ namespace Kami\Cocktail\Models; use Illuminate\Database\Eloquent\Model; +use Kami\Cocktail\Models\Concerns\HasBarAwareScope; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; class Tag extends Model { - use HasFactory; + use HasFactory, HasBarAwareScope; public $timestamps = false; - public $fillable = ['name']; + public $fillable = ['name', 'bar_id']; /** * @return BelongsToMany @@ -23,4 +25,12 @@ public function cocktails(): BelongsToMany { return $this->belongsToMany(Cocktail::class); } + + /** + * @return BelongsTo + */ + public function bar(): BelongsTo + { + return $this->belongsTo(Bar::class); + } } diff --git a/app/Models/UploadableInterface.php b/app/Models/UploadableInterface.php new file mode 100644 index 00000000..c99ea1ce --- /dev/null +++ b/app/Models/UploadableInterface.php @@ -0,0 +1,10 @@ + 'datetime', ]; - protected static function booted(): void + /** + * @return Collection + */ + public function getShelfIngredients(int $barId): Collection { - static::addGlobalScope('realUsers', function (Builder $builder) { - $builder->where('id', '>', 1); - }); + return $this->getBarMembership($barId)?->userIngredients ?? new Collection(); } /** - * @return HasMany + * @return HasMany */ - public function shelfIngredients(): HasMany + public function memberships(): HasMany { - return $this->hasMany(UserIngredient::class); + return $this->hasMany(BarMembership::class); } - /** - * @return HasMany - */ - public function favorites(): HasMany + public function joinBarAs(Bar $bar, UserRoleEnum $role = UserRoleEnum::General): BarMembership { - return $this->hasMany(CocktailFavorite::class); + $existingMembership = $this->getBarMembership($bar->id); + if ($existingMembership !== null) { + return $existingMembership; + } + + $barMemberShip = new BarMembership(); + $barMemberShip->bar_id = $bar->id; + $barMemberShip->user_role_id = $role->value; + + $this->memberships()->save($barMemberShip); + + return $barMemberShip; + } + + public function leaveBar(Bar $bar): void + { + $this->getBarMembership($bar->id)->delete(); } /** - * @return HasMany + * @return HasMany */ - public function shoppingList(): HasMany + public function ownedBars(): HasMany + { + return $this->hasMany(Bar::class, 'created_user_id'); + } + + public function getBarMembership(int $barId): ?BarMembership + { + return $this->memberships->where('bar_id', $barId)->first(); + } + + public function hasBarMembership(int $barId): bool + { + return $this->getBarMembership($barId)?->id !== null; + } + + public function isBarAdmin(int $barId): bool + { + return $this->hasBarRole($barId, UserRoleEnum::Admin); + } + + public function isBarModerator(int $barId): bool + { + return $this->hasBarRole($barId, UserRoleEnum::Moderator); + } + + public function isBarGeneral(int $barId): bool { - return $this->hasMany(UserShoppingList::class); + return $this->hasBarRole($barId, UserRoleEnum::General); } - public function isAdmin(): bool + public function isBarGuest(int $barId): bool { - return (bool) $this->is_admin; + return $this->hasBarRole($barId, UserRoleEnum::Guest); } - public function isUser(): bool + private function hasBarRole(int $barId, UserRoleEnum $role): bool { - return !$this->isAdmin(); + return $this->memberships + ->where('bar_id', $barId) + ->where('user_role_id', $role->value) + ->count() > 0; } } diff --git a/app/Models/UserRole.php b/app/Models/UserRole.php new file mode 100644 index 00000000..e64be719 --- /dev/null +++ b/app/Models/UserRole.php @@ -0,0 +1,11 @@ + @@ -19,4 +21,12 @@ public function cocktails(): HasMany { return $this->hasMany(Cocktail::class); } + + /** + * @return BelongsTo + */ + public function bar(): BelongsTo + { + return $this->belongsTo(Bar::class); + } } diff --git a/app/Policies/BarPolicy.php b/app/Policies/BarPolicy.php new file mode 100644 index 00000000..ca7069ca --- /dev/null +++ b/app/Policies/BarPolicy.php @@ -0,0 +1,39 @@ +ownedBars->count() < config('bar-assistant.max_default_bars', 1); + } + + public function show(User $user, Bar $bar): bool + { + return $user->hasBarMembership($bar->id); + } + + public function edit(User $user, Bar $bar): bool + { + return $user->isBarAdmin($bar->id) || $user->isBarModerator($bar->id); + } + + public function delete(User $user, Bar $bar): bool + { + return $user->isBarAdmin($bar->id); + } + + public function deleteMembership(User $user, Bar $bar): bool + { + return $user->isBarAdmin($bar->id); + } +} diff --git a/app/Policies/CocktailMethodPolicy.php b/app/Policies/CocktailMethodPolicy.php new file mode 100644 index 00000000..b94390ed --- /dev/null +++ b/app/Policies/CocktailMethodPolicy.php @@ -0,0 +1,37 @@ +isBarAdmin(bar()->id) + || $user->isBarModerator(bar()->id); + } + + public function show(User $user, CocktailMethod $method): bool + { + return $user->hasBarMembership($method->bar_id); + } + + public function edit(User $user, CocktailMethod $method): bool + { + return $user->isBarAdmin($method->bar_id) + || $user->isBarModerator($method->bar_id); + } + + public function delete(User $user, CocktailMethod $method): bool + { + return $user->isBarAdmin($method->bar_id) + || $user->isBarModerator($method->bar_id); + } +} diff --git a/app/Policies/CocktailPolicy.php b/app/Policies/CocktailPolicy.php index c41f48bb..1afeca70 100644 --- a/app/Policies/CocktailPolicy.php +++ b/app/Policies/CocktailPolicy.php @@ -12,27 +12,41 @@ class CocktailPolicy { use HandlesAuthorization; - public function before(User $user, string $ability): bool|null + public function create(User $user): bool { - if ($user->isAdmin()) { - return true; - } + $barId = bar()->id; - return null; + return $user->isBarAdmin($barId) + || $user->isBarModerator($barId) + || $user->isBarGeneral($barId); } - public function addNote(User $user, Cocktail $cocktail): bool + public function show(User $user, Cocktail $cocktail): bool { - return $user->id === $cocktail->user_id; + return $user->hasBarMembership($cocktail->bar_id); } public function edit(User $user, Cocktail $cocktail): bool { - return $user->id === $cocktail->user_id; + return ($user->id === $cocktail->created_user_id && $user->hasBarMembership($cocktail->bar_id)) + || $user->isBarAdmin($cocktail->bar_id) + || $user->isBarModerator($cocktail->bar_id); } public function delete(User $user, Cocktail $cocktail): bool { - return $user->id === $cocktail->user_id; + return ($user->id === $cocktail->created_user_id && $user->hasBarMembership($cocktail->bar_id)) + || $user->isBarAdmin($cocktail->bar_id) + || $user->isBarModerator($cocktail->bar_id); + } + + public function addNote(User $user, Cocktail $cocktail): bool + { + return $user->hasBarMembership($cocktail->bar_id); + } + + public function rate(User $user, Cocktail $cocktail): bool + { + return $user->hasBarMembership($cocktail->bar_id); } } diff --git a/app/Policies/CollectionPolicy.php b/app/Policies/CollectionPolicy.php index 970af05c..a032f2ba 100644 --- a/app/Policies/CollectionPolicy.php +++ b/app/Policies/CollectionPolicy.php @@ -12,27 +12,23 @@ class CollectionPolicy { use HandlesAuthorization; - public function before(User $user, string $ability): bool|null + public function create(User $user): bool { - if ($user->isAdmin()) { - return true; - } - - return null; + return $user->hasBarMembership(bar()->id); } public function show(User $user, Collection $collection): bool { - return $user->id === $collection->user_id; + return $user->memberships->contains('id', $collection->bar_membership_id); } public function edit(User $user, Collection $collection): bool { - return $user->id === $collection->user_id; + return $user->memberships->contains('id', $collection->bar_membership_id); } public function delete(User $user, Collection $collection): bool { - return $user->id === $collection->user_id; + return $user->memberships->contains('id', $collection->bar_membership_id); } } diff --git a/app/Policies/GlassPolicy.php b/app/Policies/GlassPolicy.php new file mode 100644 index 00000000..9a86a081 --- /dev/null +++ b/app/Policies/GlassPolicy.php @@ -0,0 +1,37 @@ +isBarAdmin(bar()->id) + || $user->isBarModerator(bar()->id); + } + + public function show(User $user, Glass $glass): bool + { + return $user->hasBarMembership($glass->bar_id); + } + + public function edit(User $user, Glass $glass): bool + { + return $user->isBarAdmin($glass->bar_id) + || $user->isBarModerator($glass->bar_id); + } + + public function delete(User $user, Glass $glass): bool + { + return $user->isBarAdmin($glass->bar_id) + || $user->isBarModerator($glass->bar_id); + } +} diff --git a/app/Policies/ImagePolicy.php b/app/Policies/ImagePolicy.php index 99ee9a53..038ab260 100644 --- a/app/Policies/ImagePolicy.php +++ b/app/Policies/ImagePolicy.php @@ -12,22 +12,39 @@ class ImagePolicy { use HandlesAuthorization; - public function before(User $user, string $ability): bool|null + public function show(User $user, Image $image): bool { - if ($user->isAdmin()) { - return true; + $barId = $image->imageable?->bar_id ?? null; + + if (!$barId) { + return $user->id === $image->created_user_id || $user->isBarAdmin($barId); } - return null; + return ($user->id === $image->created_user_id && $user->hasBarMembership($barId)) + || $user->isBarAdmin($barId); } public function edit(User $user, Image $image): bool { - return $user->id === $image->user_id; + $barId = $image->imageable?->bar_id ?? null; + + if (!$barId) { + return $user->id === $image->created_user_id || $user->isBarAdmin($barId); + } + + return ($user->id === $image->created_user_id && $user->hasBarMembership($barId)) + || $user->isBarAdmin($barId); } public function delete(User $user, Image $image): bool { - return $user->id === $image->user_id; + $barId = $image->imageable?->bar_id ?? null; + + if (!$barId) { + return $user->id === $image->created_user_id || $user->isBarAdmin($barId); + } + + return ($user->id === $image->created_user_id && $user->hasBarMembership($barId)) + || $user->isBarAdmin($barId); } } diff --git a/app/Policies/IngredientCategoryPolicy.php b/app/Policies/IngredientCategoryPolicy.php new file mode 100644 index 00000000..bcaecfb5 --- /dev/null +++ b/app/Policies/IngredientCategoryPolicy.php @@ -0,0 +1,37 @@ +isBarAdmin(bar()->id) + || $user->isBarModerator(bar()->id); + } + + public function show(User $user, IngredientCategory $ingredientCategory): bool + { + return $user->hasBarMembership($ingredientCategory->bar_id); + } + + public function edit(User $user, IngredientCategory $ingredientCategory): bool + { + return $user->isBarAdmin($ingredientCategory->bar_id) + || $user->isBarModerator($ingredientCategory->bar_id); + } + + public function delete(User $user, IngredientCategory $ingredientCategory): bool + { + return $user->isBarAdmin($ingredientCategory->bar_id) + || $user->isBarModerator($ingredientCategory->bar_id); + } +} diff --git a/app/Policies/IngredientPolicy.php b/app/Policies/IngredientPolicy.php index ca81d059..de7b3f77 100644 --- a/app/Policies/IngredientPolicy.php +++ b/app/Policies/IngredientPolicy.php @@ -12,22 +12,31 @@ class IngredientPolicy { use HandlesAuthorization; - public function before(User $user, string $ability): bool|null + public function create(User $user): bool { - if ($user->isAdmin()) { - return true; - } + $barId = bar()->id; - return null; + return $user->isBarAdmin($barId) + || $user->isBarModerator($barId) + || $user->isBarGeneral($barId); + } + + public function show(User $user, Ingredient $ingredient): bool + { + return $user->hasBarMembership($ingredient->bar_id); } public function edit(User $user, Ingredient $ingredient): bool { - return $user->id === $ingredient->user_id; + return ($user->id === $ingredient->created_user_id && $user->hasBarMembership($ingredient->bar_id)) + || $user->isBarAdmin($ingredient->bar_id) + || $user->isBarModerator($ingredient->bar_id); } public function delete(User $user, Ingredient $ingredient): bool { - return $user->id === $ingredient->user_id; + return ($user->id === $ingredient->created_user_id && $user->hasBarMembership($ingredient->bar_id)) + || $user->isBarAdmin($ingredient->bar_id) + || $user->isBarModerator($ingredient->bar_id); } } diff --git a/app/Policies/NotePolicy.php b/app/Policies/NotePolicy.php index 3e0e0911..d72272df 100644 --- a/app/Policies/NotePolicy.php +++ b/app/Policies/NotePolicy.php @@ -12,25 +12,11 @@ class NotePolicy { use HandlesAuthorization; - public function before(User $user, string $ability): bool|null - { - if ($user->isAdmin()) { - return true; - } - - return null; - } - public function show(User $user, Note $note): bool { return $user->id === $note->user_id; } - public function edit(User $user, Note $note): bool - { - return $user->id === $note->user_id; - } - public function delete(User $user, Note $note): bool { return $user->id === $note->user_id; diff --git a/app/Policies/TagPolicy.php b/app/Policies/TagPolicy.php new file mode 100644 index 00000000..d532899f --- /dev/null +++ b/app/Policies/TagPolicy.php @@ -0,0 +1,37 @@ +isBarAdmin(bar()->id) + || $user->isBarModerator(bar()->id); + } + + public function show(User $user, Tag $tag): bool + { + return $user->hasBarMembership($tag->bar_id); + } + + public function edit(User $user, Tag $tag): bool + { + return $user->isBarAdmin($tag->bar_id) + || $user->isBarModerator($tag->bar_id); + } + + public function delete(User $user, Tag $tag): bool + { + return $user->isBarAdmin($tag->bar_id) + || $user->isBarModerator($tag->bar_id); + } +} diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php new file mode 100644 index 00000000..48bfdb97 --- /dev/null +++ b/app/Policies/UserPolicy.php @@ -0,0 +1,42 @@ +isBarAdmin(bar()->id) + || $user->isBarModerator(bar()->id); + } + + public function create(User $user): bool + { + return $user->isBarAdmin(bar()->id) + || $user->isBarModerator(bar()->id); + } + + public function show(User $user, User $model): bool + { + return $user->isBarAdmin(bar()->id) + || $user->isBarModerator(bar()->id); + } + + public function edit(User $user, User $model): bool + { + return $user->isBarAdmin(bar()->id) + || $user->isBarModerator(bar()->id); + } + + public function delete(User $user, User $model): bool + { + return $user->id === $model->id; + } +} diff --git a/app/Policies/UtensilPolicy.php b/app/Policies/UtensilPolicy.php new file mode 100644 index 00000000..c82574f8 --- /dev/null +++ b/app/Policies/UtensilPolicy.php @@ -0,0 +1,37 @@ +isBarAdmin(bar()->id) + || $user->isBarModerator(bar()->id); + } + + public function show(User $user, Utensil $utensil): bool + { + return $user->isBarAdmin($utensil->bar_id); + } + + public function edit(User $user, Utensil $utensil): bool + { + return $user->isBarAdmin($utensil->bar_id) + || $user->isBarModerator($utensil->bar_id); + } + + public function delete(User $user, Utensil $utensil): bool + { + return $user->isBarAdmin($utensil->bar_id) + || $user->isBarModerator($utensil->bar_id); + } +} diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 40b5543c..a1ba4384 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -2,7 +2,6 @@ namespace Kami\Cocktail\Providers; -use Illuminate\Support\Facades\Event; use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Listeners\SendEmailVerificationNotification; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; diff --git a/app/Search/AlgoliaActions.php b/app/Search/AlgoliaActions.php index 20a2578c..6db65923 100644 --- a/app/Search/AlgoliaActions.php +++ b/app/Search/AlgoliaActions.php @@ -13,7 +13,7 @@ public function __construct(private readonly AlgoliaEngine $client) { } - public function getPublicApiKey(): ?string + public function getBarSearchApiKey(int $barId): ?string { $response = $this->client->addApiKey(['search']); diff --git a/app/Search/MeilisearchActions.php b/app/Search/MeilisearchActions.php index c7aa48a1..8d789996 100644 --- a/app/Search/MeilisearchActions.php +++ b/app/Search/MeilisearchActions.php @@ -13,17 +13,43 @@ public function __construct(private readonly MeiliSearchEngine $client) { } - public function getPublicApiKey(): ?string + public function getBarSearchApiKey(?int $barId): ?string { - $key = $this->client->createKey([ - 'actions' => ['search'], - 'indexes' => ['cocktails', 'ingredients'], - 'expiresAt' => null, - 'name' => 'Bar Assistant', - 'description' => 'Client key generated by Bar Assistant Server' - ]); + $searchApiKey = null; + $keys = $this->client->getKeys(); + foreach ($keys->getResults() as $key) { + if ($key->getName() === 'bar-assistant-v3') { + $searchApiKey = $key; + } + } + + if (!$searchApiKey) { + $searchApiKey = $this->client->createKey([ + 'actions' => ['search'], + 'indexes' => ['cocktails', 'ingredients'], + 'expiresAt' => null, + 'name' => 'bar-assistant-v3', + 'description' => 'Client key generated by Bar Assistant v3 Server' + ]); + } + + $rules = (object) [ + 'cocktails' => (object) [ + 'filter' => 'bar_id = ' . $barId, + ], + 'ingredients' => (object) [ + 'filter' => 'bar_id = ' . $barId, + ] + ]; - return $key->getKey(); + $tenantToken = $this->client->generateTenantToken($searchApiKey->getUid(), $rules, ['apiKey' => $searchApiKey->getKey()]); + + return $tenantToken; + } + + public function deleteBarSearchApiKey(string $key): void + { + $this->client->deleteKey($key); } public function isAvailable(): bool @@ -52,21 +78,20 @@ public function getHost(): ?string public function updateIndexSettings(): void { $this->client->index('cocktails')->updateSettings([ - 'filterableAttributes' => ['id', 'tags', 'user_id', 'glass', 'average_rating', 'main_ingredient_name', 'method', 'calculated_abv', 'has_public_link'], - 'sortableAttributes' => ['name', 'date', 'average_rating'], + 'filterableAttributes' => ['tags', 'bar_id'], + 'sortableAttributes' => ['name', 'date'], 'searchableAttributes' => [ 'name', 'tags', 'description', 'date', + 'short_ingredients', ] ]); - $this->client->index('cocktails')->updatePagination(['maxTotalHits' => 2000]); - $this->client->index('ingredients')->updateSettings([ - 'filterableAttributes' => ['category', 'strength_abv', 'origin', 'color', 'id'], - 'sortableAttributes' => ['name', 'strength_abv'], + 'filterableAttributes' => ['category', 'origin', 'bar_id'], + 'sortableAttributes' => ['name', 'category'], 'searchableAttributes' => [ 'name', 'description', @@ -74,7 +99,5 @@ public function updateIndexSettings(): void 'origin', ] ]); - - $this->client->index('ingredients')->updatePagination(['maxTotalHits' => 2000]); } } diff --git a/app/Search/NullActions.php b/app/Search/NullActions.php index aa02645e..ebe50c98 100644 --- a/app/Search/NullActions.php +++ b/app/Search/NullActions.php @@ -6,7 +6,7 @@ class NullActions implements SearchActionsContract { - public function getPublicApiKey(): ?string + public function getBarSearchApiKey(int $barId): ?string { return null; } diff --git a/app/Search/SearchActionsContract.php b/app/Search/SearchActionsContract.php index 50c196ac..c6408c1c 100644 --- a/app/Search/SearchActionsContract.php +++ b/app/Search/SearchActionsContract.php @@ -6,7 +6,7 @@ interface SearchActionsContract { - public function getPublicApiKey(): ?string; + public function getBarSearchApiKey(int $barId): ?string; public function isAvailable(): bool; diff --git a/app/Services/CocktailService.php b/app/Services/CocktailService.php index 0c457941..4b96980b 100644 --- a/app/Services/CocktailService.php +++ b/app/Services/CocktailService.php @@ -16,7 +16,6 @@ use Kami\Cocktail\Models\CocktailFavorite; use Kami\Cocktail\Models\CocktailIngredient; use Kami\Cocktail\Exceptions\CocktailException; -use Kami\Cocktail\DataObjects\Cocktail\Ingredient; use Kami\Cocktail\Models\CocktailIngredientSubstitute; use Kami\Cocktail\DataObjects\Cocktail\Cocktail as CocktailDTO; @@ -39,9 +38,10 @@ public function createCocktail(CocktailDTO $cocktailDTO): Cocktail $cocktail->description = $cocktailDTO->description; $cocktail->garnish = $cocktailDTO->garnish; $cocktail->source = $cocktailDTO->source; - $cocktail->user_id = $cocktailDTO->userId; + $cocktail->created_user_id = $cocktailDTO->userId; $cocktail->glass_id = $cocktailDTO->glassId; $cocktail->cocktail_method_id = $cocktailDTO->methodId; + $cocktail->bar_id = $cocktailDTO->barId; $cocktail->save(); foreach ($cocktailDTO->ingredients as $ingredient) { @@ -51,13 +51,18 @@ public function createCocktail(CocktailDTO $cocktailDTO): Cocktail $cIngredient->units = $ingredient->units; $cIngredient->optional = $ingredient->optional; $cIngredient->sort = $ingredient->sort; + $cIngredient->amount_max = $ingredient->amountMax; + $cIngredient->note = $ingredient->note; $cocktail->ingredients()->save($cIngredient); // Substitutes - foreach ($ingredient->substitutes as $subId) { + foreach ($ingredient->substitutes as $substituteDto) { $substitute = new CocktailIngredientSubstitute(); - $substitute->ingredient_id = $subId; + $substitute->ingredient_id = $substituteDto->ingredientId; + $substitute->amount = $substituteDto->amount; + $substitute->amount_max = $substituteDto->amountMax; + $substitute->units = $substituteDto->units; $cIngredient->substitutes()->save($substitute); } } @@ -66,6 +71,7 @@ public function createCocktail(CocktailDTO $cocktailDTO): Cocktail foreach ($cocktailDTO->tags as $tagName) { $tag = Tag::firstOrNew([ 'name' => trim($tagName), + 'bar_id' => $cocktailDTO->barId, ]); $tag->save(); $dbTags[] = $tag->id; @@ -77,7 +83,7 @@ public function createCocktail(CocktailDTO $cocktailDTO): Cocktail $this->log->error('[COCKTAIL_SERVICE] ' . $e->getMessage()); $this->db->rollBack(); - throw new CocktailException('Error occured while creating a cocktail!', 0, $e); + throw $e; } $this->db->commit(); @@ -115,11 +121,10 @@ public function updateCocktail(int $id, CocktailDTO $cocktailDTO): Cocktail $cocktail->description = $cocktailDTO->description; $cocktail->garnish = $cocktailDTO->garnish; $cocktail->source = $cocktailDTO->source; - if ($cocktail->user_id !== 1) { - $cocktail->user_id = $cocktailDTO->userId; - } + $cocktail->updated_user_id = $cocktailDTO->userId; $cocktail->glass_id = $cocktailDTO->glassId; $cocktail->cocktail_method_id = $cocktailDTO->methodId; + $cocktail->updated_at = now(); $cocktail->save(); // TODO: Implement upsert and delete @@ -131,14 +136,19 @@ public function updateCocktail(int $id, CocktailDTO $cocktailDTO): Cocktail $cIngredient->units = $ingredient->units; $cIngredient->optional = $ingredient->optional; $cIngredient->sort = $ingredient->sort; + $cIngredient->amount_max = $ingredient->amountMax; + $cIngredient->note = $ingredient->note; $cocktail->ingredients()->save($cIngredient); // Substitutes $cIngredient->substitutes()->delete(); - foreach ($ingredient->substitutes as $subId) { + foreach ($ingredient->substitutes as $substituteDto) { $substitute = new CocktailIngredientSubstitute(); - $substitute->ingredient_id = $subId; + $substitute->ingredient_id = $substituteDto->ingredientId; + $substitute->amount = $substituteDto->amount; + $substitute->amount_max = $substituteDto->amountMax; + $substitute->units = $substituteDto->units; $cIngredient->substitutes()->save($substitute); } } @@ -147,6 +157,7 @@ public function updateCocktail(int $id, CocktailDTO $cocktailDTO): Cocktail foreach ($cocktailDTO->tags as $tagName) { $tag = Tag::firstOrNew([ 'name' => trim($tagName), + 'bar_id' => $cocktail->bar_id, ]); $tag->save(); $dbTags[] = $tag->id; @@ -158,7 +169,7 @@ public function updateCocktail(int $id, CocktailDTO $cocktailDTO): Cocktail $this->log->error('[COCKTAIL_SERVICE] ' . $e->getMessage()); $this->db->rollBack(); - throw new CocktailException('Error occured while updating a cocktail with id "' . $id . '"!', 0, $e); + throw $e; } $this->db->commit(); @@ -192,7 +203,7 @@ public function updateCocktail(int $id, CocktailDTO $cocktailDTO): Cocktail * @param array $ingredientIds * @return \Illuminate\Support\Collection */ - public function getCocktailsByUserIngredients(array $ingredientIds, ?int $limit = null): Collection + public function getCocktailsByIngredients(array $ingredientIds, ?int $limit = null): Collection { $query = $this->db->table('cocktails AS c') ->select('c.id') @@ -285,34 +296,26 @@ public function getCocktailUserRatings(int $userId): array ->toArray(); } - /** - * Toggle user favorite cocktail - * - * @param \Kami\Cocktail\Models\User $user - * @param int $cocktailId - * @return bool - */ - public function toggleFavorite(User $user, int $cocktailId): bool + public function toggleFavorite(User $user, int $cocktailId): ?CocktailFavorite { - $cocktail = Cocktail::find($cocktailId); + $cocktail = Cocktail::findOrFail($cocktailId); - if (!$cocktail) { - return false; - } + $barMembership = $user->getBarMembership($cocktail->bar_id); - $existing = CocktailFavorite::where('cocktail_id', $cocktailId)->where('user_id', $user->id)->first(); + $existing = CocktailFavorite::where('cocktail_id', $cocktailId)->where('bar_membership_id', $barMembership->id)->first(); if ($existing) { $existing->delete(); - return false; + return null; } $cocktailFavorite = new CocktailFavorite(); $cocktailFavorite->cocktail_id = $cocktail->id; + $cocktailFavorite->bar_membership_id = $barMembership->id; - $user->favorites()->save($cocktailFavorite); + $barMembership->cocktailFavorites()->save($cocktailFavorite); - return true; + return $cocktailFavorite; } /** diff --git a/app/Services/ImageService.php b/app/Services/ImageService.php index c8528098..fc62df09 100644 --- a/app/Services/ImageService.php +++ b/app/Services/ImageService.php @@ -7,8 +7,11 @@ use Throwable; use Thumbhash\Thumbhash; use Illuminate\Support\Str; +use Kami\Cocktail\Models\Bar; use Illuminate\Log\LogManager; use Kami\Cocktail\Models\Image; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Storage; use Illuminate\Filesystem\FilesystemAdapter; use Kami\Cocktail\DataObjects\Image as ImageDTO; @@ -52,13 +55,11 @@ public function uploadAndSaveImages(array $requestImages, int $userId): array $image->copyright = $dtoImage->copyright; $image->file_path = $filepath; $image->file_extension = $fileExtension; - $image->user_id = $userId; + $image->created_user_id = $userId; $image->sort = $dtoImage->sort; $image->placeholder_hash = $thumbHash; $image->save(); - $this->log->info('[IMAGE_SERVICE] Image created with id: ' . $image->id); - $images[] = $image; } @@ -72,7 +73,7 @@ public function uploadAndSaveImages(array $requestImages, int $userId): array * @param ImageDTO $imageDTO Image object * @return \Kami\Cocktail\Models\Image Database image model */ - public function updateImage(int $imageId, ImageDTO $imageDTO): Image + public function updateImage(int $imageId, ImageDTO $imageDTO, int $userId): Image { $image = Image::findOrFail($imageId); @@ -84,14 +85,16 @@ public function updateImage(int $imageId, ImageDTO $imageDTO): Image $image->file_path = $filepath; $image->placeholder_hash = $thumbHash; $image->file_extension = $fileExtension; + $image->updated_user_id = $userId; + $image->updated_at = now(); } catch (Throwable $e) { - $this->log->info('[IMAGE_SERVICE] File upload error | ' . $e->getMessage()); + $this->log->error('[IMAGE_SERVICE] File upload error | ' . $e->getMessage()); } try { $this->disk->delete($oldFilePath); } catch (Throwable $e) { - $this->log->info('[IMAGE_SERVICE] File delete error | ' . $e->getMessage()); + $this->log->error('[IMAGE_SERVICE] File delete error | ' . $e->getMessage()); } } @@ -105,8 +108,6 @@ public function updateImage(int $imageId, ImageDTO $imageDTO): Image $image->save(); - $this->log->info('[IMAGE_SERVICE] Image updated with id: ' . $image->id); - return $image; } @@ -136,6 +137,27 @@ public function generateThumbHash(InterventionImage $image, bool $destroyInstanc return $key; } + public function cleanBarImages(Bar $bar): void + { + $cocktailIds = $bar->cocktails()->pluck('id'); + $ingredientIds = $bar->ingredients()->pluck('id'); + + DB::transaction(function () use ($cocktailIds, $ingredientIds) { + DB::table('images') + ->where('imageable_type', \Kami\Cocktail\Models\Cocktail::class) + ->whereIn('imageable_id', $cocktailIds) + ->delete(); + + DB::table('images') + ->where('imageable_type', \Kami\Cocktail\Models\Ingredient::class) + ->whereIn('imageable_id', $ingredientIds) + ->delete(); + }); + + File::deleteDirectory(storage_path('bar-assistant/uploads/cocktails/' . $bar->id . '/')); + File::deleteDirectory(storage_path('bar-assistant/uploads/ingredients/' . $bar->id . '/')); + } + private function processImageFile(InterventionImage $image, ?string $filename = null): array { $filename = $filename ?? Str::random(40); @@ -153,7 +175,7 @@ private function processImageFile(InterventionImage $image, ?string $filename = try { $thumbHash = $this->generateThumbHash($image); } catch (Throwable $e) { - $this->log->info('[IMAGE_SERVICE] ThumbHash Error | ' . $e->getMessage()); + $this->log->error('[IMAGE_SERVICE] ThumbHash Error | ' . $e->getMessage()); throw $e; } @@ -161,7 +183,7 @@ private function processImageFile(InterventionImage $image, ?string $filename = try { $this->disk->put($filepath, (string) $image->encode()); } catch (Throwable $e) { - $this->log->info('[IMAGE_SERVICE] ' . $e->getMessage()); + $this->log->error('[IMAGE_SERVICE] ' . $e->getMessage()); throw $e; } diff --git a/app/Services/ImportService.php b/app/Services/ImportService.php deleted file mode 100644 index f1b01945..00000000 --- a/app/Services/ImportService.php +++ /dev/null @@ -1,248 +0,0 @@ - $sourceData Scraper data - * @return Cocktail Database model of the cocktail - */ - public function importCocktailFromArray(array $sourceData, int $userId = 1): Cocktail - { - $dbIngredients = DB::table('ingredients')->select('id', DB::raw('LOWER(name) AS name'))->get()->keyBy('name'); - $dbGlasses = DB::table('glasses')->select('id', DB::raw('LOWER(name) AS name'))->get()->keyBy('name'); - $dbMethods = DB::table('cocktail_methods')->select('id', DB::raw('LOWER(name) AS name'))->get()->keyBy('name'); - - // Add images - $cocktailImages = []; - foreach ($sourceData['images'] ?? [] as $image) { - try { - $imageDTO = new Image( - ImageProcessor::make($image['url']), - $image['copyright'] ?? null - ); - - $cocktailImages[] = $this->imageService->uploadAndSaveImages([$imageDTO], 1)[0]->id; - } catch (Throwable $e) { - Log::error($e->getMessage()); - } - } - - // Match glass - $glassId = null; - if ($sourceData['glass']) { - $glassNameLower = strtolower($sourceData['glass']); - if ($dbGlasses->has($glassNameLower)) { - $glassId = $dbGlasses->get($glassNameLower)->id; - } elseif ($sourceData['glass'] !== null) { - $newGlass = new Glass(); - $newGlass->name = ucfirst($sourceData['glass']); - $newGlass->description = 'Created by scraper from ' . $sourceData['source']; - $newGlass->save(); - $dbGlasses->put($glassNameLower, $newGlass->id); - $glassId = $newGlass->id; - } - } - - // Match method - $methodId = null; - if ($sourceData['method']) { - $methodNameLower = strtolower($sourceData['method']); - if ($dbMethods->has($methodNameLower)) { - $methodId = $dbMethods->get($methodNameLower)->id; - } - } - - // Match ingredients - $ingredients = []; - $sort = 1; - foreach ($sourceData['ingredients'] as $scrapedIngredient) { - if ($dbIngredients->has(strtolower($scrapedIngredient['name']))) { - $ingredientId = $dbIngredients->get(strtolower($scrapedIngredient['name']))->id; - } else { - $newIngredient = $this->ingredientService->createIngredient( - ucfirst($scrapedIngredient['name']), - 1, - $userId, - $scrapedIngredient['strength'] ?? 0.0, - $scrapedIngredient['description'] ?? 'Created by scraper from ' . $sourceData['source'], - $scrapedIngredient['origin'] ?? null - ); - $dbIngredients->put(strtolower($scrapedIngredient['name']), $newIngredient->id); - $ingredientId = $newIngredient->id; - } - - $substitutes = []; - if (array_key_exists('substitutes', $scrapedIngredient) && !empty($scrapedIngredient['substitutes'])) { - foreach ($scrapedIngredient['substitutes'] as $substituteName) { - if ($dbIngredients->has(strtolower($substituteName))) { - $substitutes[] = $dbIngredients->get(strtolower($substituteName))->id; - } - } - } - - $ingredient = new IngredientDTO( - $ingredientId, - $scrapedIngredient['name'], - $scrapedIngredient['amount'], - $scrapedIngredient['units'], - $sort, - $scrapedIngredient['optional'] ?? false, - $substitutes, - ); - - $ingredients[] = $ingredient; - $sort++; - } - - $cocktailDTO = new CocktailDTO( - $sourceData['name'], - $sourceData['instructions'], - $userId, - $sourceData['description'], - $sourceData['source'], - $sourceData['garnish'], - $glassId, - $methodId, - $sourceData['tags'], - $ingredients, - $cocktailImages, - ); - - return $this->cocktailService->createCocktail($cocktailDTO); - } - - /** - * Import zipped data from another BA instance - * - * @param string $zipFilePath - * @return void - */ - public function importFromZipFile(string $zipFilePath): void - { - Log::info(sprintf('[IMPORT_SERVICE] Started importing data from "%s"', $zipFilePath)); - $importTimeStart = microtime(true); - - $unzipPath = storage_path('temp/export/import_' . Str::random(8)); - /** @var \Illuminate\Support\Facades\Storage */ - $disk = Storage::build([ - 'driver' => 'local', - 'root' => $unzipPath, - ]); - - // Extract the archive - $zip = new ZipArchive(); - if ($zip->open($zipFilePath) !== true) { - $message = sprintf('[IMPORT_SERVICE] Error opening zip archive with filepath "%s"', $zipFilePath); - Log::error($message); - - throw new ImportException($message); - } - $zip->extractTo($unzipPath); - $zip->close(); - - $importOrder = [ - 'ingredient_categories', - 'glasses', - 'tags', - 'ingredients', - 'cocktails', - 'cocktail_ingredients', - 'cocktail_ingredient_substitutes', - 'cocktail_tag', - 'images', - ]; - - foreach (array_reverse($importOrder) as $tableName) { - try { - DB::table($tableName)->truncate(); - } catch (Throwable) { - Log::error(sprintf('[IMPORT_SERVICE] Unable to truncate table "%s"', $tableName)); - } - } - - DB::statement('PRAGMA foreign_keys = OFF'); - foreach ($importOrder as $tableName) { - $data = json_decode(file_get_contents($disk->path($tableName . '.json')), true); - - foreach ($data as $row) { - try { - DB::table($tableName)->insert($row); - } catch (Throwable) { - Log::error(sprintf('[IMPORT_SERVICE] Unable to import row with id "%s" to table "%s"', $row['id'], $tableName)); - } - } - } - DB::statement('PRAGMA foreign_keys = ON'); - - /** @var \Illuminate\Support\Facades\Storage */ - $baDisk = Storage::disk('bar-assistant'); - - foreach (glob($disk->path('uploads/cocktails/*')) as $pathFrom) { - if (!copy($pathFrom, $baDisk->path('cocktails/' . basename($pathFrom)))) { - Log::error(sprintf('[IMPORT_SERVICE] Unable to copy cocktail image from path "%s"', $pathFrom)); - } - } - - foreach (glob($disk->path('uploads/ingredients/*')) as $pathFrom) { - if (!copy($pathFrom, $baDisk->path('ingredients/' . basename($pathFrom)))) { - Log::error(sprintf('[IMPORT_SERVICE] Unable to copy ingredient image from path "%s"', $pathFrom)); - } - } - - $importTimeEnd = microtime(true); - Log::info(sprintf('[IMPORT_SERVICE] Finished importing data in %s seconds', $importTimeEnd - $importTimeStart)); - - $disk->deleteDirectory('/'); - } - - /** - * @param array{name: string, description: string|null, cocktails: array} $sourceData - */ - public function importCocktailCollection(array $sourceData, int $userId = 1): CocktailCollection - { - $collection = new CocktailCollection(); - $collection->name = $sourceData['name']; - $collection->description = $sourceData['description']; - $collection->user_id = $userId; - $collection->save(); - - foreach ($sourceData['cocktails'] as $cocktail) { - $cocktail = $this->importCocktailFromArray($cocktail, $userId); - $cocktail->addToCollection($collection); - } - - return $collection; - } -} diff --git a/app/Services/IngredientService.php b/app/Services/IngredientService.php index 931d5b7a..b90be085 100644 --- a/app/Services/IngredientService.php +++ b/app/Services/IngredientService.php @@ -5,7 +5,6 @@ namespace Kami\Cocktail\Services; use Throwable; -use InvalidArgumentException; use Illuminate\Log\LogManager; use Kami\Cocktail\Models\Image; use Illuminate\Support\Collection; @@ -14,6 +13,7 @@ use Illuminate\Database\DatabaseManager; use Kami\Cocktail\Exceptions\ImageException; use Kami\Cocktail\Exceptions\IngredientException; +use Kami\Cocktail\DataObjects\Ingredient\Ingredient as IngredientDTO; class IngredientService { @@ -23,57 +23,33 @@ public function __construct( ) { } - /** - * Create a new ingredient - * - * @param string $name - * @param int $ingredientCategoryId - * @param int $userId - * @param float $strength - * @param string|null $description - * @param string|null $origin - * @param string|null $color - * @param int|null $parentIngredientId - * @param array $images - * @return \Kami\Cocktail\Models\Ingredient - */ - public function createIngredient( - string $name, - int $ingredientCategoryId, - int $userId, - float $strength = 0.0, - ?string $description = null, - ?string $origin = null, - ?string $color = null, - ?int $parentIngredientId = null, - array $images = [] - ): Ingredient { + public function createIngredient(IngredientDTO $dto): Ingredient + { try { $ingredient = new Ingredient(); - $ingredient->name = $name; - $ingredient->ingredient_category_id = $ingredientCategoryId; - $ingredient->strength = $strength; - $ingredient->description = $description; - $ingredient->origin = $origin; - $ingredient->color = $color; - $ingredient->parent_ingredient_id = $parentIngredientId; - $ingredient->user_id = $userId; + $ingredient->bar_id = $dto->barId; + $ingredient->name = $dto->name; + $ingredient->ingredient_category_id = $dto->ingredientCategoryId; + $ingredient->strength = $dto->strength; + $ingredient->description = $dto->description; + $ingredient->origin = $dto->origin; + $ingredient->color = $dto->color; + $ingredient->parent_ingredient_id = $dto->parentIngredientId; + $ingredient->created_user_id = $dto->userId; $ingredient->save(); } catch (Throwable $e) { throw new IngredientException('Error occured while creating ingredient!', 0, $e); } - if (count($images) > 0) { + if (count($dto->images) > 0) { try { - $imageModels = Image::findOrFail($images); + $imageModels = Image::findOrFail($dto->images); $ingredient->attachImages($imageModels); } catch (Throwable $e) { throw new ImageException('Error occured while attaching images to ingredient with id "' . $ingredient->id . '"', 0, $e); } } - $this->log->info('[INGREDIENT_SERVICE] Ingredient created with id:' . $ingredient->id); - // Refresh model for response $ingredient->refresh(); // Upsert scout index @@ -82,56 +58,31 @@ public function createIngredient( return $ingredient; } - /** - * Update an existing ingredient - * - * @param int $id - * @param string $name - * @param int $ingredientCategoryId - * @param int $userId - * @param float $strength - * @param string|null $description - * @param string|null $origin - * @param string|null $color - * @param int|null $parentIngredientId - * @param array $images - * @return \Kami\Cocktail\Models\Ingredient - */ - public function updateIngredient( - int $id, - string $name, - int $ingredientCategoryId, - int $userId, - float $strength = 0.0, - ?string $description = null, - ?string $origin = null, - ?string $color = null, - ?int $parentIngredientId = null, - array $images = [] - ): Ingredient { - if ($parentIngredientId === $id) { + public function updateIngredient(int $id, IngredientDTO $dto): Ingredient + { + if ($dto->parentIngredientId === $id) { throw new IngredientException('Parent ingredient is the same as the current ingredient!'); } try { $ingredient = Ingredient::findOrFail($id); - $ingredient->name = $name; - $ingredient->ingredient_category_id = $ingredientCategoryId; - $ingredient->strength = $strength; - $ingredient->description = $description; - $ingredient->origin = $origin; - $ingredient->color = $color; - $ingredient->parent_ingredient_id = $parentIngredientId; - $ingredient->user_id = $userId; + $ingredient->name = $dto->name; + $ingredient->ingredient_category_id = $dto->ingredientCategoryId; + $ingredient->strength = $dto->strength; + $ingredient->description = $dto->description; + $ingredient->origin = $dto->origin; + $ingredient->color = $dto->color; + $ingredient->parent_ingredient_id = $dto->parentIngredientId; + $ingredient->updated_user_id = $dto->userId; + $ingredient->updated_at = now(); $ingredient->save(); } catch (Throwable $e) { throw new IngredientException('Error occured while updating ingredient!', 0, $e); } - if (count($images) > 0) { - // $ingredient->deleteImages(); + if (count($dto->images) > 0) { try { - $imageModels = Image::findOrFail($images); + $imageModels = Image::findOrFail($dto->images); $ingredient->attachImages($imageModels); } catch (Throwable $e) { throw new ImageException('Error occured while attaching images to ingredient with id "' . $ingredient->id . '"', 0, $e); @@ -144,11 +95,11 @@ public function updateIngredient( $ingredient->refresh(); // Upsert scout index $ingredient->save(); - $ingredient->cocktails->each(fn ($cocktail) => $cocktail->searchable()); $ingredient->cocktails->each(function (Cocktail $cocktail) { $cocktail->abv = $cocktail->getABV(); $cocktail->save(); }); + $ingredient->cocktails->each(fn ($cocktail) => $cocktail->searchable()); return $ingredient; } @@ -156,13 +107,15 @@ public function updateIngredient( /** * @return Collection */ - public function getMainIngredientsInCocktails(): Collection + public function getMainIngredientsInCocktails(int $barId): Collection { return $this->db->table('cocktail_ingredients') - ->selectRaw('ingredient_id, COUNT(cocktail_id) AS cocktails') + ->selectRaw('cocktail_ingredients.ingredient_id, COUNT(cocktail_ingredients.cocktail_id) AS cocktails') + ->join('cocktails', 'cocktails.id', '=', 'cocktail_ingredients.cocktail_id') ->where('sort', 1) + ->where('cocktails.bar_id', $barId) ->groupBy('cocktail_id') - ->orderBy('id', 'desc') + ->orderBy('cocktails.name', 'desc') ->get(); } } diff --git a/app/helpers.php b/app/helpers.php new file mode 100644 index 00000000..04fa1a52 --- /dev/null +++ b/app/helpers.php @@ -0,0 +1,19 @@ +make(BarContext::class)->getBar(); + } +} diff --git a/composer.json b/composer.json index 63c479a1..539d10df 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ "karlomikus/recipe-utils": "^0.3.0", "laravel/framework": "^10.0", "laravel/sanctum": "^3.2", - "laravel/scout": "^9.4", + "laravel/scout": "^10.4", "laravel/tinker": "^2.7", "meilisearch/meilisearch-php": "^1.0", "spatie/array-to-xml": "^3.1", @@ -40,6 +40,9 @@ "symplify/easy-coding-standard": "^11.1" }, "autoload": { + "files": [ + "app/helpers.php" + ], "psr-4": { "Kami\\Cocktail\\": "app/", "Database\\Factories\\": "database/factories/", diff --git a/composer.lock b/composer.lock index 134575ca..f9c21044 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "92acf5dcecfda0f55087429218a680ad", + "content-hash": "62336c2c94b4bd291081e3d46f5506d4", "packages": [ { "name": "algolia/algoliasearch-client-php", @@ -469,16 +469,16 @@ }, { "name": "doctrine/dbal", - "version": "3.6.6", + "version": "3.7.0", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "63646ffd71d1676d2f747f871be31b7e921c7864" + "reference": "00d03067f07482f025d41ab55e4ba0db5eca2cdf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/63646ffd71d1676d2f747f871be31b7e921c7864", - "reference": "63646ffd71d1676d2f747f871be31b7e921c7864", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/00d03067f07482f025d41ab55e4ba0db5eca2cdf", + "reference": "00d03067f07482f025d41ab55e4ba0db5eca2cdf", "shasum": "" }, "require": { @@ -494,9 +494,9 @@ "doctrine/coding-standard": "12.0.0", "fig/log-test": "^1", "jetbrains/phpstorm-stubs": "2023.1", - "phpstan/phpstan": "1.10.29", + "phpstan/phpstan": "1.10.35", "phpstan/phpstan-strict-rules": "^1.5", - "phpunit/phpunit": "9.6.9", + "phpunit/phpunit": "9.6.13", "psalm/plugin-phpunit": "0.18.4", "slevomat/coding-standard": "8.13.1", "squizlabs/php_codesniffer": "3.7.2", @@ -562,7 +562,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/3.6.6" + "source": "https://github.com/doctrine/dbal/tree/3.7.0" }, "funding": [ { @@ -578,20 +578,20 @@ "type": "tidelift" } ], - "time": "2023-08-17T05:38:17+00:00" + "time": "2023-09-26T20:56:55+00:00" }, { "name": "doctrine/deprecations", - "version": "v1.1.1", + "version": "1.1.2", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "612a3ee5ab0d5dd97b7cf3874a6efe24325efac3" + "reference": "4f2d4f2836e7ec4e7a8625e75c6aa916004db931" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/612a3ee5ab0d5dd97b7cf3874a6efe24325efac3", - "reference": "612a3ee5ab0d5dd97b7cf3874a6efe24325efac3", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/4f2d4f2836e7ec4e7a8625e75c6aa916004db931", + "reference": "4f2d4f2836e7ec4e7a8625e75c6aa916004db931", "shasum": "" }, "require": { @@ -623,9 +623,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/v1.1.1" + "source": "https://github.com/doctrine/deprecations/tree/1.1.2" }, - "time": "2023-06-03T09:27:29+00:00" + "time": "2023-09-27T20:04:15+00:00" }, { "name": "doctrine/event-manager", @@ -1743,16 +1743,16 @@ }, { "name": "laravel/framework", - "version": "v10.24.0", + "version": "v10.25.2", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "bcebd0a4c015d5c38aeec299d355a42451dd3726" + "reference": "6014dd456b414b305fb0b408404efdcec18e64bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/bcebd0a4c015d5c38aeec299d355a42451dd3726", - "reference": "bcebd0a4c015d5c38aeec299d355a42451dd3726", + "url": "https://api.github.com/repos/laravel/framework/zipball/6014dd456b414b305fb0b408404efdcec18e64bc", + "reference": "6014dd456b414b305fb0b408404efdcec18e64bc", "shasum": "" }, "require": { @@ -1770,7 +1770,7 @@ "ext-tokenizer": "*", "fruitcake/php-cors": "^1.2", "guzzlehttp/uri-template": "^1.0", - "laravel/prompts": "^0.1", + "laravel/prompts": "^0.1.9", "laravel/serializable-closure": "^1.3", "league/commonmark": "^2.2.1", "league/flysystem": "^3.8.0", @@ -1852,7 +1852,7 @@ "league/flysystem-read-only": "^3.3", "league/flysystem-sftp-v3": "^3.0", "mockery/mockery": "^1.5.1", - "orchestra/testbench-core": "^8.10", + "orchestra/testbench-core": "^8.12", "pda/pheanstalk": "^4.0", "phpstan/phpstan": "^1.4.7", "phpunit/phpunit": "^10.0.7", @@ -1939,20 +1939,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2023-09-19T15:25:04+00:00" + "time": "2023-09-28T14:08:59+00:00" }, { "name": "laravel/prompts", - "version": "v0.1.8", + "version": "v0.1.10", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "68dcc65babf92e1fb43cba0b3f78fc3d8002709c" + "reference": "37ed55f6950d921a87d5beeab16d03f8de26b060" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/68dcc65babf92e1fb43cba0b3f78fc3d8002709c", - "reference": "68dcc65babf92e1fb43cba0b3f78fc3d8002709c", + "url": "https://api.github.com/repos/laravel/prompts/zipball/37ed55f6950d921a87d5beeab16d03f8de26b060", + "reference": "37ed55f6950d921a87d5beeab16d03f8de26b060", "shasum": "" }, "require": { @@ -1961,6 +1961,10 @@ "php": "^8.1", "symfony/console": "^6.2" }, + "conflict": { + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, "require-dev": { "mockery/mockery": "^1.5", "pestphp/pest": "^2.3", @@ -1971,6 +1975,11 @@ "ext-pcntl": "Required for the spinner to be animated." }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.1.x-dev" + } + }, "autoload": { "files": [ "src/helpers.php" @@ -1985,9 +1994,9 @@ ], "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.1.8" + "source": "https://github.com/laravel/prompts/tree/v0.1.10" }, - "time": "2023-09-19T15:33:56+00:00" + "time": "2023-09-29T07:26:07+00:00" }, { "name": "laravel/sanctum", @@ -2057,43 +2066,45 @@ }, { "name": "laravel/scout", - "version": "v9.8.1", + "version": "v10.4.0", "source": { "type": "git", "url": "https://github.com/laravel/scout.git", - "reference": "38595717b396ce733d432b82e3225fa4e0d6c8ef" + "reference": "cc46608b277c2922c53533995b8fe2b7bf72315a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/scout/zipball/38595717b396ce733d432b82e3225fa4e0d6c8ef", - "reference": "38595717b396ce733d432b82e3225fa4e0d6c8ef", + "url": "https://api.github.com/repos/laravel/scout/zipball/cc46608b277c2922c53533995b8fe2b7bf72315a", + "reference": "cc46608b277c2922c53533995b8fe2b7bf72315a", "shasum": "" }, "require": { - "illuminate/bus": "^8.0|^9.0|^10.0", - "illuminate/contracts": "^8.0|^9.0|^10.0", - "illuminate/database": "^8.0|^9.0|^10.0", - "illuminate/http": "^8.0|^9.0|^10.0", - "illuminate/pagination": "^8.0|^9.0|^10.0", - "illuminate/queue": "^8.0|^9.0|^10.0", - "illuminate/support": "^8.0|^9.0|^10.0", - "php": "^7.3|^8.0" + "illuminate/bus": "^9.0|^10.0", + "illuminate/contracts": "^9.0|^10.0", + "illuminate/database": "^9.0|^10.0", + "illuminate/http": "^9.0|^10.0", + "illuminate/pagination": "^9.0|^10.0", + "illuminate/queue": "^9.0|^10.0", + "illuminate/support": "^9.0|^10.0", + "php": "^8.0" }, "require-dev": { - "meilisearch/meilisearch-php": "^0.19", + "algolia/algoliasearch-client-php": "^3.2", + "meilisearch/meilisearch-php": "^1.0", "mockery/mockery": "^1.0", - "orchestra/testbench": "^6.17|^7.0|^8.0", + "orchestra/testbench": "^7.31|^8.11", "php-http/guzzle7-adapter": "^1.0", + "phpstan/phpstan": "^1.10", "phpunit/phpunit": "^9.3" }, "suggest": { "algolia/algoliasearch-client-php": "Required to use the Algolia engine (^3.2).", - "meilisearch/meilisearch-php": "Required to use the MeiliSearch engine (^0.23)." + "meilisearch/meilisearch-php": "Required to use the Meilisearch engine (^1.0)." }, "type": "library", "extra": { "branch-alias": { - "dev-master": "9.x-dev" + "dev-master": "10.x-dev" }, "laravel": { "providers": [ @@ -2126,7 +2137,7 @@ "issues": "https://github.com/laravel/scout/issues", "source": "https://github.com/laravel/scout" }, - "time": "2023-02-14T16:53:14+00:00" + "time": "2023-09-26T16:36:29+00:00" }, { "name": "laravel/serializable-closure", @@ -2720,16 +2731,16 @@ }, { "name": "meilisearch/meilisearch-php", - "version": "v1.3.0", + "version": "v1.4.0", "source": { "type": "git", "url": "https://github.com/meilisearch/meilisearch-php.git", - "reference": "ba2756acae52c42e6f99376cebbe3b0a992404c6" + "reference": "956e2677b0cbbff8e9dfa658106097639bc9a507" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/meilisearch/meilisearch-php/zipball/ba2756acae52c42e6f99376cebbe3b0a992404c6", - "reference": "ba2756acae52c42e6f99376cebbe3b0a992404c6", + "url": "https://api.github.com/repos/meilisearch/meilisearch-php/zipball/956e2677b0cbbff8e9dfa658106097639bc9a507", + "reference": "956e2677b0cbbff8e9dfa658106097639bc9a507", "shasum": "" }, "require": { @@ -2744,7 +2755,7 @@ "guzzlehttp/guzzle": "^7.1", "http-interop/http-factory-guzzle": "^1.0", "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "1.10.26", + "phpstan/phpstan": "1.10.32", "phpstan/phpstan-deprecation-rules": "^1.0", "phpstan/phpstan-phpunit": "^1.0", "phpstan/phpstan-strict-rules": "^1.1", @@ -2782,9 +2793,9 @@ ], "support": { "issues": "https://github.com/meilisearch/meilisearch-php/issues", - "source": "https://github.com/meilisearch/meilisearch-php/tree/v1.3.0" + "source": "https://github.com/meilisearch/meilisearch-php/tree/v1.4.0" }, - "time": "2023-07-31T12:08:59+00:00" + "time": "2023-09-25T11:42:54+00:00" }, { "name": "monolog/monolog", @@ -2889,16 +2900,16 @@ }, { "name": "nesbot/carbon", - "version": "2.70.0", + "version": "2.71.0", "source": { "type": "git", "url": "https://github.com/briannesbitt/Carbon.git", - "reference": "d3298b38ea8612e5f77d38d1a99438e42f70341d" + "reference": "98276233188583f2ff845a0f992a235472d9466a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/d3298b38ea8612e5f77d38d1a99438e42f70341d", - "reference": "d3298b38ea8612e5f77d38d1a99438e42f70341d", + "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/98276233188583f2ff845a0f992a235472d9466a", + "reference": "98276233188583f2ff845a0f992a235472d9466a", "shasum": "" }, "require": { @@ -2991,7 +3002,7 @@ "type": "tidelift" } ], - "time": "2023-09-07T16:43:50+00:00" + "time": "2023-09-25T11:31:05+00:00" }, { "name": "nette/schema", @@ -3890,16 +3901,16 @@ }, { "name": "psr/http-client", - "version": "1.0.2", + "version": "1.0.3", "source": { "type": "git", "url": "https://github.com/php-fig/http-client.git", - "reference": "0955afe48220520692d2d09f7ab7e0f93ffd6a31" + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-client/zipball/0955afe48220520692d2d09f7ab7e0f93ffd6a31", - "reference": "0955afe48220520692d2d09f7ab7e0f93ffd6a31", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", "shasum": "" }, "require": { @@ -3936,9 +3947,9 @@ "psr-18" ], "support": { - "source": "https://github.com/php-fig/http-client/tree/1.0.2" + "source": "https://github.com/php-fig/http-client" }, - "time": "2023-04-10T20:12:12+00:00" + "time": "2023-09-23T14:17:50+00:00" }, { "name": "psr/http-factory", @@ -4713,16 +4724,16 @@ }, { "name": "spatie/laravel-responsecache", - "version": "7.4.7", + "version": "7.4.9", "source": { "type": "git", "url": "https://github.com/spatie/laravel-responsecache.git", - "reference": "236bcb19a874aea10ba6351a6bd1e306df329c10" + "reference": "6bf62468041f80cd51b669fd856829ee87ef10be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-responsecache/zipball/236bcb19a874aea10ba6351a6bd1e306df329c10", - "reference": "236bcb19a874aea10ba6351a6bd1e306df329c10", + "url": "https://api.github.com/repos/spatie/laravel-responsecache/zipball/6bf62468041f80cd51b669fd856829ee87ef10be", + "reference": "6bf62468041f80cd51b669fd856829ee87ef10be", "shasum": "" }, "require": { @@ -4781,7 +4792,7 @@ "spatie" ], "support": { - "source": "https://github.com/spatie/laravel-responsecache/tree/7.4.7" + "source": "https://github.com/spatie/laravel-responsecache/tree/7.4.9" }, "funding": [ { @@ -4793,7 +4804,7 @@ "type": "github" } ], - "time": "2023-04-07T08:26:47+00:00" + "time": "2023-10-02T09:55:32+00:00" }, { "name": "spatie/laravel-sluggable", @@ -5261,16 +5272,16 @@ }, { "name": "symfony/error-handler", - "version": "v6.3.2", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "85fd65ed295c4078367c784e8a5a6cee30348b7a" + "reference": "1f69476b64fb47105c06beef757766c376b548c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/85fd65ed295c4078367c784e8a5a6cee30348b7a", - "reference": "85fd65ed295c4078367c784e8a5a6cee30348b7a", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/1f69476b64fb47105c06beef757766c376b548c4", + "reference": "1f69476b64fb47105c06beef757766c376b548c4", "shasum": "" }, "require": { @@ -5315,7 +5326,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v6.3.2" + "source": "https://github.com/symfony/error-handler/tree/v6.3.5" }, "funding": [ { @@ -5331,7 +5342,7 @@ "type": "tidelift" } ], - "time": "2023-07-16T17:05:46+00:00" + "time": "2023-09-12T06:57:20+00:00" }, { "name": "symfony/event-dispatcher", @@ -5491,16 +5502,16 @@ }, { "name": "symfony/finder", - "version": "v6.3.3", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "9915db259f67d21eefee768c1abcf1cc61b1fc9e" + "reference": "a1b31d88c0e998168ca7792f222cbecee47428c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/9915db259f67d21eefee768c1abcf1cc61b1fc9e", - "reference": "9915db259f67d21eefee768c1abcf1cc61b1fc9e", + "url": "https://api.github.com/repos/symfony/finder/zipball/a1b31d88c0e998168ca7792f222cbecee47428c4", + "reference": "a1b31d88c0e998168ca7792f222cbecee47428c4", "shasum": "" }, "require": { @@ -5535,7 +5546,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v6.3.3" + "source": "https://github.com/symfony/finder/tree/v6.3.5" }, "funding": [ { @@ -5551,20 +5562,20 @@ "type": "tidelift" } ], - "time": "2023-07-31T08:31:44+00:00" + "time": "2023-09-26T12:56:25+00:00" }, { "name": "symfony/http-client", - "version": "v6.3.2", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "15f9f4bad62bfcbe48b5dedd866f04a08fc7ff00" + "reference": "213e564da4cbf61acc9728d97e666bcdb868c10d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/15f9f4bad62bfcbe48b5dedd866f04a08fc7ff00", - "reference": "15f9f4bad62bfcbe48b5dedd866f04a08fc7ff00", + "url": "https://api.github.com/repos/symfony/http-client/zipball/213e564da4cbf61acc9728d97e666bcdb868c10d", + "reference": "213e564da4cbf61acc9728d97e666bcdb868c10d", "shasum": "" }, "require": { @@ -5627,7 +5638,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v6.3.2" + "source": "https://github.com/symfony/http-client/tree/v6.3.5" }, "funding": [ { @@ -5643,7 +5654,7 @@ "type": "tidelift" } ], - "time": "2023-07-05T08:41:27+00:00" + "time": "2023-09-29T15:57:12+00:00" }, { "name": "symfony/http-client-contracts", @@ -5725,16 +5736,16 @@ }, { "name": "symfony/http-foundation", - "version": "v6.3.4", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "cac1556fdfdf6719668181974104e6fcfa60e844" + "reference": "b50f5e281d722cb0f4c296f908bacc3e2b721957" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/cac1556fdfdf6719668181974104e6fcfa60e844", - "reference": "cac1556fdfdf6719668181974104e6fcfa60e844", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/b50f5e281d722cb0f4c296f908bacc3e2b721957", + "reference": "b50f5e281d722cb0f4c296f908bacc3e2b721957", "shasum": "" }, "require": { @@ -5782,7 +5793,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v6.3.4" + "source": "https://github.com/symfony/http-foundation/tree/v6.3.5" }, "funding": [ { @@ -5798,20 +5809,20 @@ "type": "tidelift" } ], - "time": "2023-08-22T08:20:46+00:00" + "time": "2023-09-04T21:33:54+00:00" }, { "name": "symfony/http-kernel", - "version": "v6.3.4", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "36abb425b4af863ae1fe54d8a8b8b4c76a2bccdb" + "reference": "9f991a964368bee8d883e8d57ced4fe9fff04dfc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/36abb425b4af863ae1fe54d8a8b8b4c76a2bccdb", - "reference": "36abb425b4af863ae1fe54d8a8b8b4c76a2bccdb", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/9f991a964368bee8d883e8d57ced4fe9fff04dfc", + "reference": "9f991a964368bee8d883e8d57ced4fe9fff04dfc", "shasum": "" }, "require": { @@ -5895,7 +5906,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v6.3.4" + "source": "https://github.com/symfony/http-kernel/tree/v6.3.5" }, "funding": [ { @@ -5911,20 +5922,20 @@ "type": "tidelift" } ], - "time": "2023-08-26T13:54:49+00:00" + "time": "2023-09-30T06:37:04+00:00" }, { "name": "symfony/mailer", - "version": "v6.3.0", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "7b03d9be1dea29bfec0a6c7b603f5072a4c97435" + "reference": "d89611a7830d51b5e118bca38e390dea92f9ea06" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/7b03d9be1dea29bfec0a6c7b603f5072a4c97435", - "reference": "7b03d9be1dea29bfec0a6c7b603f5072a4c97435", + "url": "https://api.github.com/repos/symfony/mailer/zipball/d89611a7830d51b5e118bca38e390dea92f9ea06", + "reference": "d89611a7830d51b5e118bca38e390dea92f9ea06", "shasum": "" }, "require": { @@ -5975,7 +5986,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v6.3.0" + "source": "https://github.com/symfony/mailer/tree/v6.3.5" }, "funding": [ { @@ -5991,20 +6002,20 @@ "type": "tidelift" } ], - "time": "2023-05-29T12:49:39+00:00" + "time": "2023-09-06T09:47:15+00:00" }, { "name": "symfony/mime", - "version": "v6.3.3", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "9a0cbd52baa5ba5a5b1f0cacc59466f194730f98" + "reference": "d5179eedf1cb2946dbd760475ebf05c251ef6a6e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/9a0cbd52baa5ba5a5b1f0cacc59466f194730f98", - "reference": "9a0cbd52baa5ba5a5b1f0cacc59466f194730f98", + "url": "https://api.github.com/repos/symfony/mime/zipball/d5179eedf1cb2946dbd760475ebf05c251ef6a6e", + "reference": "d5179eedf1cb2946dbd760475ebf05c251ef6a6e", "shasum": "" }, "require": { @@ -6059,7 +6070,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v6.3.3" + "source": "https://github.com/symfony/mime/tree/v6.3.5" }, "funding": [ { @@ -6075,7 +6086,7 @@ "type": "tidelift" } ], - "time": "2023-07-31T07:08:24+00:00" + "time": "2023-09-29T06:59:36+00:00" }, { "name": "symfony/options-resolver", @@ -6945,16 +6956,16 @@ }, { "name": "symfony/routing", - "version": "v6.3.3", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "e7243039ab663822ff134fbc46099b5fdfa16f6a" + "reference": "82616e59acd3e3d9c916bba798326cb7796d7d31" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/e7243039ab663822ff134fbc46099b5fdfa16f6a", - "reference": "e7243039ab663822ff134fbc46099b5fdfa16f6a", + "url": "https://api.github.com/repos/symfony/routing/zipball/82616e59acd3e3d9c916bba798326cb7796d7d31", + "reference": "82616e59acd3e3d9c916bba798326cb7796d7d31", "shasum": "" }, "require": { @@ -7008,7 +7019,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v6.3.3" + "source": "https://github.com/symfony/routing/tree/v6.3.5" }, "funding": [ { @@ -7024,7 +7035,7 @@ "type": "tidelift" } ], - "time": "2023-07-31T07:08:24+00:00" + "time": "2023-09-20T16:05:51+00:00" }, { "name": "symfony/service-contracts", @@ -7110,16 +7121,16 @@ }, { "name": "symfony/string", - "version": "v6.3.2", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "53d1a83225002635bca3482fcbf963001313fb68" + "reference": "13d76d0fb049051ed12a04bef4f9de8715bea339" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/53d1a83225002635bca3482fcbf963001313fb68", - "reference": "53d1a83225002635bca3482fcbf963001313fb68", + "url": "https://api.github.com/repos/symfony/string/zipball/13d76d0fb049051ed12a04bef4f9de8715bea339", + "reference": "13d76d0fb049051ed12a04bef4f9de8715bea339", "shasum": "" }, "require": { @@ -7176,7 +7187,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.3.2" + "source": "https://github.com/symfony/string/tree/v6.3.5" }, "funding": [ { @@ -7192,7 +7203,7 @@ "type": "tidelift" } ], - "time": "2023-07-05T08:41:27+00:00" + "time": "2023-09-18T10:38:32+00:00" }, { "name": "symfony/translation", @@ -7443,16 +7454,16 @@ }, { "name": "symfony/var-dumper", - "version": "v6.3.4", + "version": "v6.3.5", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "2027be14f8ae8eae999ceadebcda5b4909b81d45" + "reference": "3d9999376be5fea8de47752837a3e1d1c5f69ef5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/2027be14f8ae8eae999ceadebcda5b4909b81d45", - "reference": "2027be14f8ae8eae999ceadebcda5b4909b81d45", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/3d9999376be5fea8de47752837a3e1d1c5f69ef5", + "reference": "3d9999376be5fea8de47752837a3e1d1c5f69ef5", "shasum": "" }, "require": { @@ -7507,7 +7518,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v6.3.4" + "source": "https://github.com/symfony/var-dumper/tree/v6.3.5" }, "funding": [ { @@ -7523,7 +7534,7 @@ "type": "tidelift" } ], - "time": "2023-08-24T14:51:05+00:00" + "time": "2023-09-12T10:11:35+00:00" }, { "name": "symfony/yaml", @@ -8345,16 +8356,16 @@ }, { "name": "justinrainbow/json-schema", - "version": "5.2.12", + "version": "v5.2.13", "source": { "type": "git", "url": "https://github.com/justinrainbow/json-schema.git", - "reference": "ad87d5a5ca981228e0e205c2bc7dfb8e24559b60" + "reference": "fbbe7e5d79f618997bc3332a6f49246036c45793" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/justinrainbow/json-schema/zipball/ad87d5a5ca981228e0e205c2bc7dfb8e24559b60", - "reference": "ad87d5a5ca981228e0e205c2bc7dfb8e24559b60", + "url": "https://api.github.com/repos/justinrainbow/json-schema/zipball/fbbe7e5d79f618997bc3332a6f49246036c45793", + "reference": "fbbe7e5d79f618997bc3332a6f49246036c45793", "shasum": "" }, "require": { @@ -8409,9 +8420,9 @@ ], "support": { "issues": "https://github.com/justinrainbow/json-schema/issues", - "source": "https://github.com/justinrainbow/json-schema/tree/5.2.12" + "source": "https://github.com/justinrainbow/json-schema/tree/v5.2.13" }, - "time": "2022-04-13T08:02:27+00:00" + "time": "2023-09-26T02:20:38+00:00" }, { "name": "laravel/pint", @@ -9328,16 +9339,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.10.35", + "version": "1.10.36", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "e730e5facb75ffe09dfb229795e8c01a459f26c3" + "reference": "ffa3089511121a672e62969404e4fddc753f9b15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e730e5facb75ffe09dfb229795e8c01a459f26c3", - "reference": "e730e5facb75ffe09dfb229795e8c01a459f26c3", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/ffa3089511121a672e62969404e4fddc753f9b15", + "reference": "ffa3089511121a672e62969404e4fddc753f9b15", "shasum": "" }, "require": { @@ -9386,7 +9397,7 @@ "type": "tidelift" } ], - "time": "2023-09-19T15:27:56+00:00" + "time": "2023-09-29T14:07:45+00:00" }, { "name": "phpunit/php-code-coverage", diff --git a/config/bar-assistant.php b/config/bar-assistant.php index ebcb078f..3bed42b6 100644 --- a/config/bar-assistant.php +++ b/config/bar-assistant.php @@ -11,7 +11,7 @@ | */ - 'version' => env('BAR_ASSISTANT_VERSION', 'v0-dev'), + 'version' => env('BAR_ASSISTANT_VERSION', 'develop'), /* |-------------------------------------------------------------------------- @@ -46,16 +46,13 @@ /* |-------------------------------------------------------------------------- - | Disable login + | Max bars per user |-------------------------------------------------------------------------- | - | This option will disable the need to authenticate with token to access the api + | This will limit how many bars can a single user create | */ - 'disable_login' => env( - 'DISABLE_LOGIN', - false - ), + 'max_default_bars' => env('MAX_USER_BARS', 50), ]; diff --git a/config/database.php b/config/database.php index eb0ca4c2..c4832311 100644 --- a/config/database.php +++ b/config/database.php @@ -38,9 +38,16 @@ 'sqlite' => [ 'driver' => 'sqlite', 'url' => env('DATABASE_URL'), - 'database' => env('DB_DATABASE', storage_path('bar-assistant/database.sqlite')), + 'database' => env('DB_DATABASE', storage_path('bar-assistant/database.ba3.sqlite')), 'prefix' => '', - 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + 'foreign_key_constraints' => true, + ], + + 'sqlite_import_from_v2' => [ + 'driver' => 'sqlite', + 'database' => storage_path('bar-assistant/database.sqlite'), + 'prefix' => '', + 'foreign_key_constraints' => false, ], 'mysql' => [ diff --git a/database/factories/BarFactory.php b/database/factories/BarFactory.php new file mode 100644 index 00000000..0901e002 --- /dev/null +++ b/database/factories/BarFactory.php @@ -0,0 +1,24 @@ + + */ +class BarFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition() + { + return [ + 'name' => fake()->name(), + 'created_user_id' => \Kami\Cocktail\Models\User::factory(), + ]; + } +} diff --git a/database/factories/CocktailFactory.php b/database/factories/CocktailFactory.php index db8e4a58..28099086 100644 --- a/database/factories/CocktailFactory.php +++ b/database/factories/CocktailFactory.php @@ -19,7 +19,8 @@ public function definition() return [ 'name' => fake()->name(), 'instructions' => fake()->paragraph(), - 'user_id' => \Kami\Cocktail\Models\User::factory(), + 'created_user_id' => \Kami\Cocktail\Models\User::factory(), + 'bar_id' => \Kami\Cocktail\Models\Bar::factory(), ]; } } diff --git a/database/factories/CocktailMethodFactory.php b/database/factories/CocktailMethodFactory.php index e97b9799..194ceefe 100644 --- a/database/factories/CocktailMethodFactory.php +++ b/database/factories/CocktailMethodFactory.php @@ -19,6 +19,7 @@ public function definition() return [ 'name' => fake()->name(), 'dilution_percentage' => fake()->numberBetween(0, 50), + 'bar_id' => \Kami\Cocktail\Models\Bar::factory(), ]; } } diff --git a/database/factories/GlassFactory.php b/database/factories/GlassFactory.php index 6afbe19e..adac63e2 100644 --- a/database/factories/GlassFactory.php +++ b/database/factories/GlassFactory.php @@ -19,6 +19,7 @@ public function definition() return [ 'name' => fake()->name(), 'description' => fake()->paragraph(), + 'bar_id' => \Kami\Cocktail\Models\Bar::factory(), ]; } } diff --git a/database/factories/IngredientCategoryFactory.php b/database/factories/IngredientCategoryFactory.php index 36c2ef8f..6fbe6787 100644 --- a/database/factories/IngredientCategoryFactory.php +++ b/database/factories/IngredientCategoryFactory.php @@ -18,6 +18,7 @@ public function definition() { return [ 'name' => fake()->name(), + 'bar_id' => \Kami\Cocktail\Models\Bar::factory(), ]; } } diff --git a/database/factories/IngredientFactory.php b/database/factories/IngredientFactory.php index 805f9071..38d2ed76 100644 --- a/database/factories/IngredientFactory.php +++ b/database/factories/IngredientFactory.php @@ -21,7 +21,8 @@ public function definition() 'origin' => fake()->country(), 'strength' => fake()->randomFloat(2), 'ingredient_category_id' => \Kami\Cocktail\Models\IngredientCategory::factory(), - 'user_id' => \Kami\Cocktail\Models\User::factory(), + 'created_user_id' => \Kami\Cocktail\Models\User::factory(), + 'bar_id' => \Kami\Cocktail\Models\Bar::factory(), ]; } } diff --git a/database/factories/TagFactory.php b/database/factories/TagFactory.php index 9743dc98..7f436335 100644 --- a/database/factories/TagFactory.php +++ b/database/factories/TagFactory.php @@ -18,6 +18,7 @@ public function definition() { return [ 'name' => fake()->name(), + 'bar_id' => \Kami\Cocktail\Models\Bar::factory(), ]; } } diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 85fd24f9..b4aecd3e 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -6,7 +6,7 @@ use Illuminate\Support\Str; /** - * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User> + * @extends \Illuminate\Database\Eloquent\Factories\Factory<\Kami\Cocktail\Models\User> */ class UserFactory extends Factory { @@ -23,8 +23,6 @@ public function definition() 'email_verified_at' => now(), 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password 'remember_token' => Str::random(10), - 'search_api_key' => Str::random(10), - 'is_admin' => true ]; } diff --git a/database/factories/UtensilFactory.php b/database/factories/UtensilFactory.php index 01b568a1..fff848f8 100644 --- a/database/factories/UtensilFactory.php +++ b/database/factories/UtensilFactory.php @@ -19,6 +19,7 @@ public function definition() return [ 'name' => fake()->name(), 'description' => fake()->paragraph(), + 'bar_id' => \Kami\Cocktail\Models\Bar::factory(), ]; } } diff --git a/database/migrations/2014_10_12_000000_create_users_table.php b/database/migrations/2014_10_12_000000_create_users_table.php deleted file mode 100644 index 2878c3e6..00000000 --- a/database/migrations/2014_10_12_000000_create_users_table.php +++ /dev/null @@ -1,38 +0,0 @@ -id(); - $table->string('name'); - $table->string('email')->unique(); - $table->timestamp('email_verified_at')->nullable(); - $table->string('password'); - $table->boolean('is_admin')->default(false); - $table->string('search_api_key')->nullable(); - $table->rememberToken(); - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('users'); - } -}; diff --git a/database/migrations/2014_10_12_100000_create_password_resets_table.php b/database/migrations/2014_10_12_100000_create_password_resets_table.php deleted file mode 100644 index fcacb80b..00000000 --- a/database/migrations/2014_10_12_100000_create_password_resets_table.php +++ /dev/null @@ -1,32 +0,0 @@ -string('email')->index(); - $table->string('token'); - $table->timestamp('created_at')->nullable(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('password_resets'); - } -}; diff --git a/database/migrations/2019_08_19_000000_create_failed_jobs_table.php b/database/migrations/2019_08_19_000000_create_failed_jobs_table.php deleted file mode 100644 index 17191986..00000000 --- a/database/migrations/2019_08_19_000000_create_failed_jobs_table.php +++ /dev/null @@ -1,36 +0,0 @@ -id(); - $table->string('uuid')->unique(); - $table->text('connection'); - $table->text('queue'); - $table->longText('payload'); - $table->longText('exception'); - $table->timestamp('failed_at')->useCurrent(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('failed_jobs'); - } -}; diff --git a/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php b/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php deleted file mode 100644 index 6c81fd22..00000000 --- a/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php +++ /dev/null @@ -1,37 +0,0 @@ -id(); - $table->morphs('tokenable'); - $table->string('name'); - $table->string('token', 64)->unique(); - $table->text('abilities')->nullable(); - $table->timestamp('last_used_at')->nullable(); - $table->timestamp('expires_at')->nullable(); - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('personal_access_tokens'); - } -}; diff --git a/database/migrations/2022_09_26_171904_create_glasses_table.php b/database/migrations/2022_09_26_171904_create_glasses_table.php deleted file mode 100644 index 48b41a05..00000000 --- a/database/migrations/2022_09_26_171904_create_glasses_table.php +++ /dev/null @@ -1,33 +0,0 @@ -id(); - $table->string('name'); - $table->text('description')->nullable(); - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('glasses'); - } -}; diff --git a/database/migrations/2022_09_27_000001_create_ingredient_categories_table.php b/database/migrations/2022_09_27_000001_create_ingredient_categories_table.php deleted file mode 100644 index 903c3681..00000000 --- a/database/migrations/2022_09_27_000001_create_ingredient_categories_table.php +++ /dev/null @@ -1,33 +0,0 @@ -id(); - $table->string('name'); - $table->text('description')->nullable(); - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('ingredient_categories'); - } -}; diff --git a/database/migrations/2022_09_27_000002_create_ingredients_table.php b/database/migrations/2022_09_27_000002_create_ingredients_table.php deleted file mode 100644 index 01f5d837..00000000 --- a/database/migrations/2022_09_27_000002_create_ingredients_table.php +++ /dev/null @@ -1,42 +0,0 @@ -id(); - $table->string('slug')->unique(); - $table->string('name'); - $table->decimal('strength')->default(0.0); - $table->string('description')->nullable(); - $table->text('origin')->nullable(); - $table->text('history')->nullable(); - $table->string('color')->nullable(); - $table->foreignId('ingredient_category_id')->constrained(); - $table->foreignId('parent_ingredient_id')->nullable()->constrained('ingredients')->onDelete('cascade'); - $table->text('aliases')->nullable(); - $table->foreignId('user_id')->constrained('users'); - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('ingredients'); - } -}; diff --git a/database/migrations/2022_09_28_000003_create_cocktails_table.php b/database/migrations/2022_09_28_000003_create_cocktails_table.php deleted file mode 100644 index 91e6568e..00000000 --- a/database/migrations/2022_09_28_000003_create_cocktails_table.php +++ /dev/null @@ -1,60 +0,0 @@ -id(); - $table->string('slug')->unique(); - $table->string('name'); - $table->text('instructions'); - $table->text('description')->nullable(); - $table->string('source')->nullable(); - $table->text('garnish')->nullable(); - $table->foreignId('user_id')->constrained(); - $table->foreignId('glass_id')->nullable()->constrained(); - // $table->string('ice')->nullable(); - // $table->string('method')->nullable(); - $table->timestamps(); - }); - - Schema::create('cocktail_ingredients', function (Blueprint $table) { - $table->id(); - $table->foreignId('ingredient_id')->constrained()->onDelete('cascade'); - $table->foreignId('cocktail_id')->constrained()->onDelete('cascade'); - $table->decimal('amount'); - $table->string('units'); - $table->integer('sort')->default(0); - $table->boolean('optional')->default(false); - }); - - Schema::create('cocktail_favorites', function (Blueprint $table) { - $table->id(); - $table->foreignId('user_id')->constrained()->onDelete('cascade'); - $table->foreignId('cocktail_id')->unique()->constrained()->onDelete('cascade'); - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('cocktail_favorites'); - Schema::dropIfExists('cocktail_ingredients'); - Schema::dropIfExists('cocktails'); - } -}; diff --git a/database/migrations/2022_10_02_000004_create_tags_table.php b/database/migrations/2022_10_02_000004_create_tags_table.php deleted file mode 100644 index 0d9f6d48..00000000 --- a/database/migrations/2022_10_02_000004_create_tags_table.php +++ /dev/null @@ -1,38 +0,0 @@ -id(); - $table->string('name'); - }); - - Schema::create('cocktail_tag', function (Blueprint $table) { - $table->id(); - $table->foreignId('tag_id')->constrained()->onDelete('cascade'); - $table->foreignId('cocktail_id')->constrained()->onDelete('cascade'); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('cocktail_tag'); - Schema::dropIfExists('tags'); - } -}; diff --git a/database/migrations/2022_10_06_174533_create_user_ingredients_table.php b/database/migrations/2022_10_06_174533_create_user_ingredients_table.php deleted file mode 100644 index 897a1967..00000000 --- a/database/migrations/2022_10_06_174533_create_user_ingredients_table.php +++ /dev/null @@ -1,34 +0,0 @@ -id(); - $table->foreignId('user_id')->constrained()->onDelete('cascade'); - $table->foreignId('ingredient_id')->constrained()->onDelete('cascade'); - - $table->unique(['user_id', 'ingredient_id']); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('user_ingredients'); - } -}; diff --git a/database/migrations/2022_10_06_174709_create_images_table.php b/database/migrations/2022_10_06_174709_create_images_table.php deleted file mode 100644 index f4258aee..00000000 --- a/database/migrations/2022_10_06_174709_create_images_table.php +++ /dev/null @@ -1,35 +0,0 @@ -id(); - $table->nullableMorphs('imageable'); - $table->string('file_path'); - $table->string('file_extension'); - $table->string('copyright')->nullable(); - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('images'); - } -}; diff --git a/database/migrations/2022_10_22_132019_create_user_shopping_lists_table.php b/database/migrations/2022_10_22_132019_create_user_shopping_lists_table.php deleted file mode 100644 index 75739b39..00000000 --- a/database/migrations/2022_10_22_132019_create_user_shopping_lists_table.php +++ /dev/null @@ -1,35 +0,0 @@ -id(); - $table->foreignId('user_id')->constrained()->onDelete('cascade'); - $table->foreignId('ingredient_id')->constrained()->onDelete('cascade'); - $table->timestamps(); - - $table->unique(['user_id', 'ingredient_id']); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('user_shopping_lists'); - } -}; diff --git a/database/migrations/2022_11_09_201634_create_cocktail_ingredient_substitutes.php b/database/migrations/2022_11_09_201634_create_cocktail_ingredient_substitutes.php deleted file mode 100644 index 42b99b2c..00000000 --- a/database/migrations/2022_11_09_201634_create_cocktail_ingredient_substitutes.php +++ /dev/null @@ -1,33 +0,0 @@ -id(); - $table->foreignId('cocktail_ingredient_id')->constrained()->onDelete('cascade'); - $table->foreignId('ingredient_id')->constrained()->onDelete('cascade'); - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('cocktail_ingredient_substitutes'); - } -}; diff --git a/database/migrations/2022_12_19_084030_create_ratings_table.php b/database/migrations/2022_12_19_084030_create_ratings_table.php deleted file mode 100644 index 312d7a0d..00000000 --- a/database/migrations/2022_12_19_084030_create_ratings_table.php +++ /dev/null @@ -1,35 +0,0 @@ -id(); - $table->morphs('rateable'); - $table->foreignId('user_id')->constrained()->onDelete('cascade'); - $table->smallInteger('rating'); - $table->timestamps(); - $table->unique(['user_id', 'rateable_id', 'rateable_type']); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('ratings'); - } -}; diff --git a/database/migrations/2022_12_26_203059_add_user_to_images_table.php b/database/migrations/2022_12_26_203059_add_user_to_images_table.php deleted file mode 100644 index 6eb4c2e0..00000000 --- a/database/migrations/2022_12_26_203059_add_user_to_images_table.php +++ /dev/null @@ -1,63 +0,0 @@ -foreignId('user_id')->nullable()->constrained('users'); - }); - - // Update ingredient image user - Ingredient::all()->each(function ($ingredient) { - $userId = $ingredient->user_id; - - $ingredient->images()->update(['user_id' => $userId]); - }); - - // Update cocktail image user - Cocktail::all()->each(function ($cocktail) { - $userId = $cocktail->user_id; - - $cocktail->images()->update(['user_id' => $userId]); - }); - - // Handle unassigned images - $result = DB::table('users')->orderBy('id', 'asc')->first('id'); - - if ($result) { - DB::table('images')->whereNull('user_id')->update(['user_id' => $result->id]); - } - - // Make user not nullable - Schema::table('images', function (Blueprint $table) { - $table->bigInteger('user_id')->nullable(false)->change(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::table('images', function (Blueprint $table) { - $table->dropColumn('user_id'); - }); - } -}; diff --git a/database/migrations/2023_01_11_185049_add_cocktail_method.php b/database/migrations/2023_01_11_185049_add_cocktail_method.php deleted file mode 100644 index cee78256..00000000 --- a/database/migrations/2023_01_11_185049_add_cocktail_method.php +++ /dev/null @@ -1,51 +0,0 @@ -id(); - $table->string('name'); - $table->text('description')->nullable(); - $table->tinyInteger('dilution_percentage'); - }); - - DB::table('cocktail_methods')->insert([ - ['name' => 'Shake', 'dilution_percentage' => 25], - ['name' => 'Stir', 'dilution_percentage' => 20], - ['name' => 'Build', 'dilution_percentage' => 10], - ['name' => 'Blend', 'dilution_percentage' => 25], - ['name' => 'Muddle', 'dilution_percentage' => 10], - ['name' => 'Layer', 'dilution_percentage' => 0], - ]); - - Schema::table('cocktails', function (Blueprint $table) { - $table->foreignId('cocktail_method_id')->nullable()->constrained('cocktail_methods'); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::table('cocktails', function (Blueprint $table) { - $table->dropColumn('cocktail_method_id'); - }); - - Schema::dropIfExists('cocktail_methods'); - } -}; diff --git a/database/migrations/2023_01_15_204739_add_public_id_to_cocktails.php b/database/migrations/2023_01_15_204739_add_public_id_to_cocktails.php deleted file mode 100644 index 1d7e8c80..00000000 --- a/database/migrations/2023_01_15_204739_add_public_id_to_cocktails.php +++ /dev/null @@ -1,36 +0,0 @@ -ulid('public_id')->nullable(); - $table->dateTime('public_at')->nullable(); - $table->dateTime('public_expires_at')->nullable(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::table('cocktails', function (Blueprint $table) { - $table->removeColumn('public_expires_at'); - $table->removeColumn('public_at'); - $table->removeColumn('public_id'); - }); - } -}; diff --git a/database/migrations/2023_01_27_115401_add_method_to_stock_cocktails.php b/database/migrations/2023_01_27_115401_add_method_to_stock_cocktails.php deleted file mode 100644 index f1efa030..00000000 --- a/database/migrations/2023_01_27_115401_add_method_to_stock_cocktails.php +++ /dev/null @@ -1,51 +0,0 @@ -whereNull('cocktail_method_id')->count(); - if ($cocktailsWithoutMethod === 0) { - return; - } - - $sources = [ - Yaml::parseFile(resource_path('/data/iba_cocktails_v0.1.0.yml')), - Yaml::parseFile(resource_path('/data/popular_cocktails.yml')), - ]; - - $dbMethods = DB::table('cocktail_methods')->select(['name', 'id'])->get(); - - foreach ($sources as $source) { - foreach ($source as $sCocktail) { - $methodId = $dbMethods->filter(fn ($item) => $item->name == $sCocktail['method'])->first()->id ?? null; - - DB::table('cocktails')->where('name', $sCocktail['name'])->whereNull('cocktail_method_id')->update([ - 'cocktail_method_id' => $methodId, - ]); - } - } - - Artisan::call('bar:refresh-search'); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - } -}; diff --git a/database/migrations/2023_02_27_181649_add_sort_column_to_images_table.php b/database/migrations/2023_02_27_181649_add_sort_column_to_images_table.php deleted file mode 100644 index bacfdf4a..00000000 --- a/database/migrations/2023_02_27_181649_add_sort_column_to_images_table.php +++ /dev/null @@ -1,28 +0,0 @@ -integer('sort')->default(1); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('images', function (Blueprint $table) { - $table->removeColumn('sort'); - }); - } -}; diff --git a/database/migrations/2023_03_27_145327_add_placeholder_hash_to_image_table.php b/database/migrations/2023_03_27_145327_add_placeholder_hash_to_image_table.php deleted file mode 100644 index a3c89d06..00000000 --- a/database/migrations/2023_03_27_145327_add_placeholder_hash_to_image_table.php +++ /dev/null @@ -1,48 +0,0 @@ -string('placeholder_hash')->nullable(); - }); - - $imageService = app(ImageService::class); - - foreach (Image::whereNotNull('imageable_type')->cursor() as $image) { - try { - $hash = $imageService->generateThumbHash( - $image->asInterventionImage(), - true - ); - - $image->placeholder_hash = $hash; - $image->save(); - } catch (\Throwable $e) { - \Illuminate\Support\Facades\Log::error( - 'Placeholder hash generation migration error: ' . $e->getMessage() - ); - } - } - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('images', function (Blueprint $table) { - $table->dropColumn('placeholder_hash'); - }); - } -}; diff --git a/database/migrations/2023_04_28_200012_create_notes_table.php b/database/migrations/2023_04_28_200012_create_notes_table.php deleted file mode 100644 index 8e4ac2f9..00000000 --- a/database/migrations/2023_04_28_200012_create_notes_table.php +++ /dev/null @@ -1,30 +0,0 @@ -id(); - $table->morphs('noteable'); - $table->foreignId('user_id')->constrained()->onDelete('cascade'); - $table->string('note'); - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('notes'); - } -}; diff --git a/database/migrations/2023_04_30_122623_create_collection_table.php b/database/migrations/2023_04_30_122623_create_collection_table.php deleted file mode 100644 index 64cfd343..00000000 --- a/database/migrations/2023_04_30_122623_create_collection_table.php +++ /dev/null @@ -1,39 +0,0 @@ -id(); - $table->string('name'); - $table->string('description')->nullable(); - $table->foreignId('user_id')->constrained()->onDelete('cascade'); - $table->timestamps(); - }); - - Schema::create('collections_cocktails', function (Blueprint $table) { - $table->id(); - $table->foreignId('cocktail_id')->constrained()->onDelete('cascade'); - $table->foreignId('collection_id')->constrained()->onDelete('cascade'); - - $table->unique(['cocktail_id', 'collection_id']); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('collections_cocktails'); - Schema::dropIfExists('collections'); - } -}; diff --git a/database/migrations/2023_07_08_184606_add_abv_to_cocktails_table.php b/database/migrations/2023_07_08_184606_add_abv_to_cocktails_table.php deleted file mode 100644 index c13ed81e..00000000 --- a/database/migrations/2023_07_08_184606_add_abv_to_cocktails_table.php +++ /dev/null @@ -1,40 +0,0 @@ -decimal('abv')->nullable(); - $table->index('abv', 'cocktails_abv_index'); - }); - - Cocktail::with('ingredients.ingredient')->chunk(50, function (Collection $cocktails) { - foreach ($cocktails as $cocktail) { - $calculatedAbv = $cocktail->getABV(); - $cocktail->abv = $calculatedAbv; - $cocktail->save(); - } - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('cocktails', function (Blueprint $table) { - $table->dropIndex('cocktails_abv_index'); - $table->removeColumn('abv'); - }); - } -}; diff --git a/database/migrations/2023_07_12_193331_fix_main_ingredient_sort.php b/database/migrations/2023_07_12_193331_fix_main_ingredient_sort.php deleted file mode 100644 index 0d4a6fff..00000000 --- a/database/migrations/2023_07_12_193331_fix_main_ingredient_sort.php +++ /dev/null @@ -1,32 +0,0 @@ -orderBy('id')->lazy()->each(function ($cocktail) { - $ingredients = DB::table('cocktail_ingredients')->where('cocktail_id', $cocktail->id)->get(); - $i = 1; - foreach ($ingredients as $ci) { - DB::table('cocktail_ingredients')->where('id', $ci->id)->orderBy('id')->update(['sort' => $i]); - $i++; - } - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - // - } -}; diff --git a/database/migrations/2023_08_09_070739_create_utensils_table.php b/database/migrations/2023_08_09_070739_create_utensils_table.php deleted file mode 100644 index 80046b3d..00000000 --- a/database/migrations/2023_08_09_070739_create_utensils_table.php +++ /dev/null @@ -1,64 +0,0 @@ -id(); - $table->string('name'); - $table->text('description')->nullable(); - $table->timestamps(); - }); - - Schema::create('cocktail_utensil', function (Blueprint $table) { - $table->id(); - $table->foreignId('cocktail_id')->constrained()->onDelete('cascade'); - $table->foreignId('utensil_id')->constrained()->onDelete('cascade'); - }); - - DB::table('utensils')->insert([ - ['name' => 'Mixing glass', 'description' => 'A glass with a heavy base that doesn\'t tip over when stirring.'], - ['name' => 'Shaker', 'description' => 'A recipient in 2 parts to shake cocktails vigorously.'], - ['name' => 'Bar spoon', 'description' => 'A long and heavy spiraled spoon used to stir or layer cocktails.'], - ['name' => 'Julep Strainer', 'description' => 'A style of strainer used when using a mixing glass to strain the ice out.'], - ['name' => 'Hawthorne Strainer', 'description' => 'A style of strainer used when using a shaker to strain the ice out.'], - ['name' => 'Mesh Strainer', 'description' => 'A simple mesh strainer used to double strain cocktails in order to avoid any ice in the final drink, or to avoid pulp when juicing fruits.'], - ['name' => 'Atomizer', 'description' => 'Refillable glass spray bottle to spray and mist very small amounts of aromatics. Used for absinthe rinses, and bitter sprays.'], - ['name' => 'Muddler', 'description' => 'Essential tool to crush fruits, berries and herbs and extract the juice out of them.'], - ['name' => 'Jigger', 'description' => 'Small cup used to quickly measure volumes in the bar.'], - ['name' => 'Zester', 'description' => 'Rasp used to zest fruits, nuts, or even chocolate for garnishes.'], - ['name' => 'Channel knife', 'description' => 'Knife designed to make long and thin citrus peels.'], - ['name' => 'Y Peeler', 'description' => 'Kitchen tool designed to peel fruits and vegetables. In the bar, used for large peels to extract the oils from.'], - ['name' => 'Bar knife', 'description' => 'A small sharp knife to peel and cut fruits.'], - ['name' => 'Ice carving knife', 'description' => 'A knife with a significantly tougher spine designed to handle ice carving.'], - ['name' => 'Ice chipper', 'description' => 'A three-pronged tool to chip away and break ice.'], - ['name' => 'Ice pick', 'description' => 'A pick to break and chip away at ice.'], - ['name' => 'Cocktail smoker', 'description' => 'A device used to add smokey flavor to cocktails by burning different wood escences.'], - ['name' => 'Juicer', 'description' => 'Extract juice from citrus fruits.'], - ['name' => 'Straight tongs', 'description' => 'Small precision tongs to place garnishes.'], - ['name' => 'Ice tongs', 'description' => 'Tongs made to grab ice cubes.'], - ]); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('cocktail_utensil'); - Schema::dropIfExists('utensils'); - } -}; diff --git a/database/migrations/2023_10_01_000001_v3.php b/database/migrations/2023_10_01_000001_v3.php new file mode 100644 index 00000000..9ced717a --- /dev/null +++ b/database/migrations/2023_10_01_000001_v3.php @@ -0,0 +1,283 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + + Schema::create('password_resets', function (Blueprint $table) { + $table->string('email')->index(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + + Schema::create('bar_types', function (Blueprint $table) { + $table->id(); + $table->string('name'); + }); + + DB::table('bar_types')->insert([ + ['id' => 1, 'name' => 'Normal'], + ['id' => 2, 'name' => 'Premium'], + ]); + + Schema::create('bars', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('status')->nullable(); + $table->text('subtitle')->nullable(); + $table->text('description')->nullable(); + $table->foreignId('bar_type_id')->default(1)->constrained()->onDelete('restrict'); + $table->foreignId('created_user_id')->constrained('users')->onDelete('restrict'); + $table->foreignId('updated_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->text('invite_code')->unique()->nullable(); + $table->boolean('is_active')->default(false); + $table->timestamps(); + }); + + Schema::create('user_roles', function (Blueprint $table) { + $table->id(); + $table->string('name'); + }); + + // User can have only one role + // Roles have permission levels in asc/desc order + // Hopefully this design wont annoy me in the future :^) + DB::table('user_roles')->insert([ + ['id' => 1, 'name' => 'Admin'], + ['id' => 2, 'name' => 'Moderator'], + ['id' => 3, 'name' => 'General'], + ['id' => 4, 'name' => 'Guest'], + ]); + + Schema::create('bar_memberships', function (Blueprint $table) { + $table->id(); + $table->foreignId('bar_id')->constrained()->onDelete('cascade'); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('user_role_id')->constrained()->onDelete('restrict'); + $table->boolean('is_active')->default(true); + $table->boolean('is_shelf_public')->default(false); + $table->timestamps(); + + $table->unique(['bar_id', 'user_id', 'user_role_id']); + }); + + Schema::create('glasses', function (Blueprint $table) { + $table->id(); + $table->foreignId('bar_id')->constrained()->onDelete('cascade'); + $table->string('name'); + $table->text('description')->nullable(); + $table->timestamps(); + }); + + Schema::create('cocktail_methods', function (Blueprint $table) { + $table->id(); + $table->foreignId('bar_id')->constrained()->onDelete('cascade'); + $table->string('name'); + $table->text('description')->nullable(); + $table->tinyInteger('dilution_percentage'); + $table->timestamps(); + }); + + Schema::create('ingredient_categories', function (Blueprint $table) { + $table->id(); + $table->foreignId('bar_id')->constrained()->onDelete('cascade'); + $table->string('name'); + $table->text('description')->nullable(); + $table->timestamps(); + }); + + Schema::create('ingredients', function (Blueprint $table) { + $table->id(); + $table->foreignId('bar_id')->constrained()->onDelete('cascade'); + $table->string('slug')->unique(); + $table->string('name'); + $table->decimal('strength')->default(0.0); + $table->text('description')->nullable(); + $table->text('origin')->nullable(); + $table->string('color')->nullable(); + $table->foreignId('ingredient_category_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('parent_ingredient_id')->nullable()->constrained('ingredients')->nullOnDelete(); + $table->foreignId('created_user_id')->constrained('users')->onDelete('restrict'); + $table->foreignId('updated_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + + Schema::create('cocktails', function (Blueprint $table) { + $table->id(); + $table->foreignId('bar_id')->constrained()->onDelete('cascade'); + $table->string('slug')->unique(); + $table->string('name'); + $table->text('instructions'); + $table->text('description')->nullable(); + $table->text('source')->nullable(); + $table->text('garnish')->nullable(); + $table->foreignId('created_user_id')->constrained('users')->onDelete('restrict'); + $table->foreignId('updated_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('glass_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('cocktail_method_id')->nullable()->constrained('cocktail_methods')->nullOnDelete(); + $table->ulid('public_id')->nullable(); + $table->dateTime('public_at')->nullable(); + $table->dateTime('public_expires_at')->nullable(); + $table->decimal('abv')->nullable(); + $table->index('abv', 'cocktails_abv_index'); + $table->timestamps(); + }); + + Schema::create('cocktail_ingredients', function (Blueprint $table) { + $table->id(); + $table->foreignId('ingredient_id')->constrained()->onDelete('cascade'); + $table->foreignId('cocktail_id')->constrained()->onDelete('cascade'); + $table->decimal('amount'); + $table->decimal('amount_max')->nullable(); + $table->string('units'); + $table->integer('sort')->default(0); + $table->boolean('optional')->default(false); + $table->text('note')->nullable(); + }); + + Schema::create('cocktail_favorites', function (Blueprint $table) { + $table->id(); + $table->foreignId('bar_membership_id')->constrained()->onDelete('cascade'); + $table->foreignId('cocktail_id')->constrained()->onDelete('cascade'); + $table->timestamps(); + $table->unique(['bar_membership_id', 'cocktail_id']); + }); + + Schema::create('tags', function (Blueprint $table) { + $table->id(); + $table->foreignId('bar_id')->constrained()->onDelete('cascade'); + $table->string('name'); + }); + + Schema::create('cocktail_tag', function (Blueprint $table) { + $table->id(); + $table->foreignId('tag_id')->constrained()->onDelete('cascade'); + $table->foreignId('cocktail_id')->constrained()->onDelete('cascade'); + }); + + Schema::create('user_ingredients', function (Blueprint $table) { + $table->id(); + $table->foreignId('bar_membership_id')->constrained()->onDelete('cascade'); + $table->foreignId('ingredient_id')->constrained()->onDelete('cascade'); + $table->unique(['bar_membership_id', 'ingredient_id']); + }); + + Schema::create('images', function (Blueprint $table) { + $table->id(); + $table->nullableMorphs('imageable'); + $table->string('file_path'); + $table->string('file_extension'); + $table->string('copyright')->nullable(); + $table->foreignId('created_user_id')->constrained('users')->onDelete('restrict'); + $table->foreignId('updated_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->integer('sort')->default(1); + $table->text('placeholder_hash')->nullable(); + $table->timestamps(); + }); + + Schema::create('user_shopping_lists', function (Blueprint $table) { + $table->id(); + $table->foreignId('bar_membership_id')->constrained()->onDelete('cascade'); + $table->foreignId('ingredient_id')->constrained()->onDelete('cascade'); + $table->timestamps(); + + $table->unique(['bar_membership_id', 'ingredient_id']); + }); + + Schema::create('cocktail_ingredient_substitutes', function (Blueprint $table) { + $table->id(); + $table->foreignId('cocktail_ingredient_id')->constrained()->onDelete('cascade'); + $table->foreignId('ingredient_id')->constrained()->onDelete('cascade'); + $table->decimal('amount')->nullable(); + $table->decimal('amount_max')->nullable(); + $table->string('units')->nullable(); + $table->timestamps(); + }); + + Schema::create('ratings', function (Blueprint $table) { + $table->id(); + $table->morphs('rateable'); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->smallInteger('rating'); + $table->timestamps(); + $table->unique(['user_id', 'rateable_id', 'rateable_type']); + }); + + Schema::create('notes', function (Blueprint $table) { + $table->id(); + $table->morphs('noteable'); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->text('note'); + $table->timestamps(); + }); + + Schema::create('collections', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->text('description')->nullable(); + $table->foreignId('bar_membership_id')->constrained()->onDelete('cascade'); + $table->timestamps(); + }); + + Schema::create('collections_cocktails', function (Blueprint $table) { + $table->id(); + $table->foreignId('cocktail_id')->constrained()->onDelete('cascade'); + $table->foreignId('collection_id')->constrained()->onDelete('cascade'); + + $table->unique(['cocktail_id', 'collection_id']); + }); + + Schema::create('utensils', function (Blueprint $table) { + $table->id(); + $table->foreignId('bar_id')->constrained()->onDelete('cascade'); + $table->string('name'); + $table->text('description')->nullable(); + $table->timestamps(); + }); + + Schema::create('cocktail_utensil', function (Blueprint $table) { + $table->id(); + $table->foreignId('cocktail_id')->constrained()->onDelete('cascade'); + $table->foreignId('utensil_id')->constrained()->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + } +}; diff --git a/database/migrations/2023_10_01_000002_migrate_v2.php b/database/migrations/2023_10_01_000002_migrate_v2.php new file mode 100644 index 00000000..a6760fdb --- /dev/null +++ b/database/migrations/2023_10_01_000002_migrate_v2.php @@ -0,0 +1,44 @@ +open($filename, ZipArchive::CREATE) !== true) { + throw new Exception('Unable to backup old data, stopping...'); + } + + $zip->addGlob(storage_path('bar-assistant/database.sqlite'), options: ['remove_path' => storage_path('bar-assistant')]); + $zip->addGlob(storage_path('bar-assistant/uploads/*/*'), options: ['remove_path' => storage_path('bar-assistant')]); + + $zip->close(); + + // Migrate + $importer = app(FromVersion2::class); + $importer->process(); + } + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + } +}; diff --git a/docker-compose.yml b/docker-compose.yml index 8d895a49..dc968c7b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,11 +2,11 @@ version: "3.7" services: app: build: + target: localdev args: - user: developer - uid: 1000 + PUID: 1000 context: . - dockerfile: Dockerfile.dev + dockerfile: Dockerfile container_name: app restart: unless-stopped extra_hosts: @@ -15,6 +15,30 @@ services: - ./:/var/www/cocktails - ./resources/php.dev.ini:/usr/local/etc/php/conf.d/local.ini + queue: + build: + target: localdev + args: + PUID: 1000 + context: . + dockerfile: Dockerfile + restart: unless-stopped + # In dev env use listen instead of work + command: php artisan queue:listen + volumes: + - ./:/var/www/cocktails + + # minio: + # image: 'bitnami/minio:latest' + # ports: + # - '7000:9000' + # - '7001:9001' + # environment: + # - MINIO_ROOT_USER=minio-root-user + # - MINIO_ROOT_PASSWORD=minio-root-password + # volumes: + # - miniiodata:/data + webserver: image: nginx:alpine container_name: webserver @@ -32,7 +56,7 @@ services: restart: unless-stopped meilisearch: - image: getmeili/meilisearch:latest + image: getmeili/meilisearch:v1.3.2 environment: - MEILI_MASTER_KEY=masterKeyThatIsReallyReallyLong4Real restart: unless-stopped @@ -43,3 +67,4 @@ services: volumes: meilidata: + miniiodata: diff --git a/docs/open-api-spec.yml b/docs/open-api-spec.yml index 5872b3ef..286963c9 100644 --- a/docs/open-api-spec.yml +++ b/docs/open-api-spec.yml @@ -5,6 +5,35 @@ info: description: |- Bar Assistant is a self hosted application for managing your home bar. + ## Content + + You should set `Content-Type: application/json` header for each request. + + ## Authentication + + Add your login token in header for every request, for example: `Authorization: Bearer 1|dvWHLWuZbmWWFbjaUDla393Q9jK5Ou9ujWYPcvII`. + For requests that need reference to bar, add your bar id via query string, for example: `/api/example?bar_id=1` + + ## Authorization + + You will get response with error message and status code `403` if you try to access resource that you don't have permissions for. + + ## Sorting + + Some endpoints allow sorting by specific attributes. Prepending `-` defines descending order, and omitting it defines ascending order. Seperate multiple sorts by a comma. For example: `?sort=name` will sort by name attribute in ascending order. + + ## Includes + + Some endpoints allow including extra relationship data on demand. Seperate multiple relations witha a comma. For example: `?include=notes,user` will include extra extra data for notes and user. + + ## Pagination + + Some endpoints allow paginating results. Use `?per_page=30` to limit total results per request. Use `?page=3` to go to a specific page. + + ## Filtering + + Some endpoints allow filtering by a specific attribute. For example: `?filter[attribute_name]=value`. + [Documentation](https://bar-assistant.github.io/docs/) | [Source](https://github.com/karlomikus/bar-assistant) servers: - url: /api @@ -13,6 +42,8 @@ tags: description: Operations related to server - name: Auth description: Operations related to user authentication + - name: Profile + description: Operations related to current user - name: Ingredients description: Operations related to ingredients - name: Cocktails @@ -25,8 +56,6 @@ tags: description: Operations related to ingredient categories - name: "User shelf" description: Operations related to user shelf - - name: User - description: Operations related to current user - name: Users description: Operations related to users - name: "Shopping list" @@ -47,6 +76,8 @@ tags: description: Data import operations - name: "Utensils" description: Operations related to utensils + - name: Bars + description: Operations related to bars security: - user_token: [] paths: @@ -54,7 +85,7 @@ paths: get: tags: - Server - summary: Get server version information + summary: Get server information operationId: getServerVersion security: [] responses: @@ -63,7 +94,10 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Version' + type: object + properties: + data: + $ref: '#/components/schemas/Version' /server/openapi: get: tags: @@ -78,7 +112,7 @@ paths: post: tags: - Auth - summary: Authenticate and get a token + summary: Authenticate user and get a token operationId: login security: [] requestBody: @@ -86,17 +120,7 @@ paths: content: application/json: schema: - type: object - required: - - email - - password - properties: - email: - type: string - example: admin@example.com - password: - type: string - example: password + $ref: '#/components/schemas/LoginRequest' responses: '200': description: Successful response @@ -105,9 +129,8 @@ paths: schema: type: object properties: - token: - type: string - example: 1|dvWHLWuZbmWWFbjaUDla393Q9jK5Ou9ujWYPcvII + data: + $ref: '#/components/schemas/AuthToken' '422': $ref: '#/components/responses/UnprocessableEntity' /logout: @@ -116,17 +139,44 @@ paths: - Auth summary: Logout currently authenticated user operationId: logout + responses: + '204': + description: Successful response + /register: + post: + tags: + - Auth security: [] + summary: "Register a new user" + operationId: registerUser + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterRequest' responses: - '200': + '201': description: Successful response + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/Profile' + '422': + $ref: '#/components/responses/UnprocessableEntity' /cocktails: + parameters: + - $ref: '#/components/parameters/BarParameter' get: parameters: + - $ref: '#/components/parameters/PageParameter' + - $ref: '#/components/parameters/PerPageParameter' - in: query name: filter required: false - description: Filter by specific parameters + description: Filter by specific attributes explode: true style: deepObject schema: @@ -134,7 +184,7 @@ paths: properties: id: type: string - example: 3,4,5 + example: "3" name: type: string example: "old" @@ -142,20 +192,20 @@ paths: type: string example: "campari" ingredient_id: - type: integer - example: 3 + type: string + example: "3" tag_id: type: string - example: 4 - user_id: + example: "4" + created_user_id: type: string - example: 3 + example: "3" glass_id: type: string - example: 1 + example: "1" cocktail_method_id: type: string - example: 1 + example: "1" favorites: type: boolean example: true @@ -171,6 +221,12 @@ paths: user_rating_max: type: integer example: 5 + average_rating_min: + type: integer + example: 3 + average_rating_max: + type: integer + example: 5 abv_min: type: number example: 15 @@ -178,42 +234,31 @@ paths: type: number example: 55.5 main_ingredient_id: - type: integer - example: 1 + type: string + example: "1" collection_id: - type: integer - example: 1 + type: string + example: "1" shelf_ingredients: type: string example: 5,6,7 + user_shelves: + type: string + example: "7" - in: query name: sort required: false schema: type: string - example: name,-created_at - description: "Sort by specific parameters, change order by prepending `-`. Available sorts: `name`, `created_at`, `average_rating`, `user_rating`, `abv`, `favorited_at`, `missing_ingredients`. Seperate multiple sorts with a comma `,`." - - in: query - name: per_page - required: false - schema: - type: integer - example: 25 - description: Total results per page - - in: query - name: page - required: false - schema: - type: integer - example: 1 - description: Page number + example: attribute_name + description: "Available attributes: `name`, `created_at`, `average_rating`, `user_rating`, `abv`, `favorited_at`, `missing_ingredients`." - in: query name: include required: false schema: type: string - example: notes,user - description: "Comma seperated names of extra data you want to include in response: `notes`, `glass`, `user`, `collections`, `method`, `ingredients`" + example: attribute_name + description: "Available attributes: `glass`, `createdUser`, `updatedUser`, `method`, `ingredients`, `utensils`." tags: - "Cocktails" summary: 'Show a paginated list of cocktails' @@ -238,6 +283,8 @@ paths: $ref: '#/components/schemas/PaginationLinks' meta: $ref: '#/components/schemas/PaginationMeta' + '403': + $ref: '#/components/responses/NotAuthorized' post: tags: - "Cocktails" @@ -264,40 +311,8 @@ paths: properties: data: $ref: '#/components/schemas/Cocktail' - '422': - $ref: '#/components/responses/UnprocessableEntity' - /register: - post: - tags: - - Auth - security: [] - summary: "Register a new user" - operationId: registerUser - requestBody: - content: - application/json: - schema: - type: object - properties: - email: - type: string - example: "newuser@domain.com" - name: - type: string - example: "New User" - password: - type: string - example: "P4SSW0RD" - responses: - '200': - description: Successful response - content: - application/json: - schema: - type: object - properties: - data: - $ref: '#/components/schemas/Profile' + '403': + $ref: '#/components/responses/NotAuthorized' '422': $ref: '#/components/responses/UnprocessableEntity' /cocktails/{id}: @@ -323,6 +338,8 @@ paths: properties: data: $ref: '#/components/schemas/Cocktail' + '403': + $ref: '#/components/responses/NotAuthorized' '404': $ref: '#/components/responses/NotFound' put: @@ -395,6 +412,8 @@ paths: type: boolean description: 'Is cocktail favorited' example: true + '403': + $ref: '#/components/responses/NotAuthorized' '404': $ref: '#/components/responses/NotFound' /cocktails/{id}/public-link: @@ -419,21 +438,9 @@ paths: type: object properties: data: - type: object - properties: - public_id: - type: string - description: 'Public cocktail ULID' - example: "01ARZ3NDEKTSV4RRFFQ69G5FAV" - public_at: - type: string - description: 'ULID created at datetime' - example: "2023-03-14T20:20:20.000000Z" - public_expires_at: - type: string - description: 'ULID expiration datetime' - example: "2023-05-14T21:23:40.000000Z" - nullable: true + $ref: '#/components/schemas/CocktailPublic' + '403': + $ref: '#/components/responses/NotAuthorized' '404': $ref: '#/components/responses/NotFound' delete: @@ -496,6 +503,8 @@ paths: message: type: string example: 'Requested type "notype" not supported.' + '403': + $ref: '#/components/responses/NotAuthorized' '404': $ref: '#/components/responses/NotFound' /cocktails/{id}/similar: @@ -528,9 +537,13 @@ paths: type: array items: $ref: '#/components/schemas/Cocktail' + '403': + $ref: '#/components/responses/NotAuthorized' '404': $ref: '#/components/responses/NotFound' /shelf/cocktails: + parameters: + - $ref: '#/components/parameters/BarParameter' get: parameters: - in: query @@ -557,9 +570,109 @@ paths: items: type: integer example: 1 + /shelf/cocktail-favorites: + parameters: + - $ref: '#/components/parameters/BarParameter' + get: + tags: + - "User shelf" + summary: 'Get a list of cocktail ids that you favorited' + operationId: getFavoritedCocktailIds + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: integer + example: 1 + /shelf/ingredients: + parameters: + - $ref: '#/components/parameters/BarParameter' + get: + tags: + - "User shelf" + summary: "Get all ingredients in user shelf" + operationId: getShelfIngredients + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/UserIngredient' + /shelf/ingredients/batch-store: + parameters: + - $ref: '#/components/parameters/BarParameter' + post: + tags: + - "User shelf" + summary: "Add multiple ingredients to user shelf" + operationId: addIngredientsToShelf + requestBody: + content: + application/json: + schema: + type: object + properties: + ingredient_ids: + type: array + example: [1] + items: + type: integer + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/UserIngredient' + '422': + $ref: '#/components/responses/UnprocessableEntity' + /shelf/ingredients/batch-delete: + parameters: + - $ref: '#/components/parameters/BarParameter' + post: + tags: + - "User shelf" + summary: "Add a single ingredient to user shelf" + operationId: removeIngredientsFromShelf + requestBody: + content: + application/json: + schema: + type: object + properties: + ingredient_ids: + type: array + example: [1] + items: + type: integer + responses: + '204': + description: Successfully deleted ingredients /ingredients: + parameters: + - $ref: '#/components/parameters/BarParameter' get: parameters: + - $ref: '#/components/parameters/PageParameter' + - $ref: '#/components/parameters/PerPageParameter' - in: query name: filter required: false @@ -580,7 +693,10 @@ paths: example: "whiskey" category_id: type: string - example: 1 + example: "1" + created_user_id: + type: string + example: "1" origin: type: string example: "France" @@ -604,29 +720,15 @@ paths: required: false schema: type: string - example: name,-created_at - description: "Sort by specific parameters, change order by prepending `-`. Available sorts: `name`, `created_at`, `strength`, `total_cocktails`. Seperate multiple sorts with a comma `,`." - - in: query - name: per_page - required: false - schema: - type: integer - example: 15 - description: Total results per page - - in: query - name: page - required: false - schema: - type: integer - example: 1 - description: Page number + example: attribute_name + description: "Available attributes: `name`, `created_at`, `strength`, `total_cocktails`." - in: query name: include required: false schema: type: string - example: notes,user - description: "Comma seperated names of extra data you want to include in response: `parentIngredient`, `varieties`, `cocktails`, `cocktailIngredientSubstitutes`" + example: attribute_name + description: "Available attributes: `parentIngredient`, `varieties`, `cocktails`, `cocktailIngredientSubstitutes`." tags: - Ingredients summary: 'Get a list of ingredients' @@ -651,6 +753,8 @@ paths: $ref: '#/components/schemas/PaginationLinks' meta: $ref: '#/components/schemas/PaginationMeta' + '403': + $ref: '#/components/responses/NotAuthorized' post: tags: - Ingredients @@ -677,6 +781,8 @@ paths: properties: data: $ref: '#/components/schemas/Ingredient' + '403': + $ref: '#/components/responses/NotAuthorized' '422': $ref: '#/components/responses/UnprocessableEntity' /ingredients/{id}: @@ -702,6 +808,8 @@ paths: properties: data: $ref: '#/components/schemas/Ingredient' + '403': + $ref: '#/components/responses/NotAuthorized' '404': $ref: '#/components/responses/NotFound' put: @@ -775,9 +883,13 @@ paths: name: type: string example: Mai Tai + '403': + $ref: '#/components/responses/NotAuthorized' '404': $ref: '#/components/responses/NotFound' /glasses: + parameters: + - $ref: '#/components/parameters/BarParameter' get: tags: - Glasses @@ -887,48 +999,6 @@ paths: '404': $ref: '#/components/responses/NotFound' /images: - get: - tags: - - Images - summary: Paginated list of images - operationId: getImages - parameters: - - in: query - name: per_page - required: false - schema: - type: integer - example: 15 - description: Total results per page - - in: query - name: page - required: false - schema: - type: integer - example: 1 - description: Page number - responses: - '200': - description: Successful response - content: - application/json: - schema: - type: object - required: - - data - - links - - meta - properties: - data: - type: array - items: - $ref: '#/components/schemas/Image' - links: - $ref: '#/components/schemas/PaginationLinks' - meta: - $ref: '#/components/schemas/PaginationMeta' - '403': - $ref: '#/components/responses/NotAuthorized' post: tags: - Images @@ -1041,6 +1111,8 @@ paths: '404': $ref: '#/components/responses/NotFound' /ingredient-categories: + parameters: + - $ref: '#/components/parameters/BarParameter' get: tags: - "Ingredient categories" @@ -1072,144 +1144,55 @@ paths: '201': description: Successful response headers: - Location: - description: Absolute URL to new resource - schema: - type: string - example: 'http://localhost/api/ingredient-categories/1' - content: - application/json: - schema: - type: object - properties: - data: - $ref: '#/components/schemas/IngredientCategory' - '422': - $ref: '#/components/responses/UnprocessableEntity' - /ingredient-categories/{id}: - parameters: - - in: path - name: id - description: 'Database id of the ingredient category' - schema: - type: integer - required: true - get: - tags: - - "Ingredient categories" - summary: Get a specific ingredient category - operationId: getIngredientCategory - responses: - '200': - description: Successful response - content: - application/json: - schema: - type: object - properties: - data: - $ref: '#/components/schemas/IngredientCategory' - '404': - $ref: '#/components/responses/NotFound' - put: - tags: - - "Ingredient categories" - summary: Update a specific ingredient category - operationId: updateIngredientCategory - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/IngredientCategoryRequest' - responses: - '200': - description: Successful response - content: - application/json: - schema: - type: object - properties: - data: - $ref: '#/components/schemas/IngredientCategory' - '403': - $ref: '#/components/responses/NotAuthorized' - '404': - $ref: '#/components/responses/NotFound' - '422': - $ref: '#/components/responses/UnprocessableEntity' - delete: - tags: - - "Ingredient categories" - summary: Delete specific ingredient category - operationId: deleteIngredientCategory - responses: - '204': - description: Successful response - '403': - $ref: '#/components/responses/NotAuthorized' - '404': - $ref: '#/components/responses/NotFound' - /shelf/ingredients: - get: - tags: - - "User shelf" - summary: "Get all ingredients in user shelf" - operationId: getShelfIngredients - responses: - '200': - description: Successful response - content: - application/json: - schema: - type: object - properties: - data: - type: array - items: - $ref: '#/components/schemas/UserIngredient' - post: - tags: - - "User shelf" - summary: "Add multiple ingredients to user shelf" - operationId: addIngredientsToShelf - requestBody: - content: - application/json: - schema: - type: object - properties: - ingredient_ids: - type: array - example: [1] - items: - type: integer - responses: - '200': - description: Successful response + Location: + description: Absolute URL to new resource + schema: + type: string + example: 'http://localhost/api/ingredient-categories/1' content: application/json: schema: type: object properties: data: - type: array - items: - $ref: '#/components/schemas/UserIngredient' + $ref: '#/components/schemas/IngredientCategory' '422': $ref: '#/components/responses/UnprocessableEntity' - /shelf/ingredients/{ingredientId}: + /ingredient-categories/{id}: parameters: - in: path - name: ingredientId - description: 'Database id of the ingredient' + name: id + description: 'Database id of the ingredient category' schema: type: integer required: true - post: + get: tags: - - "User shelf" - summary: "Add a single ingredient to user shelf" - operationId: addIngredientToShelf + - "Ingredient categories" + summary: Get a specific ingredient category + operationId: getIngredientCategory + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/IngredientCategory' + '404': + $ref: '#/components/responses/NotFound' + put: + tags: + - "Ingredient categories" + summary: Update a specific ingredient category + operationId: updateIngredientCategory + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/IngredientCategoryRequest' responses: '200': description: Successful response @@ -1219,25 +1202,29 @@ paths: type: object properties: data: - $ref: '#/components/schemas/UserIngredient' + $ref: '#/components/schemas/IngredientCategory' + '403': + $ref: '#/components/responses/NotAuthorized' '404': $ref: '#/components/responses/NotFound' '422': $ref: '#/components/responses/UnprocessableEntity' delete: tags: - - "User shelf" - summary: Delete a single ingredient from user shelf - operationId: deleteIngredientFromShelf + - "Ingredient categories" + summary: Delete specific ingredient category + operationId: deleteIngredientCategory responses: '204': - description: Successfully deleted ingredient + description: Successful response + '403': + $ref: '#/components/responses/NotAuthorized' '404': $ref: '#/components/responses/NotFound' - /user: + /profile: get: tags: - - "User" + - "Profile" summary: "Get info about currently authenticated user" operationId: getProfile responses: @@ -1252,7 +1239,7 @@ paths: $ref: '#/components/schemas/Profile' post: tags: - - "User" + - "Profile" summary: "Update currently authenticated user" operationId: updateProfile description: "Updates currently authenticated user with new information. If password field is present also changes the password." @@ -1260,22 +1247,7 @@ paths: content: application/json: schema: - type: object - properties: - email: - type: string - example: new@email.com - name: - type: string - example: New name - password: - type: string - nullable: true - example: "new-password" - password_confirmation: - type: string - nullable: true - example: "new-password" + $ref: '#/components/schemas/ProfileRequest' responses: '200': description: Successful response @@ -1289,6 +1261,8 @@ paths: '422': $ref: '#/components/responses/UnprocessableEntity' /shopping-list: + parameters: + - $ref: '#/components/parameters/BarParameter' get: tags: - "Shopping list" @@ -1307,6 +1281,8 @@ paths: items: $ref: '#/components/schemas/UserShoppingList' /shopping-list/share: + parameters: + - $ref: '#/components/parameters/BarParameter' get: tags: - "Shopping list" @@ -1323,6 +1299,8 @@ paths: # Recipe name Description /shopping-list/batch-store: + parameters: + - $ref: '#/components/parameters/BarParameter' post: tags: - "Shopping list" @@ -1352,6 +1330,8 @@ paths: items: $ref: '#/components/schemas/UserShoppingList' /shopping-list/batch-delete: + parameters: + - $ref: '#/components/parameters/BarParameter' post: tags: - "Shopping list" @@ -1388,6 +1368,8 @@ paths: items: type: integer /tags: + parameters: + - $ref: '#/components/parameters/BarParameter' get: tags: - "Tags" @@ -1550,6 +1532,8 @@ paths: '404': $ref: '#/components/responses/NotFound' /users: + parameters: + - $ref: '#/components/parameters/BarParameter' get: tags: - Users @@ -1607,6 +1591,13 @@ paths: description: Database id of the user schema: type: integer + - in: query + name: bar_id + required: true + schema: + type: integer + example: 1 + description: Bar reference get: tags: - Users @@ -1665,6 +1656,8 @@ paths: '404': $ref: '#/components/responses/NotFound' /stats: + parameters: + - $ref: '#/components/parameters/BarParameter' get: tags: - Stats @@ -1681,6 +1674,8 @@ paths: data: $ref: '#/components/schemas/StatsResponse' /cocktail-methods: + parameters: + - $ref: '#/components/parameters/BarParameter' get: tags: - "Cocktail methods" @@ -1786,6 +1781,53 @@ paths: '404': $ref: '#/components/responses/NotFound' /notes: + get: + parameters: + - $ref: '#/components/parameters/PageParameter' + - $ref: '#/components/parameters/PerPageParameter' + - in: query + name: filter + required: false + description: Filter by specific attributes + explode: true + style: deepObject + schema: + type: object + properties: + cocktail_id: + type: string + example: "3" + - in: query + name: sort + required: false + schema: + type: string + example: attribute_name + description: "Available attributes: `created_at`." + tags: + - "Notes" + summary: List all current users notes + operationId: listNotes + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + required: + - data + - links + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/Note' + links: + $ref: '#/components/schemas/PaginationLinks' + meta: + $ref: '#/components/schemas/PaginationMeta' post: tags: - "Notes" @@ -1852,6 +1894,8 @@ paths: '404': $ref: '#/components/responses/NotFound' /collections: + parameters: + - $ref: '#/components/parameters/BarParameter' get: parameters: - in: query @@ -2069,6 +2113,7 @@ paths: $ref: '#/components/responses/NotFound' /import/cocktail: parameters: + - $ref: '#/components/parameters/BarParameter' - in: query name: type description: Data source type, defaults to URL @@ -2077,32 +2122,38 @@ paths: enum: - json - url + - yaml + - yml + - collection + - in: query + name: save + description: If applicable, saves the cocktail and returns cocktail response + schema: + type: boolean post: tags: - "Import" summary: Import cocktail from a data source operationId: importCocktail requestBody: + description: 'Duplicated actions can be: `0` - Do nothing, `1` - Skip duplicates, `2` - Overwrite duplicates' content: application/json: schema: - type: object - required: - - source - properties: - source: - type: string + $ref: '#/components/schemas/ImportRequest' responses: '200': description: Successful response content: application/json: schema: - type: object - properties: - data: - type: object - additionalProperties: true + oneOf: + - type: object + properties: + data: + type: object + additionalProperties: true + - $ref: '#/components/schemas/Cocktail' /collections/{id}/share: parameters: - in: path @@ -2139,6 +2190,8 @@ paths: '404': $ref: '#/components/responses/NotFound' /utensils: + parameters: + - $ref: '#/components/parameters/BarParameter' get: tags: - "Utensils" @@ -2245,11 +2298,205 @@ paths: $ref: '#/components/responses/NotAuthorized' '404': $ref: '#/components/responses/NotFound' + /bars: + get: + tags: + - Bars + summary: 'Show a list of bars' + operationId: getBars + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Bar' + post: + tags: + - Bars + summary: Add bar + operationId: addBar + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/BarRequest' + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/Bar' + '403': + $ref: '#/components/responses/NotAuthorized' + '404': + $ref: '#/components/responses/NotFound' + /bars/{id}: + parameters: + - in: path + name: id + description: 'Database id of a bar' + schema: + type: integer + required: true + get: + tags: + - Bars + summary: Get a specific bar + operationId: getBar + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/Bar' + '403': + $ref: '#/components/responses/NotAuthorized' + '404': + $ref: '#/components/responses/NotFound' + put: + tags: + - Bars + summary: Update a specific bar + operationId: updateBar + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/BarRequest' + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/Bar' + '403': + $ref: '#/components/responses/NotAuthorized' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/UnprocessableEntity' + delete: + tags: + - Bars + summary: Delete specific bar + operationId: deleteBar + responses: + '204': + description: Successful response + '403': + $ref: '#/components/responses/NotAuthorized' + '404': + $ref: '#/components/responses/NotFound' + /bars/{id}/memberships: + parameters: + - in: path + name: id + description: 'Database id of a bar' + schema: + type: integer + required: true + get: + tags: + - Bars + summary: Get a specific bar members + operationId: getBarMemberships + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/BarMembership' + '403': + $ref: '#/components/responses/NotAuthorized' + '404': + $ref: '#/components/responses/NotFound' + delete: + tags: + - Bars + summary: Leave the bar if you have membership + operationId: leaveBar + responses: + '204': + description: Successful response + '403': + $ref: '#/components/responses/NotAuthorized' + '404': + $ref: '#/components/responses/NotFound' + /bars/{id}/memberships/{userId}: + parameters: + - in: path + name: id + description: 'Database id of a bar' + schema: + type: integer + required: true + - in: path + name: userId + description: 'Database id of user' + schema: + type: integer + required: true + delete: + tags: + - Bars + summary: Remove user from the bar + operationId: deleteMembership + responses: + '204': + description: Successful response + '403': + $ref: '#/components/responses/NotAuthorized' + '404': + $ref: '#/components/responses/NotFound' components: securitySchemes: user_token: type: http scheme: bearer + parameters: + BarParameter: + name: bar_id + in: query + description: Reference to bar resource + required: true + schema: + type: integer + PageParameter: + name: page + in: query + description: Set current page number + required: false + schema: + type: integer + PerPageParameter: + name: per_page + in: query + description: Total number of results per page + required: false + schema: + type: integer responses: UnprocessableEntity: description: Request body validation failed @@ -2394,8 +2641,6 @@ components: type: object required: - name - - strength - - ingredient_category_id properties: name: type: string @@ -2442,6 +2687,7 @@ components: - images - color - category + - access properties: id: type: integer @@ -2523,6 +2769,17 @@ components: name: type: string example: 'Cocktail 1' + created_user: + $ref: '#/components/schemas/UserBasic' + updated_user: + $ref: '#/components/schemas/UserBasic' + access: + type: object + properties: + can_edit: + type: boolean + can_delete: + type: boolean IngredientCategory: type: object required: @@ -2540,6 +2797,9 @@ components: type: string nullable: true example: 'A category of base spirits' + ingredients_count: + type: integer + example: 7 IngredientCategoryRequest: type: object required: @@ -2555,28 +2815,24 @@ components: Version: type: object required: - - name - version - type - search_host - search_version properties: - name: - type: string - example: 'Bar Assistant' version: type: string - example: 'v1.0.0' + example: 'v3.0.0' type: type: string example: 'local' search_host: type: string format: hostname - example: 'https://my-meilisearch-server.com' + example: 'https://search.example.com' search_version: type: string - example: '0.29.0' + example: '1.3.0' Image: type: object required: @@ -2634,12 +2890,7 @@ components: - id - name - email - - is_admin - - search_host - - search_api_key - - favorite_cocktails - - shelf_ingredients - - shopping_lists + - memberships properties: id: type: integer @@ -2650,33 +2901,42 @@ components: email: type: string example: 'bar@tender.com' - search_host: - type: string - example: 'http://meilisearch-server.com' - search_api_key: - type: string - example: MEILI_API_KEY - favorite_cocktails: - type: array - example: [1, 2] - items: - type: integer - shelf_ingredients: + memberships: type: array - example: [1, 2] - items: - type: integer - shopping_lists: - type: array - example: [1, 2] items: - type: integer + $ref: '#/components/schemas/BarMembership' + ProfileRequest: + type: object + required: + - email + - name + properties: + email: + type: string + example: new@email.com + name: + type: string + example: New name + password: + type: string + nullable: true + example: "new-password" + password_confirmation: + type: string + nullable: true + example: "new-password" + bar_id: + type: integer + example: 1 + is_shelf_public: + type: boolean + example: false UserRequest: type: object required: - name - email - - is_admin + - role_id properties: name: type: string @@ -2687,17 +2947,16 @@ components: password: type: string example: password - is_admin: - type: boolean - example: false + role_id: + type: integer + example: 1 User: type: object required: - id - name - email - - is_admin - - search_api_key + - role properties: id: type: integer @@ -2708,12 +2967,22 @@ components: email: type: string example: admin@example.com - is_admin: - type: boolean - example: false - search_api_key: - type: string - example: 3aab83bbed9bb5e38d00c0c10a006676ed8703a26ffc8c644e2f588a5a574584 + role: + type: object + required: + - bar_id + - role_id + - role_name + properties: + bar_id: + type: integer + example: 1 + role_id: + type: integer + example: 1 + role_name: + type: string + example: "General" CocktailIngredientSubstitute: type: object required: @@ -2748,6 +3017,10 @@ components: type: number format: float example: 30.0 + amount_max: + type: number + format: float + example: 30.0 units: type: string example: ml @@ -2767,6 +3040,9 @@ components: type: array items: $ref: '#/components/schemas/CocktailIngredientSubstitute' + note: + type: string + example: Prefereby vodka Cocktail: type: object required: @@ -2780,12 +3056,12 @@ components: - main_image_id - images - tags - - user_rating - - average_rating + - rating - created_at + - updated_at - abv - - has_public_link - public_id + - access properties: id: type: integer @@ -2838,42 +3114,69 @@ components: type: array items: $ref: '#/components/schemas/CocktailIngredient' - user_rating: - type: integer - nullable: true - example: 3 - average_rating: - type: integer - nullable: true - example: 3 + rating: + type: object + properties: + user: + type: integer + nullable: true + example: 5 + average: + type: integer + example: 5 + total_votes: + type: integer + example: 322 created_at: type: string format: datetime - example: 2023-01-01 12:00:00 - user: + example: 2023-01-01T12:20:25.000000Z + updated_at: + type: string + format: datetime + nullable: true + example: 2023-01-01T12:20:25.000000Z + created_user: + $ref: '#/components/schemas/UserBasic' + updated_user: $ref: '#/components/schemas/UserBasic' method: $ref: '#/components/schemas/CocktailMethod' - collections: + utensils: type: array items: - $ref: '#/components/schemas/CocktailCollection' + $ref: '#/components/schemas/Utensil' abv: type: number format: float nullable: true example: 18.12 - has_public_link: - type: boolean - example: false public_id: type: string nullable: true format: uuid - notes: - type: array - items: - $ref: '#/components/schemas/Note' + access: + type: object + properties: + can_edit: + type: boolean + can_delete: + type: boolean + can_rate: + type: boolean + can_add_note: + type: boolean + navigation: + type: object + properties: + prev: + type: string + example: 'whiskey-sour-1' + description: Slug of prev cocktail + next: + type: string + example: 'martini-1' + description: Slug of next cocktail CocktailRequest: type: object required: @@ -2932,6 +3235,10 @@ components: type: number format: float example: 30 + amount_max: + type: number + format: float + example: 30 units: type: string example: ml @@ -2941,6 +3248,9 @@ components: sort: type: integer example: 0 + note: + type: string + example: Ingredient note UserIngredient: type: object required: @@ -2983,32 +3293,19 @@ components: UserShoppingList: type: object required: - - id - - user_id - - ingredient + - ingredient_id + - ingredient_slug + - ingredient_name properties: - id: - type: integer - example: 1 - user_id: + ingredient_id: type: integer example: 1 - ingredient: - type: object - required: - - id - - slug - - name - properties: - id: - type: integer - example: 1 - slug: - type: string - example: 'ingredient-1' - name: - type: string - example: 'Ingredient 1' + ingredient_slug: + type: string + example: 'ingredient-1' + ingredient_name: + type: string + example: 'Ingredient 1' ValidationError: type: object required: @@ -3069,6 +3366,23 @@ components: total_shelf_cocktails: type: integer example: 43 + total_favorited_cocktails: + type: integer + example: 13 + your_top_ingredients: + type: array + items: + type: object + properties: + ingredient_id: + type: integer + example: 1 + name: + type: string + example: "Lime juice" + cocktails_count: + type: integer + example: 12 total_shelf_ingredients: type: integer example: 12 @@ -3166,6 +3480,7 @@ components: example: "2023-01-01 12:00:00" UserBasic: type: object + nullable: true required: - id - name @@ -3176,18 +3491,6 @@ components: name: type: string example: "Bartender" - CocktailCollection: - type: object - required: - - id - - name - properties: - id: - type: integer - example: 1 - name: - type: string - example: "My collection" CollectionRequest: type: object required: @@ -3337,3 +3640,172 @@ components: description: type: string example: "A recipient in 2 parts to shake cocktails vigorously." + LoginRequest: + type: object + required: + - email + - password + properties: + email: + type: string + example: admin@example.com + password: + type: string + example: password + AuthToken: + type: object + required: + - token + properties: + token: + type: string + example: 1|dvWHLWuZbmWWFbjaUDla393Q9jK5Ou9ujWYPcvII + RegisterRequest: + type: object + required: + - email + - name + - password + properties: + email: + type: string + example: "newuser@domain.com" + name: + type: string + example: "New User" + password: + type: string + example: "P4SSW0RD" + Bar: + type: object + required: + - id + - name + - subtitle + - description + - invite_code + - search_driver_host + - search_driver_api_key + - created_at + - updated_at + properties: + id: + type: integer + example: 1 + name: + type: string + example: "My bar" + subtitle: + type: string + nullable: true + example: "Bar subtitle" + description: + type: string + nullable: true + example: "Lorem ipsum dolor sit amet" + invite_code: + type: string + nullable: true + example: 01H8S3VH2HTEB3D893AW8NTBBC + search_driver_host: + type: string + nullable: true + example: null + search_driver_api_key: + type: string + nullable: true + example: null + created_at: + type: string + format: datetime + example: 2023-01-01T12:20:25.000000Z + updated_at: + nullable: true + type: string + format: datetime + example: 2023-01-01T12:20:25.000000Z + created_user: + $ref: '#/components/schemas/UserBasic' + updated_user: + $ref: '#/components/schemas/UserBasic' + BarRequest: + type: object + required: + - name + - subtitle + - description + - enable_invites + properties: + name: + type: string + example: "My bar name" + subtitle: + type: string + nullable: true + example: "The awesome bar" + description: + type: string + nullable: true + example: "A bar at the end of the town" + enable_invites: + type: boolean + example: true + options: + type: array + example: ['cocktails', 'ingredients'] + CocktailPublic: + type: object + required: + - public_id + - public_at + - public_expires_at + properties: + public_id: + type: string + nullable: true + description: 'Public cocktail ULID' + example: "01ARZ3NDEKTSV4RRFFQ69G5FAV" + public_at: + type: string + nullable: true + description: 'ULID created at datetime' + example: "2023-03-14T20:20:20.000000Z" + public_expires_at: + type: string + description: 'ULID expiration datetime' + example: "2023-05-14T21:23:40.000000Z" + nullable: true + ImportRequest: + type: object + required: + - source + properties: + source: + type: string + format: url + example: "https://source.url" + duplicate_actions: + type: integer + enum: + - 0 + - 1 + - 2 + BarMembership: + type: object + required: + - name + - bar_id + - is_shelf_public + properties: + user_id: + type: integer + example: 1 + user_name: + type: string + example: Bar Tender + bar_id: + type: integer + example: 1 + is_shelf_public: + type: integer + example: false diff --git a/ecs.php b/ecs.php index 76bac434..d8843ee9 100644 --- a/ecs.php +++ b/ecs.php @@ -1,6 +1,7 @@ sets([SetList::PSR_12]); + $ecsConfig->rules([NoUnusedImportsFixer::class]); + $ecsConfig->ruleWithConfiguration(OrderedImportsFixer::class, [ 'sort_algorithm' => 'length' ]); diff --git a/resources/data/iba_cocktails.yml b/resources/data/base_cocktails.yml similarity index 54% rename from resources/data/iba_cocktails.yml rename to resources/data/base_cocktails.yml index 1baaa081..4477dd7c 100644 --- a/resources/data/iba_cocktails.yml +++ b/resources/data/base_cocktails.yml @@ -1,729 +1,1074 @@ +- + name: '20th Century' + description: 'The 20th Century is a cocktail created in 1937 by a British bartender named C.A. Tuck, and named in honor of the celebrated 20th Century Limited train which ran between New York City and Chicago from 1902 until 1967.' + instructions: "1. Combine all ingredients with ice and shake\n2. Strain into a coupe, serve up" + garnish: 'Lemon twist' + source: 'https://en.wikipedia.org/wiki/20th_Century_(cocktail)' + glass: 'Nick and Nora' + method: Shake + abv: 20.91 + tags: + - Gin + ingredients: + - + name: Gin + amount: 45 + units: ml + optional: false + - + name: 'White Crème de Cacao' + amount: 22.5 + units: ml + optional: false + - + name: 'Lillet Blanc' + amount: 22.5 + units: ml + optional: false + - + name: 'Lemon juice' + amount: 15 + units: ml + optional: false + images: + - + resource_path: cocktails/20th-century.jpg + copyright: 'Imbibe Magazine' + placeholder_hash: DykODwIIt3iHdnh0i0aniId3dwKIxnYP +- + name: Adonis + description: 'The cocktail was created in honor of the 1884 musical Adonis after the show reached the milestone of more than 500 shows on Broadway.' + instructions: 'Stir all ingredients with ice and strain into chilled glass.' + garnish: 'Orange zest and peel' + source: 'https://en.wikipedia.org/wiki/Adonis_(cocktail)' + glass: Coupe + method: Stir + abv: 14.7 + tags: + - Wine + ingredients: + - + name: 'Dry Sherry' + amount: 45 + units: ml + optional: false + - + name: 'Sweet Vermouth' + amount: 45 + units: ml + optional: false + - + name: 'Orange bitters' + amount: 2 + units: dashes + optional: false + images: + - + resource_path: cocktails/adonis.jpg + copyright: 'Liquor.com / Tim Nusog' + placeholder_hash: GAgGDwJXeYhFe5Z8lqeHV1atQLCSBl4E +- + name: Airmail + description: 'An endlessly drinkable sparkler of debated origin' + instructions: "1. Combine all ingredients except for the champagne in a mixer and shake for ten seconds\n2. Strain into a flute and top with champagne" + garnish: null + source: 'https://en.wikipedia.org/wiki/Airmail_(cocktail)' + glass: Cocktail + method: Shake + abv: 13.87 + tags: + - Wine + - Aperitif + ingredients: + - + name: 'White Rum' + amount: 30 + units: ml + optional: false + - + name: Champagne + amount: 30 + units: ml + optional: false + - + name: 'Lime juice' + amount: 15 + units: ml + optional: false + - + name: 'Honey Syrup' + amount: 15 + units: ml + optional: false + substitutes: + - 'Simple Syrup' + images: + - + resource_path: cocktails/airmail.jpg + copyright: 'Imbibe Magazine' + placeholder_hash: ixgKFwAImIeLdnh3iRfHaIh5isB4DbML +- + name: Alaska + description: 'One of the great Chartreuse cocktails and a fundamental three-ingredient recipe' + instructions: "1. Combine all ingredients with ice in a mixing glass and stir at length, until the sides of the glass are frosty\n2. Strain into a cocktail glass and serve up" + garnish: null + source: 'https://tuxedono2.com/alaska-cocktail-recipe' + glass: 'Nick and Nora' + method: Stir + abv: 33.23 + tags: + - Herbacious + ingredients: + - + name: Gin + amount: 45 + units: ml + optional: false + - + name: 'Yellow Chartreuse' + amount: 15 + units: ml + optional: false + - + name: 'Orange bitters' + amount: 1 + units: dash + optional: false + images: + - + resource_path: cocktails/alaska.jpg + copyright: Punch + placeholder_hash: 2DgaFwZnh4eAh3iJiGeXZ4eJd2QIh5YA - name: Alexander - description: |- - The Alexander cocktail was born in London in 1922 by Hery Mc Elhone, at Ciro’s Club in honor of a famous bride, at the beginning it was called Panama, Gin was used instead of Cognac and light cocoa cream instead of dark. - - Throughout its history, Alexander has given rise to many other variations: - - **Grasshopper**: This variant is also part of the Iba list and involves the use of crème de menthe verde instead of cognac. - - **Alexandra**: Use the light cocoa cream instead of the dark one and replace the nutmeg with cocoa. - - **Alexander’s Sister**: Use crème de menthe instead of crème de cacao - - **Alejandro**: Replace cognac with rum - instructions: |- - 1. Pour all ingredients into cocktail shaker filled with ice cubes. - 2. Shake and strain into a chilled cocktail glass. + description: "The Alexander cocktail was born in London in 1922 by Hery Mc Elhone, at Ciro’s Club in honor of a famous bride, at the beginning it was called Panama, Gin was used instead of Cognac and light cocoa cream instead of dark.\n\nThroughout its history, Alexander has given rise to many other variations:\n\n**Grasshopper**: This variant is also part of the Iba list and involves the use of crème de menthe verde instead of cognac.\n\n**Alexandra**: Use the light cocoa cream instead of the dark one and replace the nutmeg with cocoa.\n\n**Alexander’s Sister**: Use crème de menthe instead of crème de cacao\n\n**Alejandro**: Replace cognac with rum" + instructions: "1. Pour all ingredients into cocktail shaker filled with ice cubes.\n2. Shake and strain into a chilled cocktail glass." garnish: 'Sprinkle ground nutmeg on top.' source: 'https://iba-world.com/alexander/' - images: - - copyright: 'Liquor.com / Tim Nusog' glass: Coupe method: Shake + abv: 17.33 tags: - 'IBA Cocktail' - 'The Unforgettables' - - Brandy + - Savory ingredients: - + name: Cognac amount: 30 units: ml - name: Cognac optional: false - + name: 'Dark Crème de Cacao' amount: 30 units: ml - name: 'Dark Crème de Cacao' optional: false - + name: Cream amount: 30 units: ml - name: Cream optional: false + images: + - + resource_path: cocktails/alexander.jpg + copyright: 'Liquor.com / Tim Nusog' + placeholder_hash: kAcKDwAHqIefdHh6hUioJ7d0pPJGBpkF +- + name: 'Amaretto Sour' + description: 'Amaretto is an Italian liqueur that’s typically flavored with almonds or apricot stones. Its distinctive flavor can be incorporated into numerous cocktails, but it’s best known for the Amaretto Sour, a drink that tends to get a bad rap. That’s because, too often, the cocktail is overly sweet and relies on premade sour mix. ' + instructions: "1. Combine all ingredients and, if using an egg white, dry shake\n2. Add ice and shake for 10 sec\n3. Strain into a coupe, serve up" + garnish: 'Spray aromatic bitters over foaming cocktail from atomiser and then garnish with lemon & cherry sail (lemon slice & Luxardo Maraschino cherry on stick)' + source: '1974' + glass: Lowball + method: Shake + abv: 11.11 + tags: + - Sour + - Sweet + ingredients: + - + name: Amaretto + amount: 60 + units: ml + optional: false + - + name: 'Lemon juice' + amount: 30 + units: ml + optional: false + - + name: 'Angostura aromatic bitters' + amount: 1 + units: dash + optional: false + - + name: 'Egg White' + amount: 15 + units: ml + optional: true + images: + - + resource_path: cocktails/amaretto-sour.jpg + copyright: 'The Spruce Eats' + placeholder_hash: MCkKLwb1nWmIiHedZpZ4pml5p2BICnUD - name: Americano - description: |- - The story of the Americano dates back to the second half of the 1800s in Gaspare Campari’s bar in Milan. Over the years the cocktail acquires notoriety thanks to some homages from the world of cinema, including James Bond (007 Casino Royale) whose favorite cocktail was, precisely, the Americano. - - The nickname “American” was born when Primo Carnera, a giant Italian boxer, became world heavyweight champion at Madison Square Garden in New York. When Carnera returned to Italy with the title, he was greeted with this very Italian cocktail which, for the occasion, was called “Americano”. - - The Americano entered the IBA Cocktail List only in 1987 but, in reality, it was a cocktail that spread all over Italy during the 1930s. - instructions: |- - 1. Mix the ingredients directly in an old fashioned glass filled with ice cubes. - 2. Add a splash of Soda Water. - 3. Stir gently. + description: "The story of the Americano dates back to the second half of the 1800s in Gaspare Campari’s bar in Milan. Over the years the cocktail acquires notoriety thanks to some homages from the world of cinema, including James Bond (007 Casino Royale) whose favorite cocktail was, precisely, the Americano.\n\nThe nickname “American” was born when Primo Carnera, a giant Italian boxer, became world heavyweight champion at Madison Square Garden in New York. When Carnera returned to Italy with the title, he was greeted with this very Italian cocktail which, for the occasion, was called “Americano”.\n\nThe Americano entered the IBA Cocktail List only in 1987 but, in reality, it was a cocktail that spread all over Italy during the 1930s." + instructions: "1. Mix the ingredients directly in an old fashioned glass filled with ice cubes.\n2. Add a splash of Soda Water.\n3. Stir gently." garnish: 'Garnish with half orange slice and a lemon zest.' source: 'https://iba-world.com/americano/' - images: - - copyright: 'Liquor.com / Tim Nusog' glass: Lowball method: Stir + abv: 17.92 tags: - 'IBA Cocktail' - 'The Unforgettables' + - Bitter ingredients: - + name: Campari amount: 30 units: ml - name: Campari optional: false - + name: 'Sweet Vermouth' amount: 30 units: ml - name: 'Sweet Vermouth' optional: false - + name: 'Club soda' amount: 1 units: splash - name: 'Club soda' optional: false + images: + - + resource_path: cocktails/americano.jpg + copyright: 'Liquor.com / Tim Nusog' + placeholder_hash: DFkKHwgMqJiIhXmIiVaYiIh2tYBYCYgE - name: 'Angel Face' - description: |- - Although the origin of the cocktail is not known, the main hypotheses assume that it was born in the twenties of the twentieth century in France: this thesis would prove the presence of calvados, a liqueur commonly used in France and, in particular, in that period, as an epidemic of phylloxera in European vineyards had limited the production of brandy. - On this basis, one of the most frequent hypotheses has it that the cocktail was created by Harry MacElhone, founder of Harry’s New York Bar in Paris on July 19, 1919, in honor of the celebrations for the victory of the First World War. - The first reliable evidence, however, dates back to 1930 when Harry Craddock included the Angel Face among the recipes of his book “Savoy cocktail book”. Some sources indicate that the name derives from that of an American gangster active during Prohibition; less likely the dedication to Rick Blaine, the protagonist of Casablanca played by Humphrey Bogart, as the film follows the first testimonies (1942). - instructions: |- - 1. Pour all ingredients into cocktail shaker filled with ice cubes. - 2. Shake and strain into a chilled cocktail glass. - garnish: N/A + description: "Although the origin of the cocktail is not known, the main hypotheses assume that it was born in the twenties of the twentieth century in France: this thesis would prove the presence of calvados, a liqueur commonly used in France and, in particular, in that period, as an epidemic of phylloxera in European vineyards had limited the production of brandy.\nOn this basis, one of the most frequent hypotheses has it that the cocktail was created by Harry MacElhone, founder of Harry’s New York Bar in Paris on July 19, 1919, in honor of the celebrations for the victory of the First World War.\nThe first reliable evidence, however, dates back to 1930 when Harry Craddock included the Angel Face among the recipes of his book “Savoy cocktail book”. Some sources indicate that the name derives from that of an American gangster active during Prohibition; less likely the dedication to Rick Blaine, the protagonist of Casablanca played by Humphrey Bogart, as the film follows the first testimonies (1942)." + instructions: "1. Pour all ingredients into cocktail shaker filled with ice cubes.\n2. Shake and strain into a chilled cocktail glass." + garnish: null source: 'https://iba-world.com/angel-face/' - images: - - copyright: 'Wine Enthusiast / Tyler Zieliniski' glass: 'Nick and Nora' method: Shake + abv: 32 tags: - 'IBA Cocktail' - 'The Unforgettables' - - Gin - - Brandy + - Fruity ingredients: - + name: Gin amount: 30 units: ml - name: Gin optional: false - + name: 'Apricot Brandy' amount: 30 units: ml - name: 'Apricot Brandy' optional: false - + name: Calvados amount: 30 units: ml - name: Calvados optional: false + images: + - + resource_path: cocktails/angel-face.jpg + copyright: 'Wine Enthusiast / Tyler Zieliniski' + placeholder_hash: JEkKLwhjV3lQdYjEiHeLt5WZhPe4aI8K - name: Aviation - description: |- - Aviation was created in New York at the Walkick hotel by the head bartender Hugo Ensslin while he was writing his book “Recipes for Mixed Drinks” (1916) one of the last cocktail books published before the start of American Prohibition. - - Lately David Wondrich found the aviation recipe in a 1911 magazine, so the recipe is thought to have been born earlier then 1916. - - In his book Ensslin also indicates the type of Gin to be used ‘El Bart Gin “and the recipe is made up in addition to gin, with lemon juice, maraschino and Creme di Violette. - - The recipe is found again in Harry Craddock’s 1930 Savoy Cocktail Book but without the Creme de Violette, this probably due to the difficulty of finding the violet cream during the post-war period In the 1960s it was still unavailable and therefore, simply, the basic ingredient of the Aviation cocktail was discontinued and the bartenders definitively gave up the characteristic blue color of the drink. - - The IBA proposes it today in its original version with the creme de violette which is now on the market again. This fragrant ingredient not only gave the mix a sweetly floral taste, but also gave it a beautiful sky blue color. - instructions: |- - 1. Add all ingredients into a cocktail shaker. - 2. Shake with cracked ice and strain into a chilled cocktail glass. + description: "Aviation was created in New York at the Walkick hotel by the head bartender Hugo Ensslin while he was writing his book “Recipes for Mixed Drinks” (1916) one of the last cocktail books published before the start of American Prohibition.\n\nLately David Wondrich found the aviation recipe in a 1911 magazine, so the recipe is thought to have been born earlier then 1916.\n\nIn his book Ensslin also indicates the type of Gin to be used ‘El Bart Gin “and the recipe is made up in addition to gin, with lemon juice, maraschino and Creme di Violette.\n\nThe recipe is found again in Harry Craddock’s 1930 Savoy Cocktail Book but without the Creme de Violette, this probably due to the difficulty of finding the violet cream during the post-war period In the 1960s it was still unavailable and therefore, simply, the basic ingredient of the Aviation cocktail was discontinued and the bartenders definitively gave up the characteristic blue color of the drink.\n\nThe IBA proposes it today in its original version with the creme de violette which is now on the market again. This fragrant ingredient not only gave the mix a sweetly floral taste, but also gave it a beautiful sky blue color." + instructions: "1. Add all ingredients into a cocktail shaker.\n2. Shake with cracked ice and strain into a chilled cocktail glass." garnish: 'Optional Maraschino Cherry.' source: 'https://iba-world.com/692/' - images: - - copyright: 'The Spruce Eats / Cara Cormack' glass: Cocktail method: Shake + abv: 24.32 tags: - 'IBA Cocktail' - 'The Unforgettables' - - Gin + - Herbacious ingredients: - + name: Gin amount: 45 units: ml - name: Gin optional: false - + name: Maraschino amount: 15 units: ml - name: Maraschino optional: false - + name: 'Lemon juice' amount: 15 units: ml - name: 'Lemon juice' optional: false - + name: 'Crème de Violette' amount: 1 units: barspoon - name: 'Crème de Violette' optional: false + images: + - + resource_path: cocktails/aviation.jpg + copyright: 'The Spruce Eats / Cara Cormack' + placeholder_hash: 7vcFHwJ5OZhQa3aal4eHZlh8SEz+QtkP +- + name: B-52 + description: 'The origins of the B-52 are not well documented, but one claim is that the B-52 was invented by Peter Fich, a head bartender at the Banff Springs Hotel in Alberta, Canada. Fich named all of his new drinks after favorite bands, albums, and songs, and he supposedly named the drink after the band of the same name, not directly after the US B-52 Stratofortress bomber after which the band was named.' + instructions: 'Refrigerate ingredients then layer in chilled glass by carefully pouring in the ingredient order.' + garnish: null + source: 'https://en.wikipedia.org/wiki/B-52_(cocktail)' + glass: Shot + method: Layer + abv: 25.67 + tags: + - Sweet + ingredients: + - + name: 'Kahlua coffee liqueur' + amount: 15 + units: ml + optional: false + - + name: 'Baileys Irish Cream' + amount: 15 + units: ml + optional: false + - + name: 'Grand Marnier' + amount: 15 + units: ml + optional: false + images: + - + resource_path: cocktails/b-52.jpg + copyright: Alchetron + placeholder_hash: VAgOJwRXiJeAhHqHema3iGeFiLAnC30D +- + name: 'Bacardi Cocktail' + description: "The Bacardí Cocktail was originally the same as the Daiquiri, containing rum, lime juice, and sugar; The Grenadine version of the Bacardí Cocktail originated in the US, while the original non-red Bacardí company recipe originated from Cuba.\nOn April 28, 1936 the New York Supreme Court ruled that the drink must contain Bacardí rum in order to be called a Bacardí cocktail." + instructions: 'Shake together with ice. Strain into glass and serve' + garnish: Lime + source: 'https://en.wikipedia.org/wiki/Bacardi_cocktail' + glass: Coupe + method: Shake + abv: 23.27 + tags: + - Sweet + ingredients: + - + name: 'White Rum' + amount: 60 + units: ml + optional: false + - + name: 'Lime juice' + amount: 15 + units: ml + optional: false + - + name: 'Grenadine Syrup' + amount: 7.5 + units: ml + optional: false + - + name: 'Simple Syrup' + amount: 1 + units: barspoon + optional: false + images: + - + resource_path: cocktails/bacardi-cocktail.jpg + copyright: 'Liquor.com / Tim Nusog' + placeholder_hash: SxgGFwYXd4d3d4ZriJaJCKWalNCJCZ4H - name: Barracuda description: 'The Barracuda cocktail is a bright yellow Rum cocktail. Its origin lies in Italy, where barkeeper Benito Cuppari created the recipe while working on a cruise ship.' - instructions: |- - 1. Pour all ingredients into cocktail shaker except the Prosecco - 2. Shake well with ice - 3. Strain into chilled highball glass filled with ice - 4. Top up with Prosecco + instructions: "1. Pour all ingredients into cocktail shaker except the Prosecco\n2. Shake well with ice\n3. Strain into chilled highball glass filled with ice\n4. Top up with Prosecco" garnish: 'Pineapple and Cherry, optional mint sprig for additional aroma.' source: 'https://iba-world.com/barracuda/' - images: - - copyright: 'Ape Time' glass: Highball method: Shake + abv: 16.11 tags: - 'IBA Cocktail' - 'New Era Drinks' - - Rum - Tiki ingredients: - + name: 'Gold Rum' amount: 45 units: ml - name: 'Gold Rum' optional: false - + name: Galliano amount: 15 units: ml - name: Galliano optional: false - + name: 'Pineapple juice' amount: 60 units: ml - name: 'Pineapple juice' optional: false - + name: 'Lime juice' amount: 1 units: dash - name: 'Lime juice' optional: false - + name: Prosecco amount: 1 units: dash - name: Prosecco optional: false + images: + - + resource_path: cocktails/barracuda.jpg + copyright: 'Ape Time' + placeholder_hash: bCkKFwILhpmHh4Z4mIeHd4iHJ5COtKAI - name: 'Bee’s Knees' - description: |- - The Bee's Knees was invented by Frank Meier, an Austrian-born, part Jewish bartender who was the first head bartender at the Ritz in Paris in 1921, when its Cafe Parisian opened its doors. - - The name comes from prohibition-era slang meaning "the best". - instructions: |- - 1. Stir honey with lemon and orange juices until it dissolves - 2. Add gin and shake with ice - 3. Strain into a chilled cocktail glass - - **Variations:** - - Barr Hill Gin is sometimes recommended for its honey infusion, though other gins may be used (including Barr Hill's Tom Cat gin). - - The honey may be diluted 1:1 with warm water to thin the consistency. - - The honey may be diluted 1:1 with simple syrup instead of water. - - A sprig of basil or thyme may be used for garnish instead of lemon peel. - - Some variations contain orange juice - - Add 2 dashes of absinthe and 2 dashes of orange bitters to make a variation called "Oldest Living Confederate Widow" + description: "The Bee's Knees was invented by Frank Meier, an Austrian-born, part Jewish bartender who was the first head bartender at the Ritz in Paris in 1921, when its Cafe Parisian opened its doors.\n\nThe name comes from prohibition-era slang meaning \"the best\".\n\n**Variations:**\n- Barr Hill Gin is sometimes recommended for its honey infusion, though other gins may be used (including Barr Hill's Tom Cat gin).\n- The honey may be diluted 1:1 with warm water to thin the consistency.\n- The honey may be diluted 1:1 with simple syrup instead of water.\n- A sprig of basil or thyme may be used for garnish instead of lemon peel.\n- Some variations contain orange juice\n- Add 2 dashes of absinthe and 2 dashes of orange bitters to make a variation called \"Oldest Living Confederate Widow\"" + instructions: "1. Stir honey with lemon and orange juices until it dissolves\n2. Add gin and shake with ice\n3. Strain into a chilled cocktail glass" garnish: 'Optionally garnish with a lemon or orange zest.' source: 'https://iba-world.com/bees-knees/' - images: - - copyright: 'Kitchen Swagger' glass: Coupe method: Shake + abv: 17.23 tags: - 'IBA Cocktail' - 'New Era Drinks' - - Gin + - Sweet + - Sour ingredients: - + name: Gin amount: 52.5 units: ml - name: Gin optional: false - + name: 'Honey Syrup' amount: 2 units: barspoon - name: 'Honey Syrup' optional: false - + name: 'Lemon juice' amount: 22.5 units: ml - name: 'Lemon juice' optional: false - + name: 'Orange juice' amount: 22.5 units: ml - name: 'Orange juice' optional: false + images: + - + resource_path: cocktails/bees-knees.jpg + copyright: 'Kitchen Swagger' + placeholder_hash: lAgKHwIEq5WRlGmSeDiVh4SbR4QJVpgA - name: Bellini - description: |- - The Bellini was invented sometime between 1934 and 1948 by Giuseppe Cipriani, founder of Harry's Bar in Venice, Italy. He named the drink the Bellini because its unique pink color reminded him of the toga of a saint in a painting by 15th-century Venetian artist Giovanni Bellini. - - Variations: - - **PUCCINI** – Fresh Mandarin Orange Juice; - - **ROSSINI** – Fresh Strawberry Puree; - - **TINTORETTO** – Fresh Pomegranate Juice. - instructions: |- - 1. Pour peach puree into the mixing glass with ice - 2. Add the Prosecco wine. - 3. Stir gently and pour in a chilled flute glass. - garnish: N/A + description: "The Bellini was invented sometime between 1934 and 1948 by Giuseppe Cipriani, founder of Harry's Bar in Venice, Italy. He named the drink the Bellini because its unique pink color reminded him of the toga of a saint in a painting by 15th-century Venetian artist Giovanni Bellini.\n\nVariations:\n\n**PUCCINI** – Fresh Mandarin Orange Juice;\n\n**ROSSINI** – Fresh Strawberry Puree;\n\n**TINTORETTO** – Fresh Pomegranate Juice." + instructions: "1. Pour peach puree into the mixing glass with ice\n2. Add the Prosecco wine.\n3. Stir gently and pour in a chilled flute glass." + garnish: null source: 'https://iba-world.com/bellini/' - images: - - copyright: 'Olive & Mango' glass: Champagne method: Stir + abv: 6.11 tags: - 'Contemporary Classics' - 'IBA Cocktail' - - Wine + - Fruity ingredients: - + name: Prosecco amount: 100 units: ml - name: Prosecco optional: false - + name: 'White Peach Puree' amount: 50 units: ml - name: 'White Peach Puree' optional: false + images: + - + resource_path: cocktails/bellini.jpg + copyright: 'Olive & Mango' + placeholder_hash: LjkKHwhiVWN8V3WfhpmXaYd6lyQEZ1MA - name: 'Between the Sheets' description: "The origin of the cocktail is usually credited to Harry MacElhone at Harry's New York Bar in Paris in the 1930s as a derivative of the sidecar. However, competing theories exist that claim the cocktail was created at The Berkeley in approximately 1921, or in French brothels as an apéritif for consumption by the prostitutes." - instructions: |- - 1. Add all ingredients into a cocktail shaker - 2. Shake with ice and strain into a chilled cocktail glass - garnish: N/A + instructions: "1. Add all ingredients into a cocktail shaker\n2. Shake with ice and strain into a chilled cocktail glass" + garnish: null source: 'https://iba-world.com/between-the-sheets/' - images: - - copyright: 'A Couple Cooks' glass: Fizzio method: Shake + abv: 26.18 tags: - 'IBA Cocktail' - 'The Unforgettables' - Rum ingredients: - + name: 'White Rum' amount: 30 units: ml - name: 'White Rum' optional: false - + name: Cognac amount: 30 units: ml - name: Cognac optional: false - + name: 'Triple Sec' amount: 30 units: ml - name: 'Triple Sec' optional: false - + name: 'Lemon juice' amount: 20 units: ml - name: 'Lemon juice' optional: false + images: + - + resource_path: cocktails/between-the-sheets.jpg + copyright: 'A Couple Cooks' + placeholder_hash: TAgGFwKZiFWxk4gSXAaXuFtvRVAbA8QD +- + name: Bijou + description: 'This cocktail was invented by Harry Johnson, "the father of professional bartending", who called it bijou because it combined the colors of three jewels: gin for diamond, vermouth for ruby, and chartreuse for emerald.' + instructions: 'Stir in mixing glass with ice and strain' + garnish: 'Maraschino cherry' + source: 'https://en.wikipedia.org/wiki/Bijou_(cocktail)' + glass: 'Nick and Nora' + method: Stir + abv: 31.28 + tags: + - Gin + ingredients: + - + name: Gin + amount: 30 + units: ml + optional: false + - + name: 'Sweet Vermouth' + amount: 30 + units: ml + optional: false + - + name: 'Green Chartreuse' + amount: 30 + units: ml + optional: false + - + name: 'Orange bitters' + amount: 2 + units: dashes + optional: false + images: + - + resource_path: cocktails/bijou.jpg + copyright: 'A Couple Cooks' + placeholder_hash: CvgFJwRC9KRodniFiwXWppeVW5ULC6oB - name: 'Black Russian' - description: |- - Variations: - - **WHITE RUSSIAN** – Float fresh cream on the top and stir in slowly. + description: "Variations:\n\n**WHITE RUSSIAN** – Float fresh cream on the top and stir in slowly." instructions: 'Pour the ingredients into the old fashioned glass filled with ice cubes. Stir gently.' - garnish: N/A + garnish: null source: 'https://iba-world.com/black-russian/' - images: - - copyright: 'Liquor.com / Tim Nusog' glass: Lowball method: Stir + abv: 28.57 tags: - 'Contemporary Classics' - 'IBA Cocktail' - Vodka ingredients: - + name: Vodka amount: 50 units: ml - name: Vodka optional: false - + name: 'Kahlua coffee liqueur' amount: 20 units: ml - name: 'Kahlua coffee liqueur' optional: false + images: + - + resource_path: cocktails/black-russian.jpg + copyright: 'Liquor.com / Tim Nusog' + placeholder_hash: SxgKDwIHXMZydoeSSyiol2d4xiYHi4AB - name: 'Bloody Mary' description: 'If requested served with ice, pour into highball glass.' instructions: 'Stir gently all the ingredients in a mixing glass with ice, pour into rocks glass.' garnish: 'Celery, Lemon Wedge (Optional)' source: 'https://iba-world.com/bloody-mary/' - images: - - copyright: Ocado glass: Highball method: Build + abv: 10.78 tags: - 'Contemporary Classics' - 'IBA Cocktail' - Vodka ingredients: - + name: Vodka amount: 45 units: ml - name: Vodka optional: false - + name: 'Tomato juice' amount: 90 units: ml - name: 'Tomato juice' optional: false - + name: 'Lemon juice' amount: 15 units: ml - name: 'Lemon juice' optional: false - + name: 'Worcestershire Sauce' amount: 2 units: dashes - name: 'Worcestershire Sauce' optional: false - + name: Tabasco amount: 1 units: dash - name: Tabasco optional: false - + name: Pepper amount: 1 units: pinch - name: Pepper optional: false - + name: Salt amount: 1 units: pinch - name: Salt optional: false + images: + - + resource_path: cocktails/bloody-mary.jpg + copyright: Ocado + placeholder_hash: llgGHwZseIlgdnmuamWHaFeqZ0BYB6gC - name: Boulevardier description: 'The boulevardier is similar to a Negroni, sharing two of its three ingredients. It is differentiated by its use of bourbon whiskey or rye whiskey as its principal component instead of gin. Paul Clark, writing for the food blog Serious Eats, says, "This isn''t a Negroni. It is, however, the Negroni''s long-lost autumnal cousin."' instructions: 'Pour all ingredients into mixing glass with ice cubes. Stir well. Strain into a chilled cocktail glass.' garnish: 'Garnish with orange zest, optionally a lemon zest.' source: 'https://iba-world.com/boulevardier/' - images: - - copyright: 'Reddit' glass: Lowball method: Stir + abv: 24.52 tags: - 'IBA Cocktail' - 'The Unforgettables' - Whiskey ingredients: - + name: 'Bourbon Whiskey' amount: 45 units: ml - name: 'Bourbon Whiskey' optional: false - substitutes: ['Rye Whiskey'] + substitutes: + - 'Rye whiskey' - + name: Campari amount: 30 units: ml - name: Campari optional: false - + name: 'Sweet Vermouth' amount: 30 units: ml - name: 'Sweet Vermouth' optional: false + images: + - + resource_path: cocktails/boulevardier.jpg + copyright: Reddit + placeholder_hash: TygKFwY4h3e/lJVnaXd3iYZ7mFYJSZYA - name: Bramble description: "The Bramble was created in London, in 1984, by Dick Bradsell. At the time, Bradsell worked at a bar in Soho called Fred's Club, and he wanted to create a British cocktail. Memories of going blackberrying in his childhood on the Isle of Wight provided the inspiration for the Bramble." - instructions: |- - If crème de mûre is unavailable, many bartenders will substitute creme de cassis for it. - - 1. Pour all ingredients into a cocktail shaker except the Crème de Mûre - 2. Shake well with ice - 3. Strain into chilled old fashioned glass filled with crushed ice - 4. Pour the blackberry liqueur (Crème de Mûre) over the top of the drink, in a circular motion. + instructions: "If crème de mûre is unavailable, many bartenders will substitute creme de cassis for it.\n\n1. Pour all ingredients into a cocktail shaker except the Crème de Mûre\n2. Shake well with ice\n3. Strain into chilled old fashioned glass filled with crushed ice\n4. Pour the blackberry liqueur (Crème de Mûre) over the top of the drink, in a circular motion." garnish: 'Garnish optionally with a lemon slice and blackberries.' source: 'https://iba-world.com/bramble/' - images: - - copyright: 'Anders' glass: Lowball method: Shake + abv: 20.66 tags: - 'IBA Cocktail' - 'New Era Drinks' - Gin ingredients: - + name: Gin amount: 50 units: ml - name: Gin optional: false - + name: 'Lemon juice' amount: 25 units: ml - name: 'Lemon juice' optional: false - + name: 'Simple Syrup' amount: 12 units: ml - name: 'Simple Syrup' optional: false - + name: 'Crème de mûre (blackberry liqueur)' amount: 15 units: ml - name: 'Crème de mûre (blackberry liqueur)' optional: false + images: + - + resource_path: cocktails/bramble.jpg + copyright: Anders + placeholder_hash: 00gGFwYQuVdYeJahfRWrpXh5VAm5WIAH - name: 'Brandy Crusta' - description: |- - The cocktail, named for the crust of sugar on the rim, was invented by Joseph Santini, a bartender in New Orleans at his bar, Jewel of the South. - - Jerry Thomas was the first to publish the recipe in his 1862 cocktail manual. + description: "The cocktail, named for the crust of sugar on the rim, was invented by Joseph Santini, a bartender in New Orleans at his bar, Jewel of the South.\n\nJerry Thomas was the first to publish the recipe in his 1862 cocktail manual." instructions: 'Mix together all ingredients with ice cubes in a mixing glass and strain into prepared slim cocktail glass.' garnish: 'Rub a slice of orange (or lemon) around the rim of the glass and dip it in pulverized white sugar, so that the sugar will adhere to the edge of the glass. Carefully curling place the orange/lemon peel around the inside of the glass.' source: 'https://iba-world.com/brandy-crusta/' - images: - - copyright: Mangosalsa glass: 'Nick and Nora' method: Stir + abv: 26.13 tags: - 'IBA Cocktail' - 'The Unforgettables' - Brandy ingredients: - + name: Brandy amount: 52 units: ml - name: Brandy optional: false - + name: Maraschino amount: 7 units: ml - name: Maraschino optional: false - + name: 'Orange Curaçao' amount: 1 units: barspoon - name: 'Orange Curaçao' optional: false - + name: 'Lemon juice' amount: 15 units: ml - name: 'Lemon juice' optional: false - + name: 'Simple Syrup' amount: 1 units: barspoon - name: 'Simple Syrup' optional: false - + name: 'Angostura aromatic bitters' amount: 2 units: dashes - name: 'Angostura aromatic bitters' optional: false + images: + - + resource_path: cocktails/brandy-crusta.jpg + copyright: Mangosalsa + placeholder_hash: WDkKHwQ5d4hviIiId0h6N5l4OfE3BnsB - name: Caipirinha - description: |- - Although the origin of the drink is unknown, one account says it came about around 1918 in the region of Alentejo in Portugal, with a popular recipe made with lemon, garlic, and honey, indicated for patients with the Spanish flu. Another account is that Caipirinha is based on Poncha, an alcoholic drink from Madeira, Portugal. The main ingredient is aguardente de cana, which is made from sugar cane. Sugar cane production was switched from Madeira to Brazil by the Portuguese as they needed more land to plant it on. Before this people in Madeira had already created aguardente de cana, which was the ancestor to cachaça. - - Variations: - - **CAIPIROSKA** – Instead of Cachaça use Vodka; - instructions: |- - 1. Place lime and sugar into a double old fashioned glass and muddle gently. - 2. Fill the glass with cracked ice and add Cachaça. - 3. Stir gently to involve ingredients. - garnish: N/A + description: "Although the origin of the drink is unknown, one account says it came about around 1918 in the region of Alentejo in Portugal, with a popular recipe made with lemon, garlic, and honey, indicated for patients with the Spanish flu. Another account is that Caipirinha is based on Poncha, an alcoholic drink from Madeira, Portugal. The main ingredient is aguardente de cana, which is made from sugar cane. Sugar cane production was switched from Madeira to Brazil by the Portuguese as they needed more land to plant it on. Before this people in Madeira had already created aguardente de cana, which was the ancestor to cachaça.\n\nVariations:\n\n**CAIPIROSKA** – Instead of Cachaça use Vodka;" + instructions: "1. Place lime and sugar into a double old fashioned glass and muddle gently.\n2. Fill the glass with cracked ice and add Cachaça.\n3. Stir gently to involve ingredients." + garnish: null source: 'https://iba-world.com/caipirinha/' - images: - - copyright: 'Liquor.com / Tim Nusog' glass: Lowball method: Muddle + abv: 36.36 tags: - 'Contemporary Classics' - 'IBA Cocktail' ingredients: - + name: Cachaça amount: 60 units: ml - name: Cachaça optional: false - + name: Lime amount: 4 units: wedge - name: Lime optional: false - + name: Sugar amount: 4 units: barspoon - name: Sugar optional: false + images: + - + resource_path: cocktails/caipirinha.jpg + copyright: 'Liquor.com / Tim Nusog' + placeholder_hash: JxkWJwR3h4d/iYeHh4d4h3d4l1A4ynAH - name: Canchànchara description: 'Known as the forerunner of the Daiquiri, and drunk by Cuban revolutionaries fighting off the Spanish at the end of the nineteenth century, the Canchànchara is a simple mix of honey, citrus and rum (originally aguardiente de caña rather than rum).' instructions: 'Mix honey with water and lime juice and spread the mixture on the bottom and sides of the glass. Add cracked ice, and then the rum. End by energetically stirring from bottom to top.' garnish: 'Lime wedge.' source: 'https://iba-world.com/cachanchara/' - images: - - copyright: 'Liquor.com / Tim Nusog' glass: Lowball method: Build + abv: 15.58 tags: - 'IBA Cocktail' - 'New Era Drinks' - Rum ingredients: - + name: 'White Rum' amount: 60 units: ml - name: 'White Rum' optional: false - + name: 'Lime juice' amount: 15 units: ml - name: 'Lime juice' optional: false - + name: 'Honey Syrup' amount: 15 units: ml - name: 'Honey Syrup' optional: false - + name: Water amount: 50 units: ml - name: Water optional: false + images: + - + resource_path: cocktails/canchanchara.jpg + copyright: 'Liquor.com / Tim Nusog' + placeholder_hash: 2RgOHwQoaJdxeXdwiZlniJiJSB+W9nEL +- + name: Cantaritos + description: 'Cantaritos are Mexican tequila cocktails served in clay cups! Similar to the Paloma, this drink stars grapefruit soda and citrus.' + instructions: "1. If using the traditional clay cup for serving, soak it in cold water for 10 minutes before using. Otherwise, use a highball glass.\n2. Combine the tequila, orange juice, lemon juice and lime juice in the glass with a pinch of salt.\n3. Fill the glass with ice and top with grapefruit soda." + garnish: 'Citrus wedges' + source: Mexico + glass: Highball + method: Build + abv: 9.7 + tags: + - Tequila + ingredients: + - + name: 'Tequila Reposado' + amount: 60 + units: ml + optional: false + - + name: 'Orange juice' + amount: 45 + units: ml + optional: false + - + name: 'Lime juice' + amount: 15 + units: ml + optional: false + - + name: 'Lemon juice' + amount: 15 + units: ml + optional: false + - + name: 'Grapefruit juice' + amount: 90 + units: ml + optional: false + - + name: Salt + amount: 2 + units: pinch + optional: true + images: + - + resource_path: cocktails/cantaritos.jpg + copyright: 'A Couple Cooks' + placeholder_hash: ZwgKLwy3QmeKlnePp2p3t3l1xwFXHYAF - name: Casino description: 'This version of the Casino Cocktail first appears in 1909, in The Reminder (3rd edition) by Jacob A. Didier.' instructions: 'Pour all ingredients into cocktails shaker, shake well with ice, strain into chilled rocks glass with ice.' garnish: 'Garnish with lemon zest and a maraschino cherry.' source: 'https://iba-world.com/casino/' - images: - - copyright: 'Cocktail Society' glass: Lowball method: Shake + abv: 25.54 tags: - 'IBA Cocktail' - 'The Unforgettables' - Gin ingredients: - + name: Gin amount: 40 units: ml - name: Gin optional: false - substitutes: ['Old Tom Gin'] + substitutes: + - 'Old Tom Gin' - + name: Maraschino amount: 10 units: ml - name: Maraschino optional: false - + name: 'Lemon juice' amount: 10 units: ml - name: 'Lemon juice' optional: false - + name: 'Orange bitters' amount: 2 units: dashes - name: 'Orange bitters' optional: false + images: + - + resource_path: cocktails/casino.jpg + copyright: 'Cocktail Society' + placeholder_hash: ZxgKDwIISJqSl4iUe0p4Z4iHt6C7CawJ - name: 'Champagne Cocktail' description: 'A recipe for the cocktail appears as early as "Professor" Jerry Thomas'' Bon Vivant''s Companion (1862), which omits the brandy or cognac and is considered to be the "classic" American version.' instructions: 'Place the sugar cube with 2 dashes of bitters in a large Champagne glass, add the cognac. Pour gently chilled Champagne.' garnish: 'Garnish with orange zest and maraschino cherry.' source: 'https://iba-world.com/champagne-cocktail/' - images: - - copyright: 'A Couple Cooks' glass: Champagne method: Build + abv: 13.78 tags: - 'Contemporary Classics' - 'IBA Cocktail' - Wine ingredients: - + name: Champagne amount: 90 units: ml - name: Champagne optional: false - + name: Cognac amount: 10 units: ml - name: Cognac optional: false - + name: 'Angostura aromatic bitters' amount: 2 units: dashes - name: 'Angostura aromatic bitters' optional: false - + name: 'Grand Marnier' amount: 1 units: drops - name: 'Grand Marnier' optional: true - + name: Sugar amount: 1 units: cube - name: Sugar optional: false + images: + - + resource_path: cocktails/champagne-cocktail.jpg + copyright: 'A Couple Cooks' + placeholder_hash: DPgJDwBHqWdxhniOiQeH14hyevEmWW8A - name: 'Clover Club' description: "The Clover Club Cocktail is a drink that pre-dates Prohibition in the United States, and is named for the Philadelphia men's club of the same name, which met in the Bellevue-Stratford Hotel at South Broad and Walnut Streets in Center City. The Clover Club was chartered in 1882." - instructions: |- - 1. Pour all ingredients into cocktails shaker. - 2. Shake well with ice. - 3. Strain into chilled cocktail glass. - - The egg white is not added for the purpose of giving the drink flavor, but rather acts as an emulsifier. Thus when the drink is shaken a characteristic foamy head is formed. + instructions: "1. Pour all ingredients into cocktails shaker.\n2. Shake well with ice.\n3. Strain into chilled cocktail glass.\n\nThe egg white is not added for the purpose of giving the drink flavor, but rather acts as an emulsifier. Thus when the drink is shaken a characteristic foamy head is formed. " garnish: 'Fresh raspberries' source: 'https://iba-world.com/clover-club/' - images: - - copyright: 'A Couple Cooks' glass: Coupe method: Shake + abv: 19.2 + tags: + - 'IBA Cocktail' + - 'The Unforgettables' + - Gin + ingredients: + - + name: Gin + amount: 45 + units: ml + optional: false + - + name: 'Raspberry Syrup' + amount: 15 + units: ml + optional: false + - + name: 'Lemon juice' + amount: 15 + units: ml + optional: false + - + name: 'Egg White' + amount: 1 + units: drops + optional: true + images: + - + resource_path: cocktails/clover-club.jpg + copyright: 'A Couple Cooks' + placeholder_hash: CRgKFwRIiYhndYaKiQend6l0evMmCH4B +- + name: 'Comte de Sureau' + description: null + instructions: 'Stir with ice and strain into a chilled rocks glass over ice.' + garnish: 'Garnish with orange and lemon twists.' + source: 'https://www.diffordsguide.com/cocktails/recipe/7257/comte-de-sureau' + glass: Lowball + method: Stir + abv: 26.75 tags: - - 'IBA Cocktail' - - 'The Unforgettables' - Gin ingredients: - + name: Gin amount: 45 units: ml - name: Gin optional: false - - amount: 15 + name: St-Germain + amount: 25 units: ml - name: 'Raspberry Syrup' optional: false + substitutes: + - 'Elderflower Cordial' - - amount: 15 + name: Campari + amount: 7.5 units: ml - name: 'Lemon juice' optional: false + images: - - amount: 1 - units: drops - name: 'Egg White' - optional: true + resource_path: cocktails/comte-de-sureau.jpg + copyright: "Eric's Cocktail Guide" + placeholder_hash: I0kKJwirmGePd4eJd5ZoiGeHN2BmBXUF - name: 'Corpse Reviver №2' description: 'The Corpse Reviver №2 as described in the Savoy Cocktail Book is the most commonly drunk of the corpse revivers, and consists of equal parts gin, lemon juice, curaçao (commonly Cointreau), Kina Lillet (now usually replaced with Cocchi Americano, as a closer match to Kina Lillet than modern Lillet Blanc), and a dash of absinthe. The dash of absinthe can either be added to the mix before shaking, or added to the cocktail glass and moved around until the glass has been coated with a layer of absinthe to give a subtle absinthe aroma and flavor to the drink.' instructions: 'Pour all ingredients into shaker with ice. Shake well and strain in chilled cocktail glass.' garnish: 'Garnish with orange zest' source: 'https://iba-world.com/corpse-reviver-2/' - images: - - copyright: 'Punch Drink' glass: Fizzio method: Shake + abv: 19.46 tags: - 'Contemporary Classics' - 'IBA Cocktail' @@ -731,844 +1076,1086 @@ - Tiki ingredients: - + name: Gin amount: 30 units: ml - name: Gin optional: false - + name: Cointreau amount: 30 units: ml - name: Cointreau optional: false - + name: 'Lillet Blanc' amount: 30 units: ml - name: 'Lillet Blanc' optional: false - + name: 'Lemon juice' amount: 30 units: ml - name: 'Lemon juice' optional: false - + name: Absinthe amount: 1 units: dash - name: Absinthe optional: false + images: + - + resource_path: cocktails/corpse-reviver-2.jpg + copyright: 'Punch Drink' + placeholder_hash: ZQgaFwJKeIdwd3hld3eHd5iHl8BYCJwD - name: Cosmopolitan - description: |- - A cosmopolitan, or informally a cosmo, is a relative of cranberry coolers. - - The cosmopolitan gained popularity in the 1990s. It was further popularized among young women by its frequent mention on the television program Sex and the City, where Sarah Jessica Parker's character, Carrie Bradshaw, commonly ordered the drink when out with her girlfriends. The film adaptation made a reference to its popularity when Miranda asks why they stopped drinking them, Carrie replies "because everyone else started." + description: "A cosmopolitan, or informally a cosmo, is a relative of cranberry coolers.\n\nThe cosmopolitan gained popularity in the 1990s. It was further popularized among young women by its frequent mention on the television program Sex and the City, where Sarah Jessica Parker's character, Carrie Bradshaw, commonly ordered the drink when out with her girlfriends. The film adaptation made a reference to its popularity when Miranda asks why they stopped drinking them, Carrie replies \"because everyone else started.\"" instructions: 'Add all ingredients into a cocktail shaker filled with ice. Shake well and strain into a large cocktail glass.' garnish: 'Garnish with a lemon twist.' source: 'https://iba-world.com/cosmopolitan/' - images: - - copyright: "Amanda's Cookin'" glass: Cocktail method: Shake + abv: 17.6 tags: - 'Contemporary Classics' - 'IBA Cocktail' - Vodka ingredients: - + name: Vodka amount: 40 units: ml - name: Vodka optional: false - substitutes: ['Vodka Citron'] + substitutes: + - 'Vodka Citron' - + name: Cointreau amount: 15 units: ml - name: Cointreau optional: false - + name: 'Lime juice' amount: 15 units: ml - name: 'Lime juice' optional: false - + name: 'Cranberry juice' amount: 30 units: ml - name: 'Cranberry juice' optional: false + images: + - + resource_path: cocktails/cosmopolitan.jpg + copyright: "Amanda's Cookin'" + placeholder_hash: cBgKDwRXdodzd3efeseYZ2aVCvZnC58H - name: 'Cuba Libre' - description: |- - The cocktail originated in the early 20th century in Cuba, after the country won independence in the Spanish–American War. It subsequently became popular across Cuba, the United States, and other countries. Its simple recipe and inexpensive, ubiquitous ingredients have made it one of the world's most-popular alcoholic drinks. Drink critics often consider the drink mediocre, but it has been noted for its historical significance. - - Traditionally, the cola ingredient is Coca-Cola ("Coke") and the alcohol is a light rum such as Bacardi; however, the drink may be made with various types of rums and cola brands, and lime juice may or may not be included. + description: "The cocktail originated in the early 20th century in Cuba, after the country won independence in the Spanish–American War. It subsequently became popular across Cuba, the United States, and other countries. Its simple recipe and inexpensive, ubiquitous ingredients have made it one of the world's most-popular alcoholic drinks. Drink critics often consider the drink mediocre, but it has been noted for its historical significance.\n\nTraditionally, the cola ingredient is Coca-Cola (\"Coke\") and the alcohol is a light rum such as Bacardi; however, the drink may be made with various types of rums and cola brands, and lime juice may or may not be included. " instructions: 'Build all ingredients in a highball glass filled with ice.' garnish: 'Garnish with lime wedge.' source: 'https://iba-world.com/cuba-libre/' - images: - - copyright: 'Jamie Oliver' glass: Highball method: Build + abv: 10.1 tags: - 'Contemporary Classics' - 'IBA Cocktail' - Rum ingredients: - + name: 'White Rum' amount: 50 units: ml - name: 'White Rum' optional: false - + name: Cola amount: 120 units: ml - name: Cola optional: false - + name: 'Lime juice' amount: 10 units: ml - name: 'Lime juice' optional: false + images: + - + resource_path: cocktails/cuba-libre.jpg + copyright: 'Jamie Oliver' + placeholder_hash: 1SgKHwZbaIdUgHqLiHh4SIeHd2BnBoUF - name: Daiquiri description: 'Daiquirí is also the name of a beach and an iron mine near Santiago de Cuba, and is a word of Taíno origin. The drink was supposedly invented by an American mining engineer named Jennings Cox, who was in Cuba (then at the tail-end of the Spanish Captaincy-General government) at the time of the Spanish–American War. It is also possible that William A. Chanler, a US congressman who purchased the Santiago iron mines in 1902, introduced the daiquiri to clubs in New York in that year.' instructions: 'In a cocktail shaker add all ingredients. Stir well to dissolve the sugar. Add ice and shake. Strain into chilled cocktail glass.' - garnish: N/A + garnish: null source: 'https://iba-world.com/daiquiri/' - images: - - copyright: 'A Couple Cooks' glass: Coupe method: Shake + abv: 24 tags: - 'IBA Cocktail' - 'The Unforgettables' - Rum ingredients: - + name: 'White Rum' amount: 60 units: ml - name: 'White Rum' optional: false - + name: 'Lime juice' amount: 20 units: ml - name: 'Lime juice' optional: false - + name: Sugar amount: 2 units: barspoon - name: Sugar optional: false + images: + - + resource_path: cocktails/daiquiri.jpg + copyright: 'A Couple Cooks' + placeholder_hash: p4YJFwRWh4dvdneDiGenV4h4OCf8RJMP - name: 'Dark ‘n’ stormy' - description: |- - This drink is very similar to the Moscow mule except that the Dark 'n' Stormy has dark rum instead of vodka. The original Dark 'n' Stormy was made with Gosling Black Seal rum and Barritt's Ginger Beer, but after the partnership between the two failed and the companies parted ways, Gosling Brothers created its own ginger beer. - - Gosling Brothers claims that the drink was invented in Bermuda just after World War I. + description: "This drink is very similar to the Moscow mule except that the Dark 'n' Stormy has dark rum instead of vodka. The original Dark 'n' Stormy was made with Gosling Black Seal rum and Barritt's Ginger Beer, but after the partnership between the two failed and the companies parted ways, Gosling Brothers created its own ginger beer.\n\nGosling Brothers claims that the drink was invented in Bermuda just after World War I." instructions: 'In a highball glass filled with ice pour the ginger beer and top floating with the Rum.' garnish: 'Garnish with a lime wedge or slice.' source: 'https://iba-world.com/dark-n-stormy/' - images: - - copyright: 'The Spruce / Madhumita Sathishkumar' glass: Highball method: Build + abv: 13.64 tags: - 'IBA Cocktail' - 'New Era Drinks' - Rum ingredients: - + name: 'Dark Rum' amount: 60 units: ml - name: 'Dark Rum' optional: false - + name: 'Ginger beer' amount: 100 units: ml - name: 'Ginger beer' optional: false + images: + - + resource_path: cocktails/dark-n-stormy.jpg + copyright: 'The Spruce / Madhumita Sathishkumar' + placeholder_hash: rCgKJwb3SIibd4eKhZlXh5h1qFA3B3YE - - name: 'Dry Martini' - description: 'A dry martini is made with little to no vermouth. Ordering a martini "extra dry" will result in even less or no vermouth added.' - instructions: 'Pour all ingredients into mixing glass with ice cubes. Stir well. Strain into chilled martini cocktail glass.' - garnish: 'Squeeze oil from lemon peel onto the drink, or garnish with green olives if requested.' + name: Martini + instructions: |- + 1. Pour all ingredients into mixing glass with ice cubes. + 2. Stir well. + 3. Strain into chilled martini cocktail glass. + garnish: 'Squeeze oil from lemon peel onto the drink, or garnish with green olives if requested.' + description: |- + This classic is a contentious and sensitive topic with serious Martini drinkers. The way one drinks a Martini is simply a matter of personal preference. + + **Variations:** + - Dry Martini: A dry martini is made with little to no vermouth + - 50/50 Martini: Equal parts vermouth and gin + - Dirty Martini: A Dirty Martini simply requires a splash of olive juice and a substitution of a good, dry olive (or two) for garnish. + - Gibson: Use cocktail onion for garnish source: 'https://iba-world.com/dry-martini/' - images: - - copyright: 'Getty Images/iStockphoto' - glass: Cocktail - method: Stir tags: - 'IBA Cocktail' - 'The Unforgettables' - - Gin + - Dry + glass: Cocktail + method: Stir + abv: 27.17 + images: + - + resource_path: cocktails/martini.jpg + copyright: 'Punch | Lizzie Munro' + placeholder_hash: EQgSDwJbd4dgaId5iJiISYZ3Kp+/8PQN ingredients: - + sort: 1 + name: Gin amount: 60 units: ml - name: Gin optional: false - - amount: 10 + sort: 2 + name: 'Dry Vermouth' + amount: 30 + units: ml + optional: false + - + sort: 3 + name: 'Orange bitters' + amount: 2 + units: dashes + optional: true +- + name: 'El Presidente' + description: 'The El Presidente earned its acclaim in Havana during the 1920s through the 1940s during the American Prohibition. It quickly became the preferred drink of the Cuban upper class.' + instructions: 'Add all ingredients to a mixing glass with ice and stir until well-chilled. ' + garnish: null + source: 'https://www.liquor.com/recipes/el-presidente/' + glass: Cocktail + method: Stir + abv: 26.17 + tags: + - Rum + ingredients: + - + name: 'White Rum' + amount: 45 units: ml + optional: false + - name: 'Dry Vermouth' + amount: 22.5 + units: ml optional: false + substitutes: + - 'Lillet Blanc' + - + name: 'Orange Curaçao' + amount: 7.5 + units: ml + optional: false + - + name: 'Grenadine Syrup' + amount: 1 + units: teaspoon + optional: false + images: + - + resource_path: cocktails/el-presidente.jpg + copyright: 'A Couple Cooks' + placeholder_hash: 8ecFFwTnd2iHeXeIdvoniHd3RICWBHQJ - name: 'Espresso Martini' description: 'It is not a true martini as it contains neither gin nor vermouth, but is one of many drinks that incorporate the term martini into their names.' instructions: 'Pour all ingredients into cocktail shaker, shake well with ice, strain into chilled cocktail glass.' - garnish: N/A + garnish: null source: 'https://iba-world.com/espresso-martini/' - images: - - copyright: "It's Liv B." glass: Cocktail method: Shake + abv: 23.11 tags: - 'IBA Cocktail' - 'New Era Drinks' - Vodka ingredients: - + name: Vodka amount: 50 units: ml - name: Vodka optional: false - + name: 'Kahlua coffee liqueur' amount: 30 units: ml - name: 'Kahlua coffee liqueur' optional: false - + name: 'Simple Syrup' amount: 10 units: ml - name: 'Simple Syrup' optional: false - + name: Espresso amount: 1 units: strong - name: Espresso optional: false + images: + - + resource_path: cocktails/espresso-martini.jpg + copyright: "It's Liv B." + placeholder_hash: zygGFwJPqId4Zoh0iUfHKLZp+qVJT5kD - name: Fernandito - description: |- - The cocktail first became popular among the youth of the college town of Córdoba, in the 1980s and—impulsed by an advertising campaign led by Fratelli Branca—its consumption grew in popularity during the following decades to become widespread throughout the country, surpassed only by that of beer and wine. It is now considered a cultural icon of Argentina and is especially associated with its home province of Córdoba, where the drink is most consumed. The popularity of fernet con coca is such that Argentina consumes more than 75% of all fernet produced globally. The cocktail can also be found in some of its bordering countries, like Uruguay. - - Although typically made with Fernet-Branca and Coca-Cola, several amaro brands have appeared in Argentina since its popularization, as well as ready-to-drink versions. + description: "The cocktail first became popular among the youth of the college town of Córdoba, in the 1980s and—impulsed by an advertising campaign led by Fratelli Branca—its consumption grew in popularity during the following decades to become widespread throughout the country, surpassed only by that of beer and wine. It is now considered a cultural icon of Argentina and is especially associated with its home province of Córdoba, where the drink is most consumed. The popularity of fernet con coca is such that Argentina consumes more than 75% of all fernet produced globally. The cocktail can also be found in some of its bordering countries, like Uruguay.\n\nAlthough typically made with Fernet-Branca and Coca-Cola, several amaro brands have appeared in Argentina since its popularization, as well as ready-to-drink versions. " instructions: 'Pour the Fernet Branca into a double old fashioned glass with ice, fill the glass up with Cola. Gently stir.' - garnish: N/A + garnish: null source: 'https://iba-world.com/fernandito/' - images: - - copyright: 'The daily meal' glass: Highball method: Build + abv: 10.43 tags: - 'IBA Cocktail' - 'New Era Drinks' ingredients: - + name: 'Fernet Branca' amount: 50 units: ml - name: 'Fernet Branca' optional: false - + name: Cola amount: 120 units: ml - name: Cola optional: false + images: + - + resource_path: cocktails/fernandito.jpg + copyright: 'The daily meal' + placeholder_hash: JykGJwT3R3ZchZd1cHvqZnd1der4eJ8P - name: 'French 75' description: "The drink dates to World War I, and an early form was created in 1915 at the New York Bar in Paris—later Harry's New York Bar—by barman Harry MacElhone. The combination was said to have such a kick that it felt like being shelled with the powerful French 75mm field gun." instructions: 'Pour all the ingredients, except Champagne, into a shaker. Shake well and strain into a Champagne flute. Top up with Champagne. Stir gently.' - garnish: N/A + garnish: null source: 'https://iba-world.com/french-75/' - images: - - copyright: 'Sip and Feast' glass: Champagne method: Shake + abv: 12.8 tags: - 'Contemporary Classics' - 'IBA Cocktail' - Gin ingredients: - + name: Gin amount: 30 units: ml - name: Gin optional: false - + name: 'Lemon juice' amount: 15 units: ml - name: 'Lemon juice' optional: false - + name: 'Simple Syrup' amount: 15 units: ml - name: 'Simple Syrup' optional: false - + name: Champagne amount: 60 units: ml - name: Champagne optional: false + images: + - + resource_path: cocktails/french-75.jpg + copyright: 'Sip and Feast' + placeholder_hash: bggKHwKIiIeeiId9dyd4d3ePxBsGj2IE - name: 'French Connection' description: 'The cocktail is named for the Gene Hackman film of the same name.' instructions: 'Pour all ingredients directly into an old fashioned glass filled with ice cubes. Stir gently.' - garnish: N/A + garnish: null source: 'https://iba-world.com/french-connection/' - images: - - copyright: 'Punch Drink' glass: Lowball method: Build + abv: 29.09 tags: - 'Contemporary Classics' - 'IBA Cocktail' - Brandy ingredients: - + name: Cognac amount: 35 units: ml - name: Cognac optional: false - + name: Amaretto amount: 35 units: ml - name: Amaretto optional: false + images: + - + resource_path: cocktails/french-connection.jpg + copyright: 'Punch Drink' + placeholder_hash: aUkKLwbZ11l/hnWLhad3hoiKdjB5BpMF - name: 'French Martini' - description: |- - It was invented in the 1980s at one of Keith McNally's New York City bars. It next appeared on the drinks menu at McNally's Balthazar in SoHo in 1996. The cocktail was produced during the 1980s–1990s cocktail renaissance. - - It is not a true martini, but is one of many drinks that incorporate the term martini into their names. The key ingredient that makes a martini "French" is Chambord, a black raspberry liqueur that has been produced in France since 1685. + description: "It was invented in the 1980s at one of Keith McNally's New York City bars. It next appeared on the drinks menu at McNally's Balthazar in SoHo in 1996. The cocktail was produced during the 1980s–1990s cocktail renaissance.\n\nIt is not a true martini, but is one of many drinks that incorporate the term martini into their names. The key ingredient that makes a martini \"French\" is Chambord, a black raspberry liqueur that has been produced in France since 1685." instructions: 'Pour all ingredients into cocktail shaker, shake well with ice, strain into chilled cocktail glass.' garnish: 'Squeeze oil from lemon peel onto the drink.' source: 'https://iba-world.com/french-martini/' - images: - - copyright: 'Supergolden Bakes' glass: Fizzio method: Shake + abv: 21.84 tags: - 'IBA Cocktail' - 'New Era Drinks' - Vodka ingredients: - + name: Vodka amount: 45 units: ml - name: Vodka optional: false - + name: Chambord amount: 15 units: ml - name: Chambord optional: false - + name: 'Pineapple juice' amount: 15 units: ml - name: 'Pineapple juice' optional: false + images: + - + resource_path: cocktails/french-martini.jpg + copyright: 'Supergolden Bakes' + placeholder_hash: yAcGDwIHlpZ5o5eFeTbWCceSucPzHyoN +- + name: 'Gin & Tonic' + description: 'Called a “G and T” or gin tonic in some countries, this refreshing drink is made in countries all around the world. The Gin and Tonic was invented in the 1850’s by British soldiers, who mixed gin with their tonic water as a way to drink quinine (which was thought to cure malaria). Tonic water of today no longer has quinine, but the drink stuck around!' + instructions: "1. Add lots of ice to a large cocktail or wine glass and stir to chill the glass. Drain any melted water.\n2. Pour in the gin. Add the garnishes. Pour the tonic water onto a bar spoon into the glass (to increase the bubbles). Stir once and serve." + garnish: 'Any of the following to spice your cocktail: Lime, lemon, cucumber, mint, orange peel, juniper berries, blood orange slice, rosemary' + source: 'https://www.acouplecooks.com/best-gin-and-tonic/' + glass: Wine + method: Build + abv: 12.12 + tags: + - Gin + ingredients: + - + name: Gin + amount: 60 + units: ml + optional: false + - + name: Tonic + amount: 120 + units: ml + optional: false + images: + - + resource_path: cocktails/gin-tonic.jpg + copyright: 'A Couple Cooks' + placeholder_hash: mAgOFwRTWmmgeHdmaVdouHiFmACCOUQP - name: 'Gin Fizz' description: 'A gin fizz is the best-known cocktail in the fizz family. The drink is similar to a Tom Collins, with a possible distinction being a Tom Collins historically used "Old Tom Gin" (a slightly sweeter precursor to London Dry Gin), whereas the kind of gin historically used in a gin fizz is unknown.' - instructions: |- - 1. Shake all ingredients with ice except soda water. - 2. Pour into thin tall Tumbler glass - 3. Top with a splash soda water - 4. Serve without ice + instructions: "1. Shake all ingredients with ice except soda water.\n2. Pour into thin tall Tumbler glass\n3. Top with a splash soda water\n4. Serve without ice" garnish: 'Garnish with a lemon slice, optional lemon zest.' source: 'https://iba-world.com/gin-fizz/' - images: - - copyright: 'A Couple Cooks' glass: Highball method: Shake + abv: 16.94 tags: - 'IBA Cocktail' - 'The Unforgettables' - Gin ingredients: - + name: Gin amount: 45 units: ml - name: Gin optional: false - + name: 'Lemon juice' amount: 30 units: ml - name: 'Lemon juice' optional: false - + name: 'Simple Syrup' amount: 10 units: ml - name: 'Simple Syrup' optional: false - + name: 'Club soda' amount: 1 units: splash - name: 'Club soda' optional: false + images: + - + resource_path: cocktails/gin-fizz.jpg + copyright: 'A Couple Cooks' + placeholder_hash: 3OcNLwh5m4enhndwa4eXd3l4VwJoRYAG +- + name: 'Gin Gimlet' + description: 'The word "gimlet" used in this sense is first attested in 1928. The most obvious derivation is from the tool for drilling small holes, a word also used figuratively to describe something as sharp or piercing. Thus, the cocktail may have been named for its "penetrating" effects on the drinker.' + instructions: "1. Add gin, lime juice, and syrup to a cocktail shaker. Fill with ice and shake until cold.\n2. Strain into glass and top with a splash of soda water, if desired." + garnish: 'Garnish with a lime wheel' + source: 'https://www.acouplecooks.com/gin-gimlet-cocktail/' + glass: Coupe + method: Shake + abv: 21.33 + tags: + - Gin + ingredients: + - + name: Gin + amount: 60 + units: ml + optional: false + - + name: 'Lime juice' + amount: 15 + units: ml + optional: false + - + name: 'Simple Syrup' + amount: 15 + units: ml + optional: false + - + name: 'Club soda' + amount: 1 + units: splash + optional: true + images: + - + resource_path: cocktails/gin-gimlet.jpg + copyright: 'A Couple Cooks' + placeholder_hash: C/gJDwI0l4jChndYbgaa6FaKVQBEVUAI - name: 'Golden Dream' - description: |- - The Golden Dream was popular during the 60s and 70s and originated at the Old King Bar in Miami, mixed by Raimundo Alvarez. - - The cocktail was dedicated to actress Joan Crawford and became quite popular at the end of the 1960s on the east coast of the United States. + description: "The Golden Dream was popular during the 60s and 70s and originated at the Old King Bar in Miami, mixed by Raimundo Alvarez.\n\nThe cocktail was dedicated to actress Joan Crawford and became quite popular at the end of the 1960s on the east coast of the United States. " instructions: 'Pour all ingredients into shaker filled with ice. Shake briskly for few seconds. Strain into chilled cocktail glass.' - garnish: N/A + garnish: null source: 'https://iba-world.com/golden-dream/' - images: - - copyright: 'Liquor.com / Tim Nusog' glass: Coupe method: Shake + abv: 18.81 tags: - 'Contemporary Classics' - 'IBA Cocktail' ingredients: - + name: Galliano amount: 20 units: ml - name: Galliano optional: false - + name: 'Triple Sec' amount: 20 units: ml - name: 'Triple Sec' optional: false - + name: 'Orange juice' amount: 20 units: ml - name: 'Orange juice' optional: false - + name: Cream amount: 10 units: ml - name: Cream optional: false + images: + - + resource_path: cocktails/golden-dream.jpg + copyright: 'Liquor.com / Tim Nusog' + placeholder_hash: dggGDwD2SLV3hoiIZbo4V3h4pYBmDFUG - name: Grasshopper description: "A Grasshopper is a sweet, mint-flavored, after-dinner drink. The name of the drink derives from its green color, which comes from crème de menthe. A bar in the French Quarter of New Orleans, Louisiana, Tujague's, claims the drink was invented in 1918 by its owner, Philip Guichet. The drink gained popularity during the 1950s and 1960s throughout the American South." - instructions: |- - Pour all ingredients into shaker filled with ice. - Shake briskly for few seconds. Strain into chilled cocktail glass. - garnish: 'N/A, optional mint leaf' + instructions: "Pour all ingredients into shaker filled with ice.\nShake briskly for few seconds. Strain into chilled cocktail glass." + garnish: 'Optional mint leaf' source: 'https://iba-world.com/grasshopper/' - images: - - copyright: 'Liquor.com / Tim Nusog' glass: Fizzio method: Shake + abv: 13.33 tags: - 'Contemporary Classics' - 'IBA Cocktail' ingredients: - + name: 'White Crème de Cacao' amount: 20 units: ml - name: 'White Crème de Cacao' optional: false - + name: 'Menthe Crème de Cacao' amount: 20 units: ml - name: 'Menthe Crème de Cacao' optional: false - + name: Cream amount: 20 units: ml - name: Cream optional: false + images: + - + resource_path: cocktails/grasshopper.jpg + copyright: 'Liquor.com / Tim Nusog' + placeholder_hash: 0SgKDwIIqIiPhnaZdyeYOJd1pvWb+70N - name: 'Hanky Panky' - description: |- - The Hanky-Panky was the brainchild of Ada Coleman (known as "Coley") who began as a bartender at the Savoy Hotel in 1903. Her benefactor was Rupert D'Oyly Carte, a member of the family that first produced Gilbert and Sullivan operas in London and that built the Savoy Hotel. When Rupert became chairman of the Savoy, Ada was given a position at the hotel's American Bar, where she eventually became the head bartender and made cocktails for the likes of Mark Twain, the Prince of Wales, Prince Wilhelm of Sweden, and Sir Charles Hawtrey. - - Coleman created the Hanky-Panky for Hawtrey. He was a Victorian and Edwardian actor who mentored Noël Coward. + description: "The Hanky-Panky was the brainchild of Ada Coleman (known as \"Coley\") who began as a bartender at the Savoy Hotel in 1903. Her benefactor was Rupert D'Oyly Carte, a member of the family that first produced Gilbert and Sullivan operas in London and that built the Savoy Hotel. When Rupert became chairman of the Savoy, Ada was given a position at the hotel's American Bar, where she eventually became the head bartender and made cocktails for the likes of Mark Twain, the Prince of Wales, Prince Wilhelm of Sweden, and Sir Charles Hawtrey.\n\nColeman created the Hanky-Panky for Hawtrey. He was a Victorian and Edwardian actor who mentored Noël Coward. " instructions: 'Pour all ingredients into mixing glass with ice cubes. Stir well. Strain into chilled cocktail glass.' garnish: 'Orange zest.' source: 'https://iba-world.com/hanky-panky/' - images: - - copyright: 'Punch / Daniel Krieger' glass: 'Nick and Nora' method: Stir + abv: 24.81 tags: - 'IBA Cocktail' - 'The Unforgettables' - Gin ingredients: - + name: Gin amount: 45 units: ml - name: Gin optional: false - + name: 'Sweet Vermouth' amount: 45 units: ml - name: 'Sweet Vermouth' optional: false - + name: 'Fernet Branca' amount: 7.5 units: ml - name: 'Fernet Branca' optional: false + images: + - + resource_path: cocktails/hanky-panky.jpg + copyright: 'Punch / Daniel Krieger' + placeholder_hash: NQgKDwL0SHeEfIeHZtk4t1iIVrBlB2wF - name: 'Hemingway Special' description: 'Ernest Hemingway, who stayed in Cuba, tried the Floridita''s signature drink, the Floridita Daiquiri, and said "That''s good but I prefer it without sugar and double rum," which became a cocktail now known as the Hemingway Daiquiri or the Papa Doble. This recipe was later modified further, adding grapefruit juice to the mix, at which point the drink was dubbed the "Hemingway Special".' instructions: 'Pour all ingredients into a shaker with ice. Shake well and strain into a large cocktail glass.' - garnish: N/A + garnish: null source: 'https://iba-world.com/hemingway-special/' - images: - - copyright: 'Liquor.com / Tim Nusog' glass: Coupe method: Shake + abv: 17.72 tags: - 'Contemporary Classics' - 'IBA Cocktail' - Rum ingredients: - + name: 'Dark Rum' amount: 60 units: ml - name: 'Dark Rum' optional: false - + name: 'Grapefruit juice' amount: 40 units: ml - name: 'Grapefruit juice' optional: false - + name: Maraschino amount: 15 units: ml - name: Maraschino optional: false - + name: 'Lime juice' amount: 15 units: ml - name: 'Lime juice' optional: false + images: + - + resource_path: cocktails/hemingway-special.jpg + copyright: 'Liquor.com / Tim Nusog' + placeholder_hash: MggGFwJ1WHdUhnmAl5hmaIh3l/B5CI0E - name: 'Horse’s Neck' description: 'Dating back to the 1890s, it was a non-alcoholic mixture of ginger ale, ice and lemon peel. By the 1910s, brandy, or bourbon would be added for a "Horse''s Neck with a Kick" or a "Stiff Horse''s Neck". The non-alcoholic version was still served in upstate New York in the late 1950s and early 60s, but eventually it was phased out.' - instructions: |- - 1. Pour Cognac and ginger ale directly into a highball glass with ice cubes. - 2. Stir gently. - 3. If preferred, add dashes of Angostura Bitter. + instructions: "1. Pour Cognac and ginger ale directly into a highball glass with ice cubes.\n2. Stir gently.\n3. If preferred, add dashes of Angostura Bitter." garnish: 'Garnish with a rind of one lemon spiral.' source: 'https://iba-world.com/horses-neck/' - images: - - copyright: 'Liquor.com / Tim Nusog' glass: Highball method: Build + abv: 9.21 tags: - 'Contemporary Classics' - 'IBA Cocktail' - Brandy ingredients: - + name: Cognac amount: 40 units: ml - name: Cognac optional: false - + name: 'Ginger beer' amount: 120 units: ml - name: 'Ginger beer' optional: false - + name: 'Angostura aromatic bitters' amount: 1 units: dash - name: 'Angostura aromatic bitters' optional: false + images: + - + resource_path: cocktails/horses-neck.jpg + copyright: 'Liquor.com / Tim Nusog' + placeholder_hash: uQgKLwL4N5Z5h4eJdnh4t2aHiJBXCXkG - name: Illegal description: null instructions: "Pour all ingredients into the shaker. Shake vigorously with ice.\_ Strain into a chilled cocktail glass, or “on the rocks” in a traditional clay or terracotta mug." - garnish: N/A + garnish: null source: 'https://iba-world.com/illegal/' - images: - - copyright: 'The Shaken Cocktail' glass: Fizzio method: Shake + abv: 16.12 tags: - 'IBA Cocktail' - 'New Era Drinks' - Tequila ingredients: - + name: Mezcal amount: 30 units: ml - name: Mezcal optional: false - + name: 'White Rum' amount: 15 units: ml - name: 'White Rum' optional: false - + name: Falernum amount: 15 units: ml - name: Falernum optional: false - + name: Maraschino amount: 1 units: teaspoon - name: Maraschino optional: false - + name: 'Lime juice' amount: 22.5 units: ml - name: 'Lime juice' optional: false - + name: 'Simple Syrup' amount: 15 units: ml - name: 'Simple Syrup' optional: false - + name: 'Egg White' amount: 1 units: drops - name: 'Egg White' optional: true + images: + - + resource_path: cocktails/illegal.jpg + copyright: 'The Shaken Cocktail' + placeholder_hash: XvgJFwIraXifhId1h0eHWId1VtEHB58D - name: 'Irish Coffee' description: 'Caffeinated alcoholic drink consisting of Irish whiskey, hot coffee, and sugar, stirred, and topped with cream (sometimes cream liqueur) The coffee is drunk through the cream.' - instructions: |- - 1. Warm black coffee is poured into a pre-heated Irish coffee glass. - 2. Whiskey and at least one teaspoon of sugar is added and stirred until dissolved. - 3. Fresh thick chilled cream is carefully poured over the back of a spoon held just above the surface of the coffee. - 4. The layer of cream will float on the coffee without mixing. - 5. Plain sugar can be replaced with sugar syrup - garnish: N/A + instructions: "1. Warm black coffee is poured into a pre-heated Irish coffee glass.\n2. Whiskey and at least one teaspoon of sugar is added and stirred until dissolved.\n3. Fresh thick chilled cream is carefully poured over the back of a spoon held just above the surface of the coffee.\n4. The layer of cream will float on the coffee without mixing.\n5. Plain sugar can be replaced with sugar syrup" + garnish: null source: 'https://iba-world.com/irish-coffee/' - images: - - copyright: VinePair - glass: Glass mug + glass: 'Glass mug' method: Build + abv: 8.26 tags: - 'Contemporary Classics' - 'IBA Cocktail' - Whiskey ingredients: - + name: 'Irish whiskey' amount: 50 units: ml - name: 'Irish whiskey' optional: false - + name: Coffee amount: 120 units: ml - name: Coffee optional: false - + name: Cream amount: 50 units: ml - name: Cream optional: false - + name: Sugar amount: 1 units: teaspoon - name: Sugar optional: false + images: + - + resource_path: cocktails/irish-coffee.jpg + copyright: VinePair + placeholder_hash: yPcJDwIIuHeJhXh5d0ioJ8hzlvZ4WJ8I +- + name: 'Japanese cocktail' + description: "The Japanese cocktail is notorious for a handful of things: it's a very old cocktail, published in Jerry Thomas’s landmark 1862 book How to Mix Drinks; it was the first mixed drink to be named something other than “Whiskey Cocktail”, “Brandy Cocktail”, or some other similarly obvious epithet; it might be the only cocktail Thomas invented himself; and finally, it was the first cocktail to feature more than a dash of a fancy sweetener, orgeat." + instructions: "1. Combine all ingredients in a mixing glass with ice and stir\n2. Strain into a coupe, serve up" + garnish: 'Lemon peel' + source: null + glass: Coupe + method: Stir + abv: 26.83 + tags: + - Brandy + ingredients: + - + name: Brandy + amount: 60 + units: ml + optional: false + - + name: 'Orgeat Syrup' + amount: 15 + units: ml + optional: false + - + name: 'Angostura aromatic bitters' + amount: 2 + units: dashes + optional: false + images: + - + resource_path: cocktails/japanese-cocktail.jpg + copyright: 'The Drink Blog' + placeholder_hash: HQgWDwJoiHdwh3h6eIeIR4d5OKBnAnoG - name: 'John Collins' description: "A John Collins is a cocktail which was attested in 1869, but may be older. It is believed to have originated with a headwaiter of that name who worked at Limmer's Old House in Conduit Street in Mayfair, which was a popular London hotel and coffee house around 1790–1817." - instructions: |- - Pour all ingredients directly into a highball filled with ice. Stir gently. - - NOTE: - - Use ‘Old Tom’ Gin for Tom Collins. + instructions: "Pour all ingredients directly into a highball filled with ice. Stir gently.\n\nNOTE:\n\nUse ‘Old Tom’ Gin for Tom Collins." garnish: 'Garnish with a lemon slice and maraschino cherry.' source: 'https://iba-world.com/john-collins/' - images: - - copyright: 'A Couple Cooks' glass: Highball method: Build + abv: 10.91 tags: - 'IBA Cocktail' - 'The Unforgettables' - Gin ingredients: - + name: Gin amount: 45 units: ml - name: Gin optional: false - + name: 'Lemon juice' amount: 30 units: ml - name: 'Lemon juice' optional: false - + name: 'Simple Syrup' amount: 15 units: ml - name: 'Simple Syrup' optional: false - + name: 'Club soda' amount: 60 units: ml - name: 'Club soda' optional: false + images: + - + resource_path: cocktails/john-collins.jpg + copyright: 'A Couple Cooks' + placeholder_hash: 0fcNJwQMyHhSeIiXilWHeHeYVzCXBHMJ - name: KIR description: 'In France it is usually drunk as an apéritif before a meal or snack. It was originally made with Bourgogne Aligoté, a white wine of Burgundy, but today various white wines are used throughout France, according to the region and the barkeeper. Many prefer a white Chardonnay-based Burgundy, such as Chablis.' - instructions: |- - Pour Crème de cassis into glass, top up with white wine. - - Note: - - KIR ROYAL – Use Champagne instead of white wine - garnish: N/A + instructions: "Pour Crème de cassis into glass, top up with white wine.\n\nNote:\n\nKIR ROYAL – Use Champagne instead of white wine" + garnish: null source: 'https://iba-world.com/kir/' - images: - - copyright: 'Liquor.com / Tim Nusog' glass: Champagne method: Build + abv: 11.27 tags: - 'Contemporary Classics' - 'IBA Cocktail' - Wine ingredients: - + name: 'White wine' amount: 90 units: ml - name: 'White wine' optional: false - + name: 'Crème de cassis (blackcurrant liqueur)' amount: 10 units: ml - name: 'Crème de cassis (blackcurrant liqueur)' optional: false + images: + - + resource_path: cocktails/kir.jpg + copyright: 'Liquor.com / Tim Nusog' + placeholder_hash: ywcKDwJoh3iQd4h/hyiHyHh1h/NoCI8K +- + name: 'La Louisiane' + description: 'The La Louisiane cocktail is an improvement on the Sazerac! Absinthe, rye whiskey and vermouth make this spirit-forward cocktail a stunner.' + instructions: 'Add all ingredients to a cocktail mixing glass (or any other type of glass). Fill the mixing glass with 1 handful ice and stir continuously for 30 seconds until very cold.' + garnish: 'Garnish with a Luxardo cherry.' + source: 'https://www.acouplecooks.com/la-louisiane-cocktail/' + glass: Cocktail + method: Stir + abv: 28.94 + tags: + - Whiskey + ingredients: + - + name: 'Rye whiskey' + amount: 60 + units: ml + optional: false + - + name: 'Sweet Vermouth' + amount: 30 + units: ml + optional: false + - + name: Bénédictine + amount: 30 + units: ml + optional: false + - + name: Absinthe + amount: 5 + units: ml + optional: false + - + name: 'Peychauds Bitters' + amount: 3 + units: dashes + optional: false + images: + - + resource_path: cocktails/la-louisiane.jpg + copyright: 'A couple cooks' + placeholder_hash: mhgWHwSXeIiAdnd0h5iYh3d3dgHICIQL - name: 'Last word' description: 'While the drink eventually fell out of favor, it enjoyed a renewed popularity after being rediscovered by the bartender Murray Stenson in 2004 during his tenure at the Zig Zag Café and becoming a cult hit in the Seattle area.' instructions: 'Add all ingredients into a cocktail shaker. Shake with ice and strain into a chilled cocktail glass.' - garnish: N/A + garnish: null source: 'https://iba-world.com/last-word/' - images: - - copyright: 'A Serious Eats / Vicky Wasik' - glass: Nick and Nora + glass: 'Nick and Nora' method: Shake + abv: 25.4 tags: - 'IBA Cocktail' - 'The Unforgettables' - Gin ingredients: - + name: Gin amount: 22.5 units: ml - name: Gin optional: false - + name: 'Green Chartreuse' amount: 22.5 units: ml - name: 'Green Chartreuse' optional: false - + name: Maraschino amount: 22.5 units: ml - name: Maraschino optional: false - + name: 'Lime juice' amount: 22.5 units: ml - name: 'Lime juice' optional: false + images: + - + resource_path: cocktails/last-word.jpg + copyright: 'A Serious Eats / Vicky Wasik' + placeholder_hash: VAgKDwINtoqnd4dziie3eIiItzoNcaAN - name: 'Lemon drop Martini' description: "The drink was invented sometime in the 1970s by Norman Jay Hobday, the founder and proprietor of Henry Africa's bar in San Francisco, California. Some variations of the drink exist, such as blueberry and raspberry lemon drops. It is served at some bars and restaurants in the United States, and in such establishments in other areas of the world." instructions: 'Pour all ingredients into cocktail shaker, shake well with ice, strain into chilled cocktail glass.' garnish: 'Garnish with sugar rim around the glass.' source: 'https://iba-world.com/lemon-drop-martini/' - images: - - copyright: 'Basil & Bubbly' glass: Cocktail method: Shake + abv: 24.62 tags: - 'IBA Cocktail' - 'New Era Drinks' - Vodka ingredients: - + name: Vodka amount: 30 units: ml - name: Vodka optional: false - substitutes: ['Vodka Citron'] + substitutes: + - 'Vodka Citron' - + name: 'Triple Sec' amount: 20 units: ml - name: 'Triple Sec' optional: false - + name: 'Lemon juice' amount: 15 units: ml - name: 'Lemon juice' optional: false + images: + - + resource_path: cocktails/lemon-drop-martini.jpg + copyright: 'Basil & Bubbly' + placeholder_hash: mxgKDwI5iHhhmIeaiGamB5mZCQC1hXAJ - name: 'Long Island Ice Tea' description: 'The cocktail has been criticized for its large number of ingredients, making it cumbersome to prepare in busy bars. It is considered a favorite of university students in the United States and it has thus garnered negative connotations as "an act of mixological atrocity favored by college students and wastrels", in the words of one food critic.' - instructions: |- - Add all ingredients into a highball glass filled with ice. - Stir gently. + instructions: "Add all ingredients into a highball glass filled with ice.\nStir gently." garnish: 'Lemon Slice (Optional)' source: 'https://iba-world.com/long-island-ice-tea/' - images: - - copyright: 'Kitchen Stories' glass: Highball method: Build + abv: 21.82 tags: - 'Contemporary Classics' - 'IBA Cocktail' ingredients: - + name: Vodka amount: 15 units: ml - name: Vodka optional: false - + name: Tequila amount: 15 units: ml - name: Tequila optional: false - + name: 'White Rum' amount: 15 units: ml - name: 'White Rum' optional: false - + name: Gin amount: 15 units: ml - name: Gin optional: false - + name: Cointreau amount: 15 units: ml - name: Cointreau optional: false - + name: 'Lemon juice' amount: 30 units: ml - name: 'Lemon juice' optional: false - + name: 'Simple Syrup' amount: 20 units: ml - name: 'Simple Syrup' optional: false - + name: Cola amount: 1 units: topup - name: Cola optional: false + images: + - + resource_path: cocktails/long-island-ice-tea.jpg + copyright: 'Kitchen Stories' + placeholder_hash: TBgGHwQF64asp3mnbmXKdoiJuYA3CWUD - name: Mai-Tai description: "Victor J. Bergeron claimed to have invented the Mai Tai in 1944 at his restaurant, Trader Vic's, in Oakland, California, US. Trader Vic's forerunner, Donn Beach, claimed to have instead first created it in 1933, although a longtime colleague said that Beach was actually just alleging that the Mai Tai was based on his Q.B. Cooler cocktail." - instructions: |- - 1. Add all ingredients into a shaker with ice. - 2. Shake and pour into a double rocks glass or a highball glass. - - *The Martinique molasses rum used by Trader Vic was not an Agricole rum but a type of “rummy” from molasses.* + instructions: "1. Add all ingredients into a shaker with ice.\n2. Shake and pour into a double rocks glass or a highball glass.\n\n*The Martinique molasses rum used by Trader Vic was not an Agricole rum but a type of “rummy” from molasses.*" garnish: 'Garnish with pineapple spear, mint leaves, and lime peel.' source: 'https://iba-world.com/mai-tai/' - images: - - copyright: 'A Couple Cooks' glass: Lowball method: Shake + abv: 18.82 tags: - 'Contemporary Classics' - 'IBA Cocktail' @@ -1576,414 +2163,453 @@ - Tiki ingredients: - + name: 'White Rum' amount: 30 units: ml - name: 'White Rum' optional: false - + name: 'Rhum agricole' amount: 30 units: ml - name: 'Rhum agricole' optional: false - + name: 'Orange Curaçao' amount: 15 units: ml - name: 'Orange Curaçao' optional: false - + name: 'Orgeat Syrup' amount: 15 units: ml - name: 'Orgeat Syrup' optional: false - + name: 'Lime juice' amount: 30 units: ml - name: 'Lime juice' optional: false - + name: 'Simple Syrup' amount: 7.5 units: ml - name: 'Simple Syrup' optional: false + images: + - + resource_path: cocktails/mai-tai.jpg + copyright: 'A Couple Cooks' + placeholder_hash: LgkKVwj1V3mraIdVdbiJeFaKiEBXCHAG - name: Manhattan description: 'Popular history suggests that the drink originated at the Manhattan Club in New York City in the mid-1870s, where it was invented by Iain Marshall for a banquet hosted by Jennie Jerome (Lady Randolph Churchill, mother of Winston) in honor of presidential candidate Samuel J. Tilden. The success of the banquet made the drink fashionable, later prompting several people to request the drink by referring to the name of the club where it originated—"the Manhattan cocktail". However, Lady Randolph was in France at the time and pregnant, so the story is likely a fiction.' instructions: 'Pour all ingredients into mixing glass with ice cubes. Stir well. Strain into a chilled cocktail glass.' garnish: 'Garnish with a cocktail cherry.' source: 'https://iba-world.com/manhattan/' - images: - - copyright: "Sainsbury's Magazine / Toby Scott" glass: Cocktail method: Stir + abv: 28.17 tags: - 'IBA Cocktail' - 'The Unforgettables' - Whiskey ingredients: - + name: 'Rye whiskey' amount: 50 units: ml - name: 'Rye whiskey' optional: false - + name: 'Sweet Vermouth' amount: 20 units: ml - name: 'Sweet Vermouth' optional: false - + name: 'Angostura aromatic bitters' amount: 1 units: dash - name: 'Angostura aromatic bitters' optional: false + images: + - + resource_path: cocktails/manhattan.jpg + copyright: "Sainsbury's Magazine / Toby Scott" + placeholder_hash: pvcJDwIObYVYiYZoV5lppneHd9A5CJ0D - name: Margarita description: 'The drink is served shaken with ice (on the rocks), blended with ice (frozen margarita), or without ice (straight up). The drink is generally served in a stepped-diameter variant of a cocktail glass or champagne coupe called a margarita glass.' - instructions: |- - Add all ingredients into a shaker with ice. - Shake and strain into a chilled cocktail glass + instructions: "Add all ingredients into a shaker with ice.\nShake and strain into a chilled cocktail glass" garnish: 'Half salt rim (Optional)' source: 'https://iba-world.com/margarita/' - images: - - copyright: 'Jamie Oliver' glass: Margarita method: Shake + abv: 26.35 tags: - 'Contemporary Classics' - 'IBA Cocktail' - Tequila ingredients: - + name: Tequila amount: 50 units: ml - name: Tequila optional: false - + name: 'Triple Sec' amount: 20 units: ml - name: 'Triple Sec' optional: false - + name: 'Lime juice' amount: 15 units: ml - name: 'Lime juice' optional: false + images: + - + resource_path: cocktails/margarita.jpg + copyright: 'Jamie Oliver' + placeholder_hash: VQgOFwIYh3ePdXaLh2aXeId3R/s2uWkA - name: Martinez - description: |- - The Martinez is a classic cocktail that is widely regarded as the direct precursor to the Martini. - It serves as the basis for many modern cocktails, and several different versions of the original exist. - These are generally distinguished by the accompaniment of either Maraschino or Curacao, as well as differences in gin or bitters. + description: "The Martinez is a classic cocktail that is widely regarded as the direct precursor to the Martini.\nIt serves as the basis for many modern cocktails, and several different versions of the original exist.\nThese are generally distinguished by the accompaniment of either Maraschino or Curacao, as well as differences in gin or bitters. " instructions: 'Pour all ingredients into mixing glass with ice cubes. Stir well. Strain into a chilled cocktail glass.' garnish: 'Lemon zest.' source: 'https://iba-world.com/martinez/' - images: - - copyright: Wikipedia glass: Cocktail method: Stir + abv: 24.16 tags: - 'IBA Cocktail' - 'The Unforgettables' - Gin ingredients: - + name: Gin amount: 45 units: ml - name: Gin optional: false - + name: 'Sweet Vermouth' amount: 45 units: ml - name: 'Sweet Vermouth' optional: false - + name: Maraschino amount: 1 units: teaspoon - name: Maraschino optional: false - + name: 'Orange bitters' amount: 2 units: dashes - name: 'Orange bitters' optional: false + images: + - + resource_path: cocktails/martinez.jpg + copyright: Wikipedia + placeholder_hash: n1kWFwZ4eIeAd3d4iId4aIh2qMgJiZ8B - name: 'Mary Pickford' description: null instructions: 'Pour all ingredients into a cocktail shaker, shake well with ice, strain into a chilled cocktail glass.' - garnish: N/A + garnish: null source: 'https://iba-world.com/mary-pickford/' - images: - - copyright: Drinkoteket glass: Fizzio method: Shake + abv: 15.92 tags: - 'IBA Cocktail' - 'The Unforgettables' - Rum ingredients: - + name: 'White Rum' amount: 45 units: ml - name: 'White Rum' optional: false - + name: 'Pineapple juice' amount: 45 units: ml - name: 'Pineapple juice' optional: false - + name: Maraschino amount: 7.5 units: ml - name: Maraschino optional: false - + name: 'Grenadine Syrup' amount: 5 units: ml - name: 'Grenadine Syrup' optional: false + images: + - + resource_path: cocktails/mary-pickford.jpg + copyright: Drinkoteket + placeholder_hash: zBcGFwQIqJiMhodxjAbHeIiLeqAHB4sA - name: Mimosa description: null - instructions: |- - Pour orange juice into a flute glass and gently pour the sparkling wine. Stir gently. - - Note: - Also known as Buck’s Fizz. + instructions: "Pour orange juice into a flute glass and gently pour the sparkling wine. Stir gently.\n\nNote:\nAlso known as Buck’s Fizz." garnish: 'Garnish with an orange twist (optional).' source: 'https://iba-world.com/mimosa/' - images: - - copyright: 'Inspired Taste' glass: Champagne method: Build + abv: 5 tags: - 'Contemporary Classics' - 'IBA Cocktail' - Wine ingredients: - + name: Prosecco amount: 75 units: ml - name: Prosecco optional: false - + name: 'Orange juice' amount: 75 units: ml - name: 'Orange juice' optional: false + images: + - + resource_path: cocktails/mimosa.jpg + copyright: 'Inspired Taste' + placeholder_hash: LyoGPwaGWMcyeIdfiUh7eXeH6PItd/oA - name: 'Mint Julep' description: null instructions: 'In Julep Stainless Steel Cup gently muddle the mint with sugar and water. Fill the glass with cracked ice, add the Bourbon and stir well until the cup frosts.' garnish: 'Garnish with a mint sprig.' source: 'https://iba-world.com/mint-julep/' - images: - - copyright: 'Liquor.com / Tim Nusog' glass: Julep method: Build + abv: 36.36 tags: - 'Contemporary Classics' - 'IBA Cocktail' - Whiskey ingredients: - + name: 'Bourbon Whiskey' amount: 60 units: ml - name: 'Bourbon Whiskey' optional: false - + name: Mint amount: 4 units: sprigs - name: Mint optional: false - + name: Sugar amount: 1 units: tsp - name: Sugar optional: false - + name: Water amount: 2 units: tsp - name: Water optional: false + images: + - + resource_path: cocktails/mint-julep.jpg + copyright: 'Liquor.com / Tim Nusog' + placeholder_hash: 0jgKJwRGe8VgZoiaiiiHhpeXd1BYBXED - name: Mojito description: null instructions: 'Mix mint springs with sugar and lime juice. Add splash of soda water and fill the glass with ice. Pour the rum and top with soda water. Light stir to involve all ingredients.' garnish: 'Garnish with sprigs of mint and slice of lime.' source: 'https://iba-world.com/mojito/' - images: - - copyright: 'Bon Apetite' glass: Highball method: Build + abv: 25.17 tags: - 'Contemporary Classics' - 'IBA Cocktail' - Rum ingredients: - + name: 'White Rum' amount: 45 units: ml - name: 'White Rum' optional: false - + name: 'Lime juice' amount: 20 units: ml - name: 'Lime juice' optional: false - + name: Mint amount: 6 units: sprigs - name: Mint optional: false - + name: Sugar amount: 2 units: tsp - name: Sugar optional: false - + name: 'Club soda' amount: 1 units: topup - name: 'Club soda' optional: false + images: + - + resource_path: cocktails/mojito.jpg + copyright: 'Bon Apetite' + placeholder_hash: MhkGFwB+WZaZf6RjaURa55hGDYp09hkH - name: 'Monkey Gland' description: null instructions: 'Pour all ingredients into a cocktail shaker, shake well with ice, strain into a chilled cocktail glass.' - garnish: N/A + garnish: null source: 'https://iba-world.com/monkey-gland/' - images: - - copyright: 'The Spruce' glass: Cocktail method: Shake + abv: 16 tags: - 'IBA Cocktail' - 'The Unforgettables' - Gin ingredients: - + name: Gin amount: 45 units: ml - name: Gin optional: false - + name: 'Orange juice' amount: 45 units: ml - name: 'Orange juice' optional: false - + name: Absinthe amount: 1 units: tablespoon - name: Absinthe optional: false - + name: 'Grenadine Syrup' amount: 1 units: tablespoon - name: 'Grenadine Syrup' optional: false + images: + - + resource_path: cocktails/monkey-gland.jpg + copyright: 'The Spruce' + placeholder_hash: sRgKFwJXiplwaHead2aGeYiIdmYMaLcA - name: 'Moscow Mule' description: null instructions: 'In a Mule Cup or rocks glass, combine the vodka and ginger beer. Add lime juice and gently stir to involve all ingredients.' garnish: 'Garnish with a lime slice' source: 'https://iba-world.com/moscow-mule/' - images: - - copyright: 'Bake It With Love' glass: 'Copper mug' method: Build + abv: 9.35 tags: - 'Contemporary Classics' - 'IBA Cocktail' - Vodka ingredients: - + name: Vodka amount: 45 units: ml - name: Vodka optional: false - + name: 'Ginger beer' amount: 120 units: ml - name: 'Ginger beer' optional: false - + name: 'Lime juice' amount: 10 units: ml - name: 'Lime juice' optional: false + images: + - + resource_path: cocktails/moscow-mule.jpg + copyright: 'Bake It With Love' + placeholder_hash: JAkGFwigP4bhlYdpiui3pmW3t0EIFpAD - name: 'Naked and Famous' description: null instructions: 'Pour all ingredients into cocktail shaker, shake well with ice, strain into chilled cocktail glass.' - garnish: N/A + garnish: null source: 'https://iba-world.com/naked-and-famous/' - images: - - copyright: Epicurious glass: 'Nick and Nora' method: Shake + abv: 18.2 tags: - 'IBA Cocktail' - 'New Era Drinks' - Tequila ingredients: - + name: Mezcal amount: 22.5 units: ml - name: Mezcal optional: false - + name: 'Yellow Chartreuse' amount: 22.5 units: ml - name: 'Yellow Chartreuse' optional: false - + name: Aperol amount: 22.5 units: ml - name: Aperol optional: false - + name: 'Lime juice' amount: 22.5 units: ml - name: 'Lime juice' optional: false + images: + - + resource_path: cocktails/naked-and-famous.jpg + copyright: Epicurious + placeholder_hash: SwgKHwQHtnmIhXiIdoinSJeVh9BoCI4G - name: Negroni description: 'A traditionally made Negroni is stirred, not shaken; it is built over ice in an old-fashioned or rocks glass and garnished with a slice of orange. Outside of Italy, an orange peel is often used in place of an orange slice.' instructions: 'Pour all ingredients directly into a chilled old fashioned glass filled with ice, Stir gently.' garnish: 'Garnish with a half orange slice.' source: 'https://iba-world.com/negroni/' - images: - - copyright: 'Kitchen Swagger' glass: Lowball method: Build + abv: 25.15 tags: - 'IBA Cocktail' - 'The Unforgettables' - Gin ingredients: - + name: Gin amount: 30 units: ml - name: Gin optional: false - + name: Campari amount: 30 units: ml - name: Campari optional: false - + name: 'Sweet Vermouth' amount: 30 units: ml - name: 'Sweet Vermouth' optional: false + images: + - + resource_path: cocktails/negroni.jpg + copyright: 'Kitchen Swagger' + placeholder_hash: 4jgGHwa8FpkhYKvMl0vHaSR3TIcHNnIA - name: 'New York Sour' description: 'Largely similar to the whiskey sour, the New York sour adds a float of dry red wine to the drink.' instructions: "Pour all ingredients into the shaker. Shake vigorously with ice.\_ Strain into a chilled rocks glass filled with ice. Float the wine on top." garnish: 'Garnish with lemon or orange zest with cherry.' source: 'https://iba-world.com/new-york-sour/' - images: - - copyright: 'Kitchen Swagger' glass: Lowball method: Shake + abv: 16.09 tags: - 'IBA Cocktail' - 'New Era Drinks' @@ -1991,269 +2617,288 @@ - Sour ingredients: - + name: Whiskey amount: 60 units: ml - name: Whiskey optional: false - + name: 'Simple Syrup' amount: 22.5 units: ml - name: 'Simple Syrup' optional: false - + name: 'Lemon juice' amount: 30 units: ml - name: 'Lemon juice' optional: false - + name: 'Egg White' amount: 1 units: drops - name: 'Egg White' optional: true - + name: 'Red wine' amount: 15 units: ml - name: 'Red wine' optional: false + images: + - + resource_path: cocktails/new-york-sour.jpg + copyright: 'Kitchen Swagger' + placeholder_hash: ZhgOFwJ4hoh/iHeIiJhniIeFaEEIJoUA - name: 'Old Cuban' description: null instructions: 'Pour all ingredients into cocktail shaker except the wine, shake well with ice, strain into chilled elegant cocktail glass. Top up with the sparkling wine.' garnish: 'Garnish with mint springs.' source: 'https://iba-world.com/old-cuban/' - images: - - copyright: 'NYT Cooking' glass: Coupe method: Shake + abv: 12.97 tags: - 'IBA Cocktail' - 'New Era Drinks' - Rum ingredients: - + name: 'Dark Rum' amount: 45 units: ml - name: 'Dark Rum' optional: false - + name: Mint amount: 6 units: sprigs - name: Mint optional: false - + name: 'Lime juice' amount: 22.5 units: ml - name: 'Lime juice' optional: false - + name: 'Simple Syrup' amount: 30 units: ml - name: 'Simple Syrup' optional: false - + name: 'Angostura aromatic bitters' amount: 2 units: dashes - name: 'Angostura aromatic bitters' optional: false - + name: Champagne amount: 60 units: ml - name: Champagne optional: false + images: + - + resource_path: cocktails/old-cuban.jpg + copyright: 'NYT Cooking' + placeholder_hash: yxgOLwQHiHh9dId2iTiXaId3Z9A3F3sA - name: 'Old Fashioned' description: "Developed during the 19th century and given its name in the 1880s, it is an IBA Official Cocktail. It is also one of six basic drinks listed in David A. Embury's The Fine Art of Mixing Drinks. " instructions: 'Place sugar cube in old fashioned glass and saturate with bitter, add few dashes of plain water. Muddle until dissolved. Fill the glass with ice cubes and add whiskey. Stir gently.' garnish: 'Garnish with an orange slice or zest, and a cocktail cherry.' source: 'https://iba-world.com/old-fashioned/' - images: - - copyright: Epicurious glass: Lowball method: Build + abv: 35.55 tags: - 'IBA Cocktail' - 'The Unforgettables' - Whiskey ingredients: - + name: 'Bourbon Whiskey' amount: 45 units: ml - name: 'Bourbon Whiskey' optional: false - + name: Sugar amount: 1 units: cube - name: Sugar optional: false - + name: 'Angostura aromatic bitters' amount: 2 units: dashes - name: 'Angostura aromatic bitters' optional: false - + name: Water amount: 2 units: dashes - name: Water optional: false + images: + - + resource_path: cocktails/old-fashioned.jpg + copyright: Epicurious + placeholder_hash: bzkGJwbxeBWfd2ZecmibZphotlEIGoUA - name: Paloma description: 'The Paloma is a refreshing, easy-to-make cooler that combines tequila, lime juice and grapefruit soda. Its origin story is nebulous, but most reports peg its creation to the 1950s. Blanco tequila is the traditional choice, but lightly aged reposado also makes a fine drink. In this case, it’s best to keep the añejo capped, as the well-aged expression’s oaky profile disrupts that clean, refreshing taste you want in a Paloma. ' instructions: 'Poor the tequila into a highball glass, squeeze the lime juice. Add ice and salt, fill up pink grapefruit soda. Stir gently.' garnish: 'Garnish with a slice of lime.' source: 'https://iba-world.com/paloma/' - images: - - copyright: 'Love and Lemons' glass: Lowball method: Build + abv: 11.73 tags: - 'IBA Cocktail' - 'New Era Drinks' - Tequila ingredients: - + name: Tequila amount: 50 units: ml - name: Tequila optional: false - + name: 'Lime juice' amount: 5 units: ml - name: 'Lime juice' optional: false - + name: Salt amount: 1 units: pinch - name: Salt optional: false - + name: 'Grapefruit juice' amount: 100 units: ml - name: 'Grapefruit juice' optional: false + images: + - + resource_path: cocktails/paloma.jpg + copyright: 'Love and Lemons' + placeholder_hash: rhgGHwRqNHahd5eQeUaYyHmHGXQEQDgB - name: 'Paper Plane' description: "Developed around 2007 by Sasha Petraske and Sam Ross of Milk & Honey for their former colleague Toby Maloney's Chicago bar The Violet Hour. The recipe is a riff on a Last Word, which is a riff on the classic Corpse Reviver #2." instructions: 'Pour all ingredients into cocktail shaker, shake well with ice, strain into chilled cocktail glass.' - garnish: N/A + garnish: null source: 'https://iba-world.com/paper-plane/' - images: - - copyright: 'Bon Apetite' glass: Coupe method: Shake + abv: 17.2 tags: - 'IBA Cocktail' - 'New Era Drinks' - Whiskey ingredients: - + name: 'Bourbon Whiskey' amount: 30 units: ml - name: 'Bourbon Whiskey' optional: false - + name: 'Amaro Nonino' amount: 30 units: ml - name: 'Amaro Nonino' optional: false - + name: Aperol amount: 30 units: ml - name: Aperol optional: false - + name: 'Lemon juice' amount: 30 units: ml - name: 'Lemon juice' optional: false + images: + - + resource_path: cocktails/paper-plane.jpg + copyright: 'Bon Apetite' + placeholder_hash: ImkKHwpCiql5Z4aPWGhoeWd4tVBYXJAJ - name: Paradise description: 'The earliest known in-print recipe for the Paradise Cocktail was written by Harry Craddock in 1930. This cocktail is prepared using gin, apricot brandy (apricot liqueur), and orange juice in a 2:1:1 ratio, with a splash of lemon juice.' instructions: 'Pour all ingredients into cocktail shaker, shake well with ice, strain into chilled cocktail glass.' - garnish: N/A + garnish: null source: 'https://iba-world.com/paradise/' - images: - - copyright: Wikipedia glass: Cocktail method: Shake + abv: 24.62 tags: - 'IBA Cocktail' - 'The Unforgettables' - Gin ingredients: - + name: Gin amount: 30 units: ml - name: Gin optional: false - + name: 'Apricot Brandy' amount: 20 units: ml - name: 'Apricot Brandy' optional: false - + name: 'Orange juice' amount: 15 units: ml - name: 'Orange juice' optional: false + images: + - + resource_path: cocktails/paradise.jpg + copyright: Wikipedia + placeholder_hash: eRgGHwL1F5h3fYVzeNg4yDd9aNCHCH4I - name: Penicillin description: 'The drink was created in 2005 by Australian bartender Sam Ross living in New York at the time. Its name derives from the drug penicillin, discovered by Scottish scientist Alexander Fleming, hinting to the medicinal properties of some of its ingredients, with suggested effects similar to that of a hot toddy which is said to relieve the symptoms of cold and flu.' - instructions: |- - 1. Muddle ginger in a shaker. - 2. Add the remaining ingredients, except for the Islay single malt whiskey. - 3. Fill the shaker with ice and shake. - 4. Double-strain into a chilled old fashioned glass with ice. - 5. Float the single malt whiskey on top. + instructions: "1. Muddle ginger in a shaker.\n2. Add the remaining ingredients, except for the Islay single malt whiskey.\n3. Fill the shaker with ice and shake.\n4. Double-strain into a chilled old fashioned glass with ice.\n5. Float the single malt whiskey on top." garnish: 'Garnish with candied ginger.' source: 'https://iba-world.com/penicillin/' - images: - - copyright: Unknown glass: Lowball method: Shake + abv: 19.2 tags: - 'IBA Cocktail' - 'New Era Drinks' - Whiskey ingredients: - + name: 'Scotch whiskey' amount: 60 units: ml - name: 'Scotch whiskey' optional: false - + name: 'Islay Scotch' amount: 7.5 units: ml - name: 'Islay Scotch' optional: false - + name: 'Lemon juice' amount: 22.5 units: ml - name: 'Lemon juice' optional: false - + name: 'Honey Syrup' amount: 22.5 units: ml - name: 'Honey Syrup' optional: false - + name: Ginger amount: 2 units: slices - name: Ginger optional: false + images: + - + resource_path: cocktails/penicillin.jpg + copyright: Unknown + placeholder_hash: lAgSJwIIeJhyeIiFe1h3h4h4eDB4CIUJ - name: 'Pina Colada' description: 'The name piña colada (Spanish) literally means "strained pineapple", a reference to the freshly pressed and strained pineapple juice used in the drink''s preparation.' - instructions: |- - Blend all the ingredients with ice in an electric blender, pour into a large glass, and serve with straws. - - **Note:** - Historically a few drops of fresh lime juice were added to taste. 4 slices of fresh pineapple can be used instead of juice + instructions: "Blend all the ingredients with ice in an electric blender, pour into a large glass, and serve with straws.\n\n**Note:**\nHistorically a few drops of fresh lime juice were added to taste. 4 slices of fresh pineapple can be used instead of juice" garnish: 'Garnish with a slice of pineapple with a cocktail cherry.' source: 'https://iba-world.com/pina-colada/' - images: - - copyright: 'A Couple Cooks' glass: Hurricane method: Blend + abv: 12.31 tags: - 'Contemporary Classics' - 'IBA Cocktail' @@ -2261,74 +2906,73 @@ - Tiki ingredients: - + name: 'White Rum' amount: 50 units: ml - name: 'White Rum' optional: false - + name: 'Coconut Cream' amount: 30 units: ml - name: 'Coconut Cream' optional: false - + name: 'Pineapple juice' amount: 50 units: ml - name: 'Pineapple juice' optional: false + images: + - + resource_path: cocktails/pina-colada.jpg + copyright: 'A Couple Cooks' + placeholder_hash: TQgOHwIIyXdkd3eXe1Z3l4iGmZAYCpYD - name: 'Pisco Sour' - description: |- - A pisco sour is an alcoholic cocktail of Peruvian origin that is typical of the cuisines from Peru. The drink's name comes from pisco, which is its base liquor, and the cocktail term sour, in reference to sour citrus juice and sweetener components. - The Peruvian pisco sour uses Peruvian pisco as the base liquor and adds freshly squeezed lemon juice, simple syrup, ice, egg white, and Angostura bitters. - The Chilean version is similar, but uses Chilean pisco and Pica lime, and excludes the bitters and egg white. - Other variants of the cocktail include those created with fruits like pineapple or plants such as coca leaves. - instructions: |- - Add all ingredients into a shaker with ice. - Shake and strain into a chilled goblet glass. + description: "A pisco sour is an alcoholic cocktail of Peruvian origin that is typical of the cuisines from Peru. The drink's name comes from pisco, which is its base liquor, and the cocktail term sour, in reference to sour citrus juice and sweetener components.\nThe Peruvian pisco sour uses Peruvian pisco as the base liquor and adds freshly squeezed lemon juice, simple syrup, ice, egg white, and Angostura bitters.\nThe Chilean version is similar, but uses Chilean pisco and Pica lime, and excludes the bitters and egg white.\nOther variants of the cocktail include those created with fruits like pineapple or plants such as coca leaves." + instructions: "Add all ingredients into a shaker with ice.\nShake and strain into a chilled goblet glass." garnish: 'Few dashes of Amargo or Angostura bitters on top as an aromatic garnish.' source: 'https://iba-world.com/pisco-sour/' - images: - - copyright: 'Liquor.com / Tim Nusog' glass: 'Nick and Nora' method: Shake + abv: 17.45 tags: - 'Contemporary Classics' - 'IBA Cocktail' - Sour ingredients: - + name: Pisco amount: 60 units: ml - name: Pisco optional: false - + name: 'Lemon juice' amount: 30 units: ml - name: 'Lemon juice' optional: false - + name: 'Simple Syrup' amount: 20 units: ml - name: 'Simple Syrup' optional: false - + name: 'Egg White' amount: 1 units: drops - name: 'Egg White' optional: true + images: + - + resource_path: cocktails/pisco-sour.jpg + copyright: 'Liquor.com / Tim Nusog' + placeholder_hash: YwgSDwBqiHd/iYeJd3d3h4d4V6AnAZkE - name: 'Planter’s Punch' description: 'The cocktail has been said to have originated at the Planters Hotel in Charleston, South Carolina, but actually originated in Jamaica.' - instructions: |- - Pour all ingredients directly in a small tumbler or a typical terracotta glass. - NOTE: - Add dilution up to taste, it can be given by water, ice or juices. + instructions: "Pour all ingredients directly in a small tumbler or a typical terracotta glass.\nNOTE:\nAdd dilution up to taste, it can be given by water, ice or juices." garnish: 'Garnish with orange zest.' source: 'https://iba-world.com/planters-punch/' - images: - - copyright: Wikipedia glass: Hurricane method: Build + abv: 18.18 tags: - 'IBA Cocktail' - 'The Unforgettables' @@ -2336,30 +2980,80 @@ - Tiki ingredients: - + name: 'Jamaican Rum' amount: 45 units: ml - name: 'Jamaican rum' optional: false - + name: 'Lime juice' amount: 15 units: ml - name: 'Lime juice' optional: false - + name: 'Simple Syrup' amount: 30 units: ml + optional: false + images: + - + resource_path: cocktails/planters-punch.jpg + copyright: Wikipedia + placeholder_hash: DjkKJwaQtEeFeGlXelaXiHloBzZ5FJAH +- + name: 'Porn Star Martini' + description: 'This easy passion fruit cocktail is bursting with zingy flavours and is perfect for celebrating with friends.' + instructions: "1. Scoop the seeds from one of the passion fruits into the glass of a cocktail shaker\n2. Add the vodka, passoa, lime juice and sugar syrup.\n3. Add a handful of ice and shake well\n4. Strain into a martini glass\n5. Serve with shot of chilled Champagne on the side" + garnish: 'Half a passion fruit on top' + source: 'Douglas Ankrah, The Townhouse | London' + glass: Coupe + method: Shake + abv: 16.36 + tags: + - Vodka + ingredients: + - + name: 'Vanilla Vodka' + amount: 60 + units: ml + optional: false + substitutes: + - Vodka + - + name: Passoã + amount: 15 + units: ml + optional: false + - name: 'Simple Syrup' + amount: 15 + units: ml + optional: false + - + name: 'Lime juice' + amount: 15 + units: ml optional: false + - + name: Champagne + amount: 60 + units: ml + optional: true + substitutes: + - Prosecco + images: + - + resource_path: cocktails/porn-star-martini.jpg + copyright: 'Punch / Jamie Lau' + placeholder_hash: 2vcNFwJqWHiCeHd7iAmGmIaXyKQJXYoA - name: 'Porto Flip' description: 'The Porto Flip was first recorded by Jerry Thomas in his 1862 book The Bartender’s Guide: How to Mix Drinks; A Bon Vivant’s Companion. albeit under the name "Coffee Cocktail", named for its appearance rather than its ingredients.' instructions: 'Pour all ingredients into a cocktail shaker, shake well with ice, strain into a chilled cocktail glass.' garnish: 'Sprinkle with ground nutmeg.' source: 'https://iba-world.com/porto-flip/' - images: - - copyright: 'Liquor.com / Tim Nusog' glass: Coupe method: Shake + abv: 12.51 tags: - 'IBA Cocktail' - 'The Unforgettables' @@ -2367,315 +3061,453 @@ - Wine ingredients: - + name: Brandy amount: 15 units: ml - name: Brandy optional: false - + name: 'Red wine' amount: 45 units: ml - name: 'Red wine' optional: false - + name: 'Egg Yolk' amount: 10 units: ml - name: 'Egg Yolk' optional: false + images: + - + resource_path: cocktails/porto-flip.jpg + copyright: 'Liquor.com / Tim Nusog' + placeholder_hash: b+cJDwJjZ4dwdndldZh4h5iGVrCGBXoN +- + name: "Queen's Park Hotel Super Cocktail" + description: null + instructions: 'Shake all ingredients with ice and strain into chilled glass.' + garnish: 'Lime zest' + source: 'https://www.cocktailexplorer.co/cocktails/queens-park-hotel-super-cocktail/anders-erickson/' + glass: Coupe + method: Shake + abv: 18.63 + tags: + - Rum + ingredients: + - + name: 'White Rum' + amount: 45 + units: ml + optional: false + - + name: 'Sweet Vermouth' + amount: 15 + units: ml + optional: false + - + name: 'Grenadine Syrup' + amount: 15 + units: ml + optional: false + - + name: 'Lime juice' + amount: 15 + units: ml + optional: false + - + name: 'Angostura aromatic bitters' + amount: 2 + units: dashes + optional: false + images: + - + resource_path: cocktails/queens-park-hotel-super-cocktail.jpg + copyright: 'Anders Erickson' + placeholder_hash: TTgGFwYHaHd2eIWMhVaXeHdxhpQHOHkA - name: 'Ramos Fizz' description: 'The drink was invented by Henry Ramos in 1888, at his bar Meyer’s Table d’Hôtel Internationale in New Orleans. The Ramos Fizz was originally shaken for 12 minutes by a crew of 30 bartenders who passed the shaker from one to another.' - instructions: |- - 1. Pour all ingredients except soda water in a cocktail shaker with ice. - 2. Shake for two minutes. - 3. Double strain in a glass. - 4. Pour the drink back in the shaker, and hard shake without ice for one minute. - 5. Strain into a highball glass, top up with soda. - garnish: N/A + instructions: "1. Pour all ingredients except soda water in a cocktail shaker with ice.\n2. Shake for two minutes.\n3. Double strain in a glass.\n4. Pour the drink back in the shaker, and hard shake without ice for one minute.\n5. Strain into a highball glass, top up with soda." + garnish: null source: 'https://iba-world.com/ramos-fizz/' - images: - - copyright: 'Punch Drink' glass: Highball method: Shake + abv: 7.32 tags: - 'IBA Cocktail' - 'The Unforgettables' - Gin ingredients: - + name: Gin amount: 45 units: ml - name: Gin optional: false - + name: 'Lime juice' amount: 15 units: ml - name: 'Lime juice' optional: false - + name: 'Lemon juice' amount: 15 units: ml - name: 'Lemon juice' optional: false - + name: 'Simple Syrup' amount: 30 units: ml - name: 'Simple Syrup' optional: false - + name: Cream amount: 60 units: ml - name: Cream optional: false - + name: 'Egg White' amount: 30 units: ml - name: 'Egg White' optional: false - + name: 'Orange Flower Water' amount: 3 units: dashes - name: 'Orange Flower Water' optional: false - + name: 'Vanilla Extract' amount: 2 units: drops - name: 'Vanilla Extract' optional: false - + name: 'Club soda' amount: 1 units: topup - name: 'Club soda' optional: false + images: + - + resource_path: cocktails/ramos-fizz.jpg + copyright: 'Punch Drink' + placeholder_hash: KQkSFwIwhXqbiHiYiYd3eIh399y8YKYP - name: 'Russian Spring Punch' description: 'The Russian Spring Punch was created in London, England by Dick Bradsell in the 1980s. He claims not to remember which bar he was working at at the time, but tells the story of how he created the recipe for personal friends wishing to hold a cocktail party while minimizing the amount of money they had to spend on alcohol. Participants were provided with the vodka, cassis, sugar syrup and lemon juice, and were asked to bring their own sparkling wine. It is named for the russian vodka, and the Tom Collins, which is a spring drink.' instructions: 'Pour all ingredients into a cocktail shaker except the sparkling wine, shake well with ice, strain into a chilled tall tumbler glass filled with ice, and top up with sparkling wine.' garnish: 'Garnish with blackberries and optionally a lemon slice as well.' source: 'https://iba-world.com/russian-spring-punch/' - images: - - copyright: 'Liquido Spirits' glass: Highball method: Shake + abv: 14.67 tags: - 'IBA Cocktail' - 'New Era Drinks' - Vodka ingredients: - + name: Vodka amount: 25 units: ml - name: Vodka optional: false - + name: 'Lemon juice' amount: 25 units: ml - name: 'Lemon juice' optional: false - + name: 'Crème de cassis (blackcurrant liqueur)' amount: 15 units: ml - name: 'Crème de cassis (blackcurrant liqueur)' optional: false - + name: 'Simple Syrup' amount: 10 units: ml - name: 'Simple Syrup' optional: false - + name: Champagne amount: 1 units: topup - name: Champagne optional: false + images: + - + resource_path: cocktails/russian-spring-punch.jpg + copyright: 'Liquido Spirits' + placeholder_hash: 1VgKJwwL2oiDloqJfESXV4l5lnBKCIYG - name: 'Rusty Nail' - description: |- - A Rusty Nail can be served in an old-fashioned glass on the rocks, or "up" in a stemmed glass. It is most commonly served over ice. A Rusty Nail served without ice is sometimes called a Straight Up Nail. - - - **The Rusty Bob**, which substitutes Bourbon whiskey for blended Scotch whisky - - **The Rusty Ale**, in which a shot of Drambuie is added to any beer, served without ice. - - **The Smoky Nail**, which uses Islay whisky (very smoky in flavor) in place of blended Scotch whisky. - - **The Clavo Ahumado** (Spanish for "smoky nail"), using mezcal instead of blended Scotch whisky. - - **The Railroad Spike**, often served at brunch and made with approximately four parts Cold brewed coffee to one part Drambuie in a tall glass over ice. - - **The Donald Sutherland**, which substitutes Canadian rye whisky for blended Scotch whisky. + description: "A Rusty Nail can be served in an old-fashioned glass on the rocks, or \"up\" in a stemmed glass. It is most commonly served over ice. A Rusty Nail served without ice is sometimes called a Straight Up Nail.\n\n - **The Rusty Bob**, which substitutes Bourbon whiskey for blended Scotch whisky\n - **The Rusty Ale**, in which a shot of Drambuie is added to any beer, served without ice.\n - **The Smoky Nail**, which uses Islay whisky (very smoky in flavor) in place of blended Scotch whisky.\n - **The Clavo Ahumado** (Spanish for \"smoky nail\"), using mezcal instead of blended Scotch whisky.\n - **The Railroad Spike**, often served at brunch and made with approximately four parts Cold brewed coffee to one part Drambuie in a tall glass over ice.\n - **The Donald Sutherland**, which substitutes Canadian rye whisky for blended Scotch whisky." instructions: 'Pour all ingredients directly into an old fashioned glass filled with ice. Stir gently.' garnish: 'Garnish with lemon zest.' source: 'https://iba-world.com/rusty-nail/' - images: - - copyright: 'Serious Eats' glass: Lowball method: Build + abv: 36.36 tags: - 'IBA Cocktail' - 'The Unforgettables' - Whiskey ingredients: - + name: 'Scotch whiskey' amount: 45 units: ml - name: 'Scotch whiskey' optional: false - + name: Drambuie amount: 25 units: ml - name: Drambuie optional: false + images: + - + resource_path: cocktails/rusty-nail.jpg + copyright: 'Serious Eats' + placeholder_hash: aCkGLwSv2YnnsIm0njYcVSuNpgOJSYAI +- + name: Sangria + description: "A punch, sangria traditionally consists of red wine and chopped fruit, often with other ingredients or spirits.\n\nSangria is very popular among foreign tourists in Spain even if locals do not consume the beverage that much. It is commonly served in bars, restaurants, and chiringuitos and at festivities throughout Portugal and Spain." + instructions: "1. Chop the orange (leaving the peel on) and apple into bite-sized chunks, then add them to the bottom of a pitcher. Sprinkle them with sugar and stir. Let them stand for 20 minutes at room temperature.\n2. After 20 minutes, pour in the red wine, brandy, orange liqueur, and lemon rounds. Stir and refrigerate 1 to 4 hours. (Don’t go beyond 4 hours or the fruit texture starts to degrade.)\n3. Pour the sangria into ice filled glasses and top with a splash of sparkling water (if desired). Add fruit to each glass, preferably on long skewers for easy snacking." + garnish: 'Drop fruit chunks into a glass' + source: 'https://www.acouplecooks.com/gin-gimlet-cocktail/' + glass: Wine + method: Build + abv: 10.52 + tags: + - Wine + ingredients: + - + name: 'Red wine' + amount: 1000 + units: ml + optional: false + - + name: Orange + amount: 1 + units: whole + optional: false + - + name: Apple + amount: 1 + units: whole + optional: false + - + name: Sugar + amount: 3 + units: tablespoons + optional: false + - + name: Brandy + amount: 10 + units: ml + optional: false + - + name: Cointreau + amount: 10 + units: ml + optional: false + - + name: Lemon + amount: 1 + units: whole + optional: false + images: + - + resource_path: cocktails/sangria.jpg + copyright: 'A Couple Cooks' + placeholder_hash: 8PcJHwaaxKxvpmabiBWYpWZ3qGgJi4YA - name: Sazerac - description: |- - The Sazerac is a local variation of a cognac or whiskey cocktail originally from New Orleans, named for the Sazerac de Forge et Fils brand of cognac brandy that served as its original main ingredient. - The drink is most traditionally a combination of cognac or rye whiskey, absinthe, Peychaud's Bitters, and sugar, although bourbon whiskey is sometimes substituted for the rye and Herbsaint is sometimes substituted for the absinthe. - Some claim it is the oldest known American cocktail, with origins in antebellum New Orleans, although drink historian David Wondrich is among those who dispute this, and American instances of published usage of the word cocktail to describe a mixture of spirits, bitters, and sugar can be traced to the dawn of the 19th century. - instructions: |- - 1. Rinse a chilled old-fashioned glass with the absinthe. - 2. Add crushed ice and set it aside. - 3. Stir the remaining ingredients over ice in a mixing glass. - 4. Discard the ice and any excess absinthe from the prepared glass. - 5. Strain the mixed drink into the glass. + description: "The Sazerac is a local variation of a cognac or whiskey cocktail originally from New Orleans, named for the Sazerac de Forge et Fils brand of cognac brandy that served as its original main ingredient.\nThe drink is most traditionally a combination of cognac or rye whiskey, absinthe, Peychaud's Bitters, and sugar, although bourbon whiskey is sometimes substituted for the rye and Herbsaint is sometimes substituted for the absinthe.\nSome claim it is the oldest known American cocktail, with origins in antebellum New Orleans, although drink historian David Wondrich is among those who dispute this, and American instances of published usage of the word cocktail to describe a mixture of spirits, bitters, and sugar can be traced to the dawn of the 19th century." + instructions: "1. Rinse a chilled old-fashioned glass with the absinthe.\n2. Add crushed ice and set it aside.\n3. Stir the remaining ingredients over ice in a mixing glass.\n4. Discard the ice and any excess absinthe from the prepared glass.\n5. Strain the mixed drink into the glass." garnish: 'Garnish with lemon zest.' source: 'https://iba-world.com/sazerac/' - images: - - copyright: 'The Spruce Eats' glass: Lowball method: Stir + abv: 33.25 tags: - 'IBA Cocktail' - 'The Unforgettables' - Brandy ingredients: - + name: Cognac amount: 50 units: ml - name: Cognac optional: false - + name: Absinthe amount: 10 units: ml - name: Absinthe optional: false - + name: Sugar amount: 1 units: cube - name: Sugar optional: false - + name: 'Peychauds Bitters' amount: 2 units: dashes - name: 'Peychauds Bitters' optional: false + images: + - + resource_path: cocktails/sazerac.jpg + copyright: 'The Spruce Eats' + placeholder_hash: l8cJJwzAdnmJhndoloh5Vpd3OGBVBGME - name: 'Sea Breeze' description: 'The cocktail is usually consumed during summer months. The drink may be shaken in order to create a foamy surface.' instructions: 'Build all ingredients in a highball glass filled with ice.' garnish: 'Garnish with orange zest and cherry' source: 'https://iba-world.com/sea-breeze/' - images: - - copyright: 'Liquor.com / Tim Nusog' glass: Highball method: Build + abv: 7.66 tags: - 'Contemporary Classics' - 'IBA Cocktail' - Vodka ingredients: - + name: Vodka amount: 40 units: ml - name: Vodka optional: false - + name: 'Cranberry juice' amount: 120 units: ml - name: 'Cranberry juice' optional: false - + name: 'Grapefruit juice' amount: 30 units: ml - name: 'Grapefruit juice' optional: false + images: + - + resource_path: cocktails/sea-breeze.jpg + copyright: 'Liquor.com / Tim Nusog' + placeholder_hash: I0kKRxL7SIZZmIerdVhKuHWoePU4B40B +- + name: Seelbach + description: "The Seelbach Cocktail may not be a julep, but it doesn't have to be: it's respectable, powerful, and delicious right down to the bottom of the glass." + instructions: "1. Add the bourbon, Cointreau, Angostura bitters and Peychaud’s bitters into a mixing glass with ice and stir until well-chilled.\n2. Strain into a chilled flute.\n3. Top with cold Champagne or other sparkling wine.\n3. Garnish with an orange twist." + garnish: 'Orange twist' + source: 'https://www.seriouseats.com/seelbach-cocktail-recipe' + glass: Champagne + method: Stir + abv: 18.43 + tags: + - Whiskey + ingredients: + - + name: 'Bourbon Whiskey' + amount: 30 + units: ml + optional: false + - + name: Cointreau + amount: 15 + units: ml + optional: false + - + name: 'Angostura aromatic bitters' + amount: 5 + units: dashes + optional: false + - + name: 'Peychauds Bitters' + amount: 5 + units: dashes + optional: false + - + name: Champagne + amount: 90 + units: ml + optional: false + images: + - + resource_path: cocktails/seelbach.jpg + copyright: 'The Educated Barfly' + placeholder_hash: eAgGFwL4J8iGe4R5hqlXqGd4eMBnB3wG - name: 'Sex on the Beach' - description: |- - There are two general types of the cocktail: - - - The IBA official cocktail is made from vodka, peach schnapps, orange juice, and cranberry juice. - - The 2008 Mr. Boston Official Bartender's Guide (67th edition) provides an alternative recipe made from vodka, Chambord, Midori Melon Liqueur, pineapple juice, and cranberry juice. - - The drink is built over ice in a highball glass and garnished with an orange slice. Sometimes they are mixed in smaller amounts and served as a shooter. + description: "There are two general types of the cocktail:\n\n- The IBA official cocktail is made from vodka, peach schnapps, orange juice, and cranberry juice.\n- The 2008 Mr. Boston Official Bartender's Guide (67th edition) provides an alternative recipe made from vodka, Chambord, Midori Melon Liqueur, pineapple juice, and cranberry juice.\n\nThe drink is built over ice in a highball glass and garnished with an orange slice. Sometimes they are mixed in smaller amounts and served as a shooter." instructions: 'Build all ingredients in a highball glass filled with ice.' garnish: 'Garnish with a half orange slice.' source: 'https://iba-world.com/sex-on-the-beach/' - images: - - copyright: 'Copy Kat Recipes' glass: Highball method: Build + abv: 15.58 tags: - 'Contemporary Classics' - 'IBA Cocktail' - Vodka ingredients: - + name: Vodka amount: 40 units: ml - name: Vodka optional: false - + name: 'Peach Schnapps' amount: 20 units: ml - name: 'Peach Schnapps' optional: false - + name: 'Orange juice' amount: 40 units: ml - name: 'Orange juice' optional: false - + name: 'Cranberry juice' amount: 40 units: ml - name: 'Cranberry juice' optional: false + images: + - + resource_path: cocktails/sex-on-the-beach.jpg + copyright: 'Copy Kat Recipes' + placeholder_hash: rYkSRxT2aHhplXl4h3h3h3iIl5B6CJYH - name: Sidecar - description: |- - The exact origin of the sidecar is unclear, but it is thought to have been invented around the end of World War I in either London or Paris. The drink was directly named for the motorcycle attachment, which was very commonly used back then. - - Like the daiquiri, the sidecar evolved from the original sour formula, but sidecars are often drier than sours, combining liqueurs like curaçao with citrus. Sidecars are considered more of a challenge for bartenders because the proportion of ingredients is more difficult to balance for liqueurs of variable sweetness. + description: "The exact origin of the sidecar is unclear, but it is thought to have been invented around the end of World War I in either London or Paris. The drink was directly named for the motorcycle attachment, which was very commonly used back then.\n\nLike the daiquiri, the sidecar evolved from the original sour formula, but sidecars are often drier than sours, combining liqueurs like curaçao with citrus. Sidecars are considered more of a challenge for bartenders because the proportion of ingredients is more difficult to balance for liqueurs of variable sweetness." instructions: 'Pour all ingredients into a cocktail shaker, shake well with ice, strain into a chilled cocktail glass.' - garnish: N/A + garnish: null source: 'https://iba-world.com/sidecar/' - images: - - copyright: 'A Couple Cooks' glass: Cocktail method: Shake + abv: 24.89 tags: - 'IBA Cocktail' - 'The Unforgettables' - Brandy ingredients: - + name: Cognac amount: 50 units: ml - name: Cognac optional: false - + name: 'Triple Sec' amount: 20 units: ml - name: 'Triple Sec' optional: false - + name: 'Lemon juice' amount: 20 units: ml - name: 'Lemon juice' optional: false + images: + - + resource_path: cocktails/sidecar.jpg + copyright: 'A Couple Cooks' + placeholder_hash: SggKFwJFd3mmhHd8aQiZeKZ1J5B9BKYH - name: 'Singapore Sling' description: 'This long drink was developed sometime before 1915 by bartender Ngiam Tong Boon, who was working at the Long Bar in Raffles Hotel, Singapore. It was initially called the gin sling – a sling was originally a North American drink composed of spirit and water, sweetened and flavored.' instructions: 'Pour all ingredients into a cocktail shaker filled with ice cubes. Shake well. Strain into Hurricane glass.' garnish: 'Garnish with pineapple and maraschino cherry' source: 'https://iba-world.com/singapore-sling/' - images: - - copyright: 'The Spruce Eats' glass: Hurricane method: Shake + abv: 8.98 tags: - 'Contemporary Classics' - 'IBA Cocktail' @@ -2683,191 +3515,205 @@ - Tiki ingredients: - + name: Gin amount: 30 units: ml - name: Gin optional: false - + name: Maraschino amount: 15 units: ml - name: Maraschino optional: false - + name: Cointreau amount: 7.5 units: ml - name: Cointreau optional: false - + name: Bénédictine amount: 7.5 units: ml - name: Bénédictine optional: false - + name: 'Pineapple juice' amount: 120 units: ml - name: 'Pineapple juice' optional: false - + name: 'Lime juice' amount: 15 units: ml - name: 'Lime juice' optional: false - + name: 'Grenadine Syrup' amount: 10 units: ml - name: 'Grenadine Syrup' optional: false - + name: 'Angostura aromatic bitters' amount: 1 units: dash - name: 'Angostura aromatic bitters' optional: false + images: + - + resource_path: cocktails/singapore-sling.jpg + copyright: 'The Spruce Eats' + placeholder_hash: sSgKFwioRYhveIiqiFmIeFZqODCLBYYF - name: Southside description: "Its origins are subject to speculation. It has been proposed that it gets its name from either the South Side district of the city of Chicago, Illinois, or from the Southside Sportsmen's Club on Long Island." - instructions: |- - Pour all ingredients into a cocktail shaker, shake well with ice, double-strain into chilled cocktail glass. - Note: - If egg white is used shake vigorously. + instructions: "Pour all ingredients into a cocktail shaker, shake well with ice, double-strain into chilled cocktail glass.\nNote:\nIf egg white is used shake vigorously." garnish: 'Garnish with mint springs.' source: 'https://iba-world.com/southside/' - images: - - copyright: 'Limoncello Kitchen' glass: 'Nick and Nora' method: Shake + abv: 18.29 tags: - 'IBA Cocktail' - 'New Era Drinks' - Gin ingredients: - + name: Gin amount: 60 units: ml - name: Gin optional: false - + name: 'Lemon juice' amount: 30 units: ml - name: 'Lemon juice' optional: false - + name: 'Simple Syrup' amount: 15 units: ml - name: 'Simple Syrup' optional: false - + name: Mint amount: 5 units: sprigs - name: Mint optional: false + images: + - + resource_path: cocktails/southside.jpg + copyright: 'Limoncello Kitchen' + placeholder_hash: cwgKDwJkdmZgZ3iHeYeIiIiKm/poUmAC - name: 'Spicy Fifty' description: null instructions: 'Pour all ingredients into a cocktail shaker, shake well with ice, double-strain into chilled cocktail glass.' garnish: 'Garnish with a red chilli pepper' source: 'https://iba-world.com/spicy-fifty/' - images: - - copyright: MixolopediA glass: Cocktail method: Shake + abv: 17.78 tags: - 'IBA Cocktail' - 'New Era Drinks' - Vodka ingredients: - + name: Vodka amount: 50 units: ml - name: Vodka optional: false - + name: 'Elderflower Cordial' amount: 15 units: ml - name: 'Elderflower Cordial' optional: false - + name: 'Lime juice' amount: 15 units: ml - name: 'Lime juice' optional: false - + name: 'Honey Syrup' amount: 10 units: ml - name: 'Honey Syrup' optional: false - + name: 'Chilli Pepper' amount: 2 units: slices - name: 'Chilli Pepper' optional: false + images: + - + resource_path: cocktails/spicy-fifty.jpg + copyright: MixolopediA + placeholder_hash: ThgODwIFeYluhJeFeRepR5iHaPEGOI8G - name: Spritz description: "A Spritz is a Venetian wine-based cocktail, commonly served as an aperitif in Northeast Italy. It consists of prosecco, digestive bitters and soda water. The Spritz became widely popular outside of Italy around 2018 and Aperol Spritz was ranked as the world's ninth bestselling cocktail in 2019 by the website Drinks International." - instructions: |- - Build all ingredients into a wine glass filled with ice. Stir gently. - NOTE: - There are other versions of the Spritz that use Campari, Cynar or Select instead of Aperol. + instructions: "Build all ingredients into a wine glass filled with ice. Stir gently.\nNOTE:\nThere are other versions of the Spritz that use Campari, Cynar or Select instead of Aperol." garnish: 'Garnish with a slice of orange.' source: 'https://iba-world.com/spritz/' - images: - - copyright: 'Kitchen Swagger' glass: Wine method: Build + abv: 10 tags: - 'IBA Cocktail' - 'New Era Drinks' - Wine ingredients: - + name: Prosecco amount: 90 units: ml - name: Prosecco optional: false - + name: Aperol amount: 60 units: ml - name: Aperol optional: false - + name: 'Club soda' amount: 1 units: splash - name: 'Club soda' optional: false + images: + - + resource_path: cocktails/spritz.jpg + copyright: 'Kitchen Swagger' + placeholder_hash: DjkKLwoDhJxPgbdveGiGOYiJN/O0U08I - name: Stinger description: "A Stinger is a duo cocktail made by adding crème de menthe to brandy (although recipes vary). The cocktail's origins can be traced to the United States in the 1890s, and the beverage remained widely popular in America until the 1970s. It was seen as a drink of the upper class, and has had a somewhat wide cultural impact." instructions: 'Pour all ingredients into mixing glass with ice cubes. Stir well. Strain into chilled martini cocktail glass.' garnish: 'Optional mint leave.' source: 'https://iba-world.com/stinger/' - images: - - copyright: 'A Couple Cooks' glass: Cocktail method: Stir + abv: 29.76 tags: - 'IBA Cocktail' - 'The Unforgettables' - Brandy ingredients: - + name: Cognac amount: 50 units: ml - name: Cognac optional: false - + name: 'Menthe Crème de Cacao' amount: 20 units: ml - name: 'Menthe Crème de Cacao' optional: false + images: + - + resource_path: cocktails/stinger.jpg + copyright: 'A Couple Cooks' + placeholder_hash: 1SgSJwZmV3iAh4d5eSmHl3h4mVAHCYIA - name: 'Suffering Bastard' description: 'When ordered in Tiki bars the Suffering Bastard is often served in the uniquely shaped and eponymously named "Suffering Bastard Tiki mug", made to look like a squat fellow with a hangover holding his hands over the top of his head in pain.' instructions: 'Pour all ingredients into a cocktail shaker except the ginger beer, shake well with ice, Pour unstrained into a Collins glass or in the original S. Bastard mug and top up with ginger beer.' garnish: 'Garnish with mint springs and optionally an orange slice as well.' source: 'https://iba-world.com/suffering-bastard/' - images: - - copyright: 'Keg Works' glass: Tiki method: Shake + abv: 25.76 tags: - 'IBA Cocktail' - 'New Era Drinks' @@ -2876,283 +3722,306 @@ - Tiki ingredients: - + name: Cognac amount: 30 units: ml - name: Cognac optional: false - + name: Gin amount: 30 units: ml - name: Gin optional: false - + name: 'Lime juice' amount: 15 units: ml - name: 'Lime juice' optional: false - + name: 'Angostura aromatic bitters' amount: 2 units: dashes - name: 'Angostura aromatic bitters' optional: false - + name: 'Ginger beer' amount: 1 units: topup - name: 'Ginger beer' optional: false + images: + - + resource_path: cocktails/suffering-bastard.jpg + copyright: 'Keg Works' + placeholder_hash: qxgKDwK5RZ5vh2etiHh2iHiHhwl6+3AI - name: 'Tequila Sunrise' description: 'The original tequila sunrise contained tequila, creme de cassis, lime juice, and soda water, and was served at the Arizona Biltmore Hotel where it was created by Gene Sulit in the 1930s or 1940s.' instructions: 'Pour tequila and orange juice directly into highball glass filled with ice cubes. Add the grenadine syrup to create chromatic effect (sunrise), do not stir.' garnish: 'Garnish with half orange slice or an orange zest' source: 'https://iba-world.com/tequila-sunrise/' - images: - - copyright: 'Homemade Hooplah' glass: Highball method: Build + abv: 10.91 tags: - 'Contemporary Classics' - 'IBA Cocktail' - Tequila ingredients: - + name: Tequila amount: 45 units: ml - name: Tequila optional: false - + name: 'Orange juice' amount: 90 units: ml - name: 'Orange juice' optional: false - + name: 'Grenadine Syrup' amount: 15 units: ml - name: 'Grenadine Syrup' optional: false + images: + - + resource_path: cocktails/tequila-sunrise.jpg + copyright: 'Homemade Hooplah' + placeholder_hash: JIsKTwzAqpiKenmaiViJyYZkOqCJBYYK - name: Tipperary description: null instructions: 'Pour all ingredients into mixing glass with ice cubes. Stir well. Strain into chilled martini cocktail glass.' garnish: 'Garnish with a slice of orange.' source: 'https://iba-world.com/tipperary/' - images: - - copyright: MixolopediA glass: Cocktail method: Stir + abv: 30.42 tags: - 'IBA Cocktail' - 'New Era Drinks' - Whiskey ingredients: - + name: 'Irish whiskey' amount: 50 units: ml - name: 'Irish whiskey' optional: false - + name: 'Sweet Vermouth' amount: 25 units: ml - name: 'Sweet Vermouth' optional: false - + name: 'Green Chartreuse' amount: 15 units: ml - name: 'Green Chartreuse' optional: false - + name: 'Angostura aromatic bitters' amount: 2 units: dashes - name: 'Angostura aromatic bitters' optional: false + images: + - + resource_path: cocktails/tipperary.jpg + copyright: MixolopediA + placeholder_hash: TwgKFwI0t3Z/d3eHiHaHWJeImKBJCYsD - name: 'Tommy’s Margarita' description: "Tommy's margarita was conceived in San Francisco in 1990 by Julio Bermejo at his parents' restaurant called Tommy's (not to be confused with Tommy's Place in Juárez, Mexico. See Margarita Origin). Bermejo had been recently introduced to agave nectar as an ingredient, and, although it was still expensive at the time, he preferred to use it to enhance the agave flavor of the cocktail instead of using triple sec to highlight the citrus flavor in the original Margarita recipe. In 2008, it became the first venue specific cocktail to be added to the IBA manual." instructions: 'Pour all ingredients into a cocktail shaker, shake well with ice, strain into a chilled rocks glass filled with ice.' garnish: 'Garnish with a lime slice.' source: 'https://iba-world.com/tommys-margarita/' - images: - - copyright: 'Moody Mixologist' glass: Lowball method: Shake + abv: 16 tags: - 'IBA Cocktail' - 'New Era Drinks' - Whiskey ingredients: - + name: Tequila amount: 60 units: ml - name: Tequila optional: false - + name: 'Lime juice' amount: 30 units: ml - name: 'Lime juice' optional: false - + name: 'Agave Syrup' amount: 30 units: ml - name: 'Agave Syrup' optional: false + images: + - + resource_path: cocktails/tommys-margarita.jpg + copyright: 'Moody Mixologist' + placeholder_hash: VAgODwIWhqiEh3eQe2eHd4eJmS+ph4AP - name: 'Trinidad Sour' description: 'Unusually for a cocktail, it uses Angostura bitters as a base spirit rather than as a flavoring, the bitters being the single largest component of the drink in its IBA formulation.' - instructions: |- - 1. Pour all ingredients into a cocktail shaker - 2. Shake well with ice - 3. Strain into a chilled cocktail glass. - garnish: N/A + instructions: "1. Pour all ingredients into a cocktail shaker\n2. Shake well with ice\n3. Strain into a chilled cocktail glass." + garnish: null source: 'https://iba-world.com/trinidad-sour/' - images: - - copyright: 'A Couple Cooks' glass: Cocktail method: Shake + abv: 18.57 tags: - 'IBA Cocktail' - 'New Era Drinks' - Sour ingredients: - + name: 'Angostura aromatic bitters' amount: 45 units: ml - name: 'Angostura aromatic bitters' optional: false - + name: 'Orgeat Syrup' amount: 30 units: ml - name: 'Orgeat Syrup' optional: false - + name: 'Lemon juice' amount: 22.5 units: ml - name: 'Lemon juice' optional: false - + name: 'Rye whiskey' amount: 15 units: ml - name: 'Rye whiskey' optional: false + images: + - + resource_path: cocktails/trinidad-sour.jpg + copyright: 'A Couple Cooks' + placeholder_hash: 8NcFDwSwBaaKr2WFY8hX6ClpCJDLBIkJ - name: Tuxedo description: 'Related to the martini, the Tuxedo has had many variations since its inception in the 1880s. The cocktail is named after the Tuxedo Club in Orange County, New York where it was first mixed. Tuxedo Park, the planned community where the club was built, is itself a derivation of the Lenape word tucseto. The form of menswear by the same name originated at the same country club around the same time.' instructions: 'Pour all ingredients into mixing glass with ice cubes. Stir well. Strain into chilled martini cocktail glass.' garnish: 'Garnish with cherry and lemon zest' source: 'https://iba-world.com/tuxedo/' - images: - - copyright: Punch glass: Cocktail method: Stir + abv: 24.14 tags: - 'IBA Cocktail' - 'The Unforgettables' - Gin ingredients: - + name: Gin amount: 30 units: ml - name: Gin optional: false - + name: 'Dry Vermouth' amount: 30 units: ml - name: 'Dry Vermouth' optional: false - + name: Maraschino amount: 1 units: teaspoon - name: Maraschino optional: false - + name: Absinthe amount: 2 units: teaspoon - name: Absinthe optional: false - + name: 'Orange bitters' amount: 3 units: dashes - name: 'Orange bitters' optional: false + images: + - + resource_path: cocktails/tuxedo.jpg + copyright: Punch + placeholder_hash: kQgKJwQJaIiLhXVmhkm2h4dohvJ3CG8J - name: VE.N.TO description: 'The Ve.n.to is one of the new additions to the IBA (International Bartender Association) cocktail list. And it is a truly unusual one, for it is the first-ever Grappa cocktail that made it into one of the lists of the IBA.' - instructions: |- - 1. Pour all ingredients into the shaker. - 2. Shake vigorously with ice. - 3. Strain into a chilled small tumbler glass filled with ice. - - Notes: - If desired water can be replaced by chamomile infusion in the honey mix. + instructions: "1. Pour all ingredients into the shaker.\n2. Shake vigorously with ice.\n3. Strain into a chilled small tumbler glass filled with ice.\n\nNotes:\nIf desired water can be replaced by chamomile infusion in the honey mix." garnish: 'Garnish with lemon zest and white grapes.' source: 'https://iba-world.com/ve-n-to/' - images: - - copyright: 'IBA World' glass: Lowball method: Shake + abv: 18.46 tags: - 'IBA Cocktail' - 'New Era Drinks' ingredients: - + name: Grappa amount: 45 units: ml - name: Grappa optional: false - + name: 'Lemon juice' amount: 22.5 units: ml - name: 'Lemon juice' optional: false - + name: 'Honey Syrup' amount: 15 units: ml - name: 'Honey Syrup' optional: false - + name: 'Chamomile cordial' amount: 15 units: ml - name: 'Chamomile cordial' optional: false + images: + - + resource_path: cocktails/vento.jpg + copyright: 'IBA World' + placeholder_hash: e/gFFwD4V2eHeHeKdphnqFd4iKBYao8K - name: Vesper description: 'The Vesper or Vesper Martini is a cocktail that was originally made of gin, vodka, and Kina Lillet. The formulations of its ingredients have changed since its original publication in print, and so some modern bartenders have created new versions which attempt to more closely mimic the original taste. The drink was invented and named by Ian Fleming in the 1953 James Bond novel Casino Royale.' instructions: 'Pour all ingredients into a cocktail shaker filled with ice cubes. Shake and strain into a chilled cocktail glass.' garnish: 'Lemon zest' source: 'https://iba-world.com/vesper/' - images: - - copyright: 'A Couple Cooks' glass: Cocktail method: Shake + abv: 29.96 tags: - 'Contemporary Classics' - 'IBA Cocktail' - Gin ingredients: - + name: Gin amount: 45 units: ml - name: Gin optional: false - + name: Vodka amount: 15 units: ml - name: Vodka optional: false - + name: 'Lillet Blanc' amount: 7.5 units: ml - name: 'Lillet Blanc' optional: false + images: + - + resource_path: cocktails/vesper.jpg + copyright: 'A Couple Cooks' + placeholder_hash: SwgKDwAlWHl+doVkjAa3Z4eICPeCoB4I - name: 'Vieux Carrè' description: 'It originated with Walter Bergeron, a bartender at New Orleans’ Carousel Bar in the Hotel Monteleone. The name is French for "old square”, in reference to the city''s French Quarter neighborhood.' instructions: 'Pour all ingredients into mixing glass with ice cubes. Stir well. Strain into a chilled cocktail glass.' garnish: 'Garnish with orange zest and maraschino cherry.' source: 'https://iba-world.com/vieux-carre/' - images: - - copyright: 'Liquor.com / Tim Nusog' glass: Cocktail method: Stir + abv: 27.25 tags: - 'IBA Cocktail' - 'The Unforgettables' @@ -3160,47 +4029,44 @@ - Brandy ingredients: - + name: 'Rye whiskey' amount: 30 units: ml - name: 'Rye whiskey' optional: false - + name: Cognac amount: 30 units: ml - name: Cognac optional: false - + name: 'Sweet Vermouth' amount: 30 units: ml - name: 'Sweet Vermouth' optional: false - + name: Bénédictine amount: 1 units: teaspoon - name: Bénédictine optional: false - + name: 'Peychauds Bitters' amount: 2 units: dashes - name: 'Peychauds Bitters' optional: false + images: + - + resource_path: cocktails/vieux-carre.jpg + copyright: 'Liquor.com / Tim Nusog' + placeholder_hash: mCgGHwQzepeNhoh5WgaZlYdWt2AoHHMA - name: 'Whiskey Sour' description: 'The oldest historical mention of a whiskey sour was published in the Wisconsin newspaper, Waukesha Plain Dealer, in 1870. With egg white included, it is sometimes called a Boston Sour; when the whiskey used is a Scotch, it is called a Scotch Sour. With a few bar spoons of full-bodied red wine floated on top, it is often referred to as a New York Sour. It is shaken and served either straight up or over ice.' - instructions: |- - 1. Pour all ingredients into a cocktail shaker filled with ice. - 2. Shake well. - 3. Strain into cobbler glass. - 4. If served “On the rocks”, strain ingredients into an old fashioned glass filled with ice. - - NOTE: - If egg white is used shake little harder to release and incorporate the foam from the egg white. + instructions: "1. Pour all ingredients into a cocktail shaker filled with ice.\n2. Shake well.\n3. Strain into cobbler glass.\n4. If served “On the rocks”, strain ingredients into an old fashioned glass filled with ice.\n\nNOTE:\nIf egg white is used shake little harder to release and incorporate the foam from the egg white." garnish: 'Garnish with a half orange slice and maraschino cherry, optionally use orange zest.' source: 'https://iba-world.com/whiskey-sour/' - images: - - copyright: VinePair glass: Lowball method: Shake + abv: 16 tags: - 'IBA Cocktail' - 'The Unforgettables' @@ -3208,105 +4074,144 @@ - Sour ingredients: - + name: 'Bourbon Whiskey' amount: 45 units: ml - name: 'Bourbon Whiskey' optional: false - + name: 'Lemon juice' amount: 25 units: ml - name: 'Lemon juice' optional: false - + name: 'Simple Syrup' amount: 20 units: ml - name: 'Simple Syrup' optional: false - + name: 'Egg White' amount: 1 units: drops - name: 'Egg white' optional: true + images: + - + resource_path: cocktails/whiskey-sour.jpg + copyright: VinePair + placeholder_hash: ZKcJNxBHiYh0cYeVf5eJeKV3aSAHNnAD - name: 'White Lady' description: "The original recipe for the White Lady was devised by Harry MacElhone in 1919 at Ciro's Club in London. He originally used crème de menthe, but replaced it with gin at Harry's New York Bar in Paris in 1929. According to the American Bar at the Savoy Hotel, the drink was created there by Harry Craddock." instructions: 'Pour all ingredients into a cocktail shaker, shake well with ice, strain into a chilled cocktail glass.' - garnish: N/A + garnish: null source: 'https://iba-world.com/white-lady/' - images: - - copyright: 'BBC Good Food' glass: Coupe method: Shake + abv: 24.89 tags: - 'IBA Cocktail' - 'The Unforgettables' - Gin ingredients: - + name: Gin amount: 40 units: ml - name: Gin optional: false - + name: 'Triple Sec' amount: 30 units: ml - name: 'Triple Sec' optional: false - + name: 'Lemon juice' amount: 20 units: ml - name: 'Lemon juice' optional: false + images: + - + resource_path: cocktails/white-lady.jpg + copyright: 'BBC Good Food' + placeholder_hash: XAgKBwA2hYdRZ4h6igeXiKd1CytDwDAG +- + name: 'White Negroni' + description: "The White Negroni is a fabulous use of the gentian's powers, and a downright brilliant twist on a bonafide classic. All of the flavor components of a classic Negroni are present, but only the gin remains verbatim." + instructions: "1. Combine all ingredients with ice and stir\n2. Strain into a lowball glass" + garnish: 'Lemon peel' + source: 'https://tuxedono2.com/white-negroni-cocktail-recipe' + glass: Lowball + method: Stir + abv: 20 + tags: + - Gin + ingredients: + - + name: Gin + amount: 30 + units: ml + optional: false + - + name: Suze + amount: 30 + units: ml + optional: false + - + name: 'Lillet Blanc' + amount: 30 + units: ml + optional: false + images: + - + resource_path: cocktails/white-negroni.jpg + copyright: 'A Couple Cooks' + placeholder_hash: URkKLwRKSfqAdnmGfRmYqYeJqQJ4aJAJ - name: 'Yellow Bird' description: null instructions: 'Pour all ingredients into a cocktail shaker, shake well with ice, strain into chilled cocktail glass.' - garnish: N/A + garnish: null source: 'https://iba-world.com/yellow-bird/' - images: - - copyright: 'Cooking to Entertain' glass: Coupe method: Shake + abv: 25.97 tags: - 'IBA Cocktail' - 'New Era Drinks' - Rum ingredients: - + name: 'White Rum' amount: 30 units: ml - name: 'White Rum' optional: false - + name: Galliano amount: 15 units: ml - name: Galliano optional: false - + name: 'Triple Sec' amount: 15 units: ml - name: 'Triple Sec' optional: false - + name: 'Lime juice' amount: 15 units: ml - name: 'Lime juice' optional: false + images: + - + resource_path: cocktails/yellow-bird.jpg + copyright: 'Cooking to Entertain' + placeholder_hash: GOoJLwJViHiHiXhqigaXuGeFavY2CpMC - name: Zombie description: "It first appeared in late 1934, invented by Donn Beach at his Hollywood Don the Beachcomber restaurant. It was popularized on the East coast soon afterwards at the 1939 New York World's Fair." - instructions: |- - 1. Add all ingredients into an electric blender with 170 grams of cracked ice. - 2. With pulse bottom blend for a few seconds. - 3. Serve in a tall tumbler glass. - - Donn’s Mix: 2 parts of fresh yellow grapefruit and 1 part of cinnamon syrup + instructions: "1. Add all ingredients into an electric blender with 170 grams of cracked ice.\n2. With pulse bottom blend for a few seconds.\n3. Serve in a tall tumbler glass.\n\nDonn’s Mix: 2 parts of fresh yellow grapefruit and 1 part of cinnamon syrup" garnish: 'Garnish with mint leaves.' source: 'https://iba-world.com/zombie/' - images: - - copyright: 'Punch Drink' glass: Highball method: Blend + abv: 23.41 tags: - 'Contemporary Classics' - 'IBA Cocktail' @@ -3314,47 +4219,306 @@ - Tiki ingredients: - + name: 'Jamaican Rum' amount: 45 units: ml - name: 'Jamaican rum' optional: false - + name: 'Gold Rum' amount: 45 units: ml - name: 'Gold Rum' optional: false - + name: 'Demerara Rum' amount: 30 units: ml - name: 'Demerara Rum' optional: false - + name: 'Lime juice' amount: 20 units: ml - name: 'Lime juice' optional: false - + name: Falernum amount: 15 units: ml - name: Falernum optional: false - + name: "Donn's Mix" amount: 15 units: ml - name: "Donn's Mix" optional: false - + name: 'Grenadine Syrup' amount: 1 units: tsp - name: 'Grenadine Syrup' optional: false - + name: 'Angostura aromatic bitters' amount: 1 units: dash - name: 'Angostura aromatic bitters' optional: false - + name: Pernod amount: 6 units: drops - name: Pernod + optional: false + images: + - + resource_path: cocktails/zombie.jpg + copyright: 'Punch Drink' + placeholder_hash: rggKJwT2O5R5lnmGeXeIh3iImXB4CHEH +- + name: 'Štrukani Pelin' + description: 'Štrukani pelinkovac (or štrukani pelin) is a somewhat novel Croatian drink that combines pelinkovac (traditional herbal liqueur) with lemon juice. In its basic form, the drink is made with pelinkovac that is poured into an ice-filled glass.' + instructions: "1. A lemon slice is then squeezed directly into the glass and mixed.\n\nThe amount of lemon juice can vary, sometimes resulting in a drink with equal amounts of both ingredients. More elaborate versions often add orange juice, citrus zest, or spices." + garnish: 'Lemon wedge' + source: 'https://www.journal.hr/lifestyle/gastro/antique-pelinkovac-strukani-pelin-jesenski-koktel-cool-pice/' + glass: Lowball + method: Build + abv: 9.19 + tags: { } + ingredients: + - + name: Pelinkovac + amount: 30 + units: ml + optional: false + - + name: 'Lemon juice' + amount: 15 + units: ml + optional: false + - + name: Tonic + amount: 50 + units: ml + optional: true + images: + - + resource_path: cocktails/strukani-pelin.jpg + copyright: Tasteatlas + placeholder_hash: owgOHwbY2YePdpeZhSand1h2R6AZFpcA +- + name: 'Final Ward' + instructions: |- + 1. Combine all the ingredients into your cocktail shaker + 2. Shake with ice for 12 seconds + 3. Strain into a chilled cocktail glass + garnish: 'Cocktail cherry at the bottom of the glass' + description: |- + The Final Ward is perhaps the best-know riff on the classic Last Word and was created in 2007 by Phil Ward at Death & Co. NYC. It is still comprised of equal parts with rye whiskey replacing the gin and fresh lemon juice in place of lime. + + The Last Word is a gin-based prohibition-era cocktail which was rediscovered in 2004 and becoming a cult hit in the Seattle area. + source: 'https://stevethebartender.com.au/final-ward-cocktail-recipe-last-word-riff/' + tags: + - Citrusy + - Herbacious + glass: 'Nick and Nora' + method: Shake + abv: 25.4 + images: + - + resource_path: cocktails/final-ward.jpg + copyright: 'The Educated Barfly' + placeholder_hash: 'nBgSFwJXmHhzdoh/eKeXWId4iUT4aIIP' + ingredients: + - + sort: 1 + name: 'Rye whiskey' + amount: 22.5 + amount_max: null + note: null + units: ml + optional: false + - + sort: 2 + name: 'Green Chartreuse' + amount: 22.5 + amount_max: null + note: null + units: ml + optional: false + - + sort: 3 + name: Maraschino + amount: 22.5 + amount_max: null + note: null + units: ml + optional: false + - + sort: 4 + name: 'Lemon juice' + amount: 22.5 + amount_max: null + note: null + units: ml + optional: false +- + name: 'Hugo Spritz' + instructions: 'Combine everything in wine glass with ice' + garnish: 'Mint sprig and lime wedge' + description: 'As reported by the magazines Mixology and Der Spiegel, the Hugo was conceived in 2005 by Naturns bar manager Roland Gruber (aka A.K.) at San Zeno Bar, as an alternative to Spritz Veneziano, and quickly spread beyond the borders of South Tyrol. Initially, the recipe provided for the use of lemon balm syrup, then in practice replaced by elderflower syrup, more easily available.' + source: 'https://en.wikipedia.org/wiki/Hugo_(cocktail)' + tags: + - Wine + - Spritz + - Summer + glass: Wine + method: Build + abv: 11.06 + images: + - + resource_path: cocktails/hugo-spritz.jpg + copyright: 'sipandfeast' + placeholder_hash: '8ugJFwK8fonFg2iNZPiGltN5VYAUncAP' + ingredients: + - + sort: 1 + name: Prosecco + amount: 90 + amount_max: null + note: null + units: ml + optional: false + - + sort: 2 + name: St-Germain + amount: 60 + amount_max: null + note: null + units: ml + optional: false + substitutes: + - 'Elderflower Cordial' + - + sort: 3 + name: 'Club soda' + amount: 30 + amount_max: null + note: null + units: ml + optional: false +- + name: "Gin Basil Smash" + instructions: |- + 1. Muddle basil leaves in a shaker tin. + 2. Add remaining ingredients and ice and shake until chilled. + 3. Double strain into a rocks glass filled with ice. + 4. Garnish with basil leaves." + garnish: 'Basil leaves' + description: 'At Le Lion where the drink was created in 2008, 300 to 500 have been sold every week since its debut—22,000 a year—and the bar goes through more than 3,100 bottles of gin every twelve months. The drink is served at bars all across Germany—so much so that one bartender, griping about the amount of basil-muddling required to make it, once called it “Meyer’s curse.”' + source: 'https://punchdrink.com/recipes/gin-basil-smash/' + tags: + - Summer + - Bright + - Smash + glass: Lowball + method: Shake + abv: 17.07 + images: + - + resource_path: cocktails/gin-basil-smash.jpg + copyright: 'Punch Staff' + placeholder_hash: EwkOFwQFeKmhiGdia4h4d3h3iAM85oAK + ingredients: + - + sort: 1 + name: Gin + amount: 60 + amount_max: null + note: null + units: ml + optional: false + - + sort: 2 + name: Basil + amount: 10 + amount_max: null + note: null + units: leaves + optional: false + - + sort: 3 + name: 'Lemon juice' + amount: 30 + amount_max: null + note: null + units: ml + optional: false + - + sort: 4 + name: 'Simple Syrup' + amount: 22.5 + amount_max: null + note: null + units: ml + optional: false +- + name: 'Picante de la Casa' + instructions: |- + 1. Cut a small piece of chili (about ¼ inch wide) and press with muddler in the shaker tin. + 2. Hand clap the cilantro (to release the aroma, place the leaves or sprig in one hand and gently smack it with the other – this warms up the garnish slightly and starts to extract the oils before you use it), and drop in. + 3. Add the rest of the ingredients, shake and fine strain into an ice-filled rocks glass (a short tumbler used for serving spirits). + 4. Cut off the top end the chili pepper and place – stem upwards – on top. + garnish: 'Top end (stem) of the chilli pepper' + description: |- + Lovers of the margarita, we have a treat for you here! The Picante de la Casa Cocktail, also called the Soho House Picante or Soho House Tonic, is a creation of the Soho House Club. That is a group of private members' clubs with its origin in Soho in the Londoner West End. The cocktail, however, does not have its roots in England but in West Hollywood, USA. -Although, actually, it looks like it could be straight out of Mexico. + + Soho House have shared the recipe for their picante de la casa. The picante is based on the much-loved margarita, but the picante cocktail ingredients includes chilli and coriander – to add a little extra heat and spice. It is revelatory – and a drink you’ll be coming back to. + source: 'https://www.sohohouse.com/en-us/house-notes/issue-4/house-favourites-how-to-make-a-picante' + tags: + - Spicy + - Margarita + glass: Lowball + method: Shake + abv: 17.07 + images: + - + resource_path: cocktails/picante-de-la-casa.jpg + copyright: SohoHouse + placeholder_hash: '1/cJDwJOpoqdiHaKivdot4h5BJS3QGYO' + ingredients: + - + sort: 1 + name: 'Tequila Reposado' + amount: 60 + amount_max: null + note: null + units: ml + optional: false + - + sort: 2 + name: 'Agave Syrup' + amount: 22.5 + amount_max: null + note: null + units: ml + optional: false + substitutes: + - 'Honey Syrup' + - + sort: 3 + name: 'Lime juice' + amount: 30 + amount_max: null + note: null + units: ml + optional: false + - + sort: 4 + name: 'Chilli Pepper' + amount: 0.25 + amount_max: null + note: null + units: inch + optional: false + - + sort: 5 + name: Cilantro + amount: 10 + amount_max: null + note: null + units: leaves optional: false diff --git a/resources/data/base_glasses.yml b/resources/data/base_glasses.yml new file mode 100644 index 00000000..6e9bca01 --- /dev/null +++ b/resources/data/base_glasses.yml @@ -0,0 +1,51 @@ +- + name: 'Cocktail' + description: "A cocktail glass is a stemmed glass with an inverted cone bowl, mainly used to serve straight-up cocktails. The term cocktail glass is often used interchangeably with martini glass, despite their differing slightly. A standard cocktail glass contains 90 to 300 millilitres." +- + name: 'Lowball' + description: 'The old fashioned glass, otherwise known as the rocks glass and lowball glass (or simply lowball), is a short tumbler used for serving spirits, such as whisky, neat or with ice cubes ("on the rocks"). Old fashioned glasses usually contain 180–300 ml.' +- + name: 'Highball' + description: 'A highball glass is a glass tumbler that can contain 240 to 350 millilitres (8 to 12 US fl oz).' +- + name: 'Shot' + description: 'A shot glass is a glass originally designed to hold or measure spirits or liquor, which is either imbibed straight from the glass ("a shot") or poured into a cocktail ("a drink").' +- + name: 'Coupe' + description: 'The champagne coupe is a shallow, broad-bowled saucer shaped stemmed glass generally capable of containing 180 to 240 ml (6.1 to 8.1 US fl oz) of liquid.' +- + name: 'Margarita' + description: 'A variant of the classic champagne coupe.' +- + name: 'Wine' + description: 'A wine glass is a type of glass that is used to drink and taste wine. Most wine glasses are stemware (goblets), i.e., they are composed of three parts: the bowl, stem, and foot.' +- + name: 'Champagne' + description: 'A champagne glass is stemware designed for champagne and other sparkling wines.' +- + name: 'Hurricane' + description: 'A hurricane glass is a form of drinking glass which typically will contain 20 US fluid ounces.' +- + name: 'Nick and Nora' + description: 'A Nick & Nora glass is a stemmed glass with an inverted bowl, mainly used to serve straight-up cocktails. The glass is similar to a cocktail glass or martini glass.' +- + name: 'Fizzio' + description: 'Wide flat bowl on a stem.' +- + name: 'Sour' + description: 'Tulip shaped with a fatter stem.' +- + name: 'Julep' + description: 'Metal bucket shaped glass.' +- + name: 'Absinthe' + description: 'Absinthe glasses have a reservoir in the stem to measure the correct amount of Absinthe for one serving.' +- + name: 'Glass mug' + description: 'A mug made of glass.' +- + name: 'Copper mug' + description: 'A mug made of copper.' +- + name: 'Tiki' + description: 'The term "tiki mug" is a blanket term for the sculptural drinkware even though they vary in size and most do not contain handles.' diff --git a/resources/data/base_ingredient_categories.yml b/resources/data/base_ingredient_categories.yml new file mode 100644 index 00000000..7656ccc5 --- /dev/null +++ b/resources/data/base_ingredient_categories.yml @@ -0,0 +1,27 @@ +- + name: 'Uncategorized' + description: null +- + name: 'Spirits' + description: 'Alcoholic drinks produced by distillation of grains, fruits, vegetables, or sugar, that have already gone through alcoholic fermentation.' +- + name: 'Liqueurs' + description: 'Alcoholic drinks composed of spirits (often rectified spirit) and additional flavorings such as sugar, fruits, herbs, and spices.' +- + name: 'Juices' + description: 'Drinks made from the extraction or pressing of the natural liquid contained in fruit and vegetables.' +- + name: 'Fruits and vegetables' + description: null +- + name: 'Syrups' + description: 'Condiment that is a thick, viscous liquid consisting primarily of a solution of sugar in water, containing a large amount of dissolved sugars but showing little tendency to deposit crystals.' +- + name: 'Wines' + description: null +- + name: 'Bitters' + description: null +- + name: 'Beverages' + description: null diff --git a/resources/data/base_ingredients.yml b/resources/data/base_ingredients.yml new file mode 100644 index 00000000..3b6f7725 --- /dev/null +++ b/resources/data/base_ingredients.yml @@ -0,0 +1,1528 @@ +- + name: Absinthe + category: Spirits + strength: 40 + description: 'Anise-flavoured spirit derived from several plants, including wormwood.' + color: '#b7ca8e' + origin: France + images: + - + resource_path: ingredients/absinthe.png + copyright: null + placeholder_hash: m4qBFQIrY/SRu4XLp/d3e3CIB1eCdoRneA +- + name: 'Agave Syrup' + category: Syrups + strength: 0 + description: 'Syrup made from agave.' + color: '#deca3f' + origin: null + images: + - + resource_path: ingredients/agave-syrup.png + copyright: null + placeholder_hash: 6HqKRQo8iXaAeXioh3mPh/Z3CCiHd4d3eA +- + name: Amaretto + category: Liqueurs + strength: 24 + description: 'Sweet almond-flavored liqueur' + color: '#d62b0e' + origin: Italy + images: + - + resource_path: ingredients/amaretto.png + copyright: null + placeholder_hash: 3YmGDQRJONh0i3+Dp+OvRv5ZB1iFiIZoeA +- + name: 'Amaro Nonino' + category: Liqueurs + strength: 35 + description: 'Sweet amaro' + color: '#c16e4b' + origin: Italy + images: + - + resource_path: ingredients/amaro-nonino.png + copyright: null + placeholder_hash: I1mGDQRISeaEeXv158iPg/toB3iGeHZodw +- + name: 'Angostura aromatic bitters' + category: Bitters + strength: 44.7 + description: 'Angostura bitters is a concentrated bitters (herbal alcoholic preparation) based on gentian, herbs, and spices, by House of Angostura in Trinidad and Tobago.' + color: '#e95310' + origin: 'Trinidad & Tobago' + images: + - + resource_path: ingredients/angostura-aromatic-bitters.png + copyright: null + placeholder_hash: MAiGBQAPaQd5iHeYxwl3yP3+CQmWlod3eA +- + name: 'Angostura cocoa bitters' + category: Bitters + strength: 38 + description: 'Top notes of rich bitter, floral, nutty cocoa with a bold infusion of aromatic botanicals provide endless possibilities to remix classic cocktails.' + color: '#894c36' + origin: 'Trinidad & Tobago' + images: + - + resource_path: ingredients/angostura-cocoa-bitters.png + copyright: null + placeholder_hash: 7AeGBQIPCQmLh31mNwZ3dRB3AQF3d4h4dw +- + name: Aperol + category: Liqueurs + strength: 11 + description: 'Italian bitter apéritif made of gentian, rhubarb and cinchona, among other ingredients.' + color: '#fa4321' + origin: Italy + images: + - + resource_path: ingredients/aperol.png + copyright: null + placeholder_hash: o6mGFQhYmDiAfn1jV4iPiPioCHeGeHZ4dw +- + name: Apple + category: 'Fruits and vegetables' + strength: 0 + description: 'Apple fruit' + color: null + origin: null + images: + - + resource_path: ingredients/apple.png + copyright: null + placeholder_hash: FuqCFQQsoXGNVOwHeziPiVUHJ1h3iHB0Rw +- + name: 'Apricot Brandy' + category: Spirits + strength: 40 + description: 'Liquor distilled from fermented apricot juice or a liqueur made from apricot flesh and kernels.' + color: '#ca5210' + origin: Worldwide + images: + - + resource_path: ingredients/apricot-brandy.png + copyright: null + placeholder_hash: H4uCDQIzaNafUXt0iIeAegiXB9iGeXeIeA +- + name: 'Baileys Irish Cream' + category: Liqueurs + strength: 17 + description: 'Baileys Irish Cream is an Irish cream liqueur, an alcoholic drink flavoured with cream, cocoa and Irish whiskey. It is made by Diageo at Nangor Road, in Dublin, Ireland and in Mallusk, Northern Ireland. It is the original Irish cream, invented by a team headed by Tom Jago in 1971 for Gilbeys of Ireland.' + color: null + origin: Ireland + images: + - + resource_path: ingredients/baileys-irish-cream.png + copyright: null + placeholder_hash: USiKDQItSDiAa3mniAhxhREHBzeEhXRleA +- + name: 'Blue Curaçao' + category: Liqueurs + strength: 20 + description: 'Curaçao with added blue dye.' + color: '#0192fe' + origin: Netherlands + images: + - + resource_path: ingredients/blue-curacao.png + copyright: null + placeholder_hash: 55WRRQ48iHdweXioiIdweQiXCCiHd4d3eA +- + name: 'Bourbon Whiskey' + category: Spirits + strength: 40 + description: 'Barrel-aged distilled liquor made primarily from corn.' + color: '#d54a06' + origin: 'North America' + images: + - + resource_path: ingredients/bourbon-whiskey.png + copyright: null + placeholder_hash: 35mGBQJFeYR1mnj3KHqHgFgIB6iFeXaIhw +- + name: Brandy + category: Spirits + strength: 40 + description: 'Liquor produced by distilling wine.' + color: '#e66500' + origin: Worldwide + images: + - + resource_path: ingredients/brandy.png + copyright: null + placeholder_hash: 03mCHQY7amdqcm/l6FKmPwfLGHiAdHaIiA +- + name: Bénédictine + category: Liqueurs + strength: 40 + description: 'Herbal liqueur flavored with twenty-seven flowers, berries, herbs, roots, and spices.' + color: '#f39100' + origin: France + images: + - + resource_path: ingredients/benedictine.png + copyright: null + placeholder_hash: W0mGDQJFSPeJiI9BlySvN6QKB5h0iYeYdw +- + name: Cachaça + category: Spirits + strength: 40 + description: 'Distilled spirit made from fermented sugarcane juice.' + color: '#ffffff' + origin: Brazil + images: + - + resource_path: ingredients/cachaca.png + copyright: null + placeholder_hash: KCmKFQJJqCaJdX+Chml/cWoEB1h4iId3eA +- + name: Calvados + category: Spirits + strength: 40 + description: 'Brandy made from apples or pears.' + color: '#ca5210' + origin: France + images: + - + resource_path: ingredients/calvados.png + copyright: null + placeholder_hash: YsqGFQY7JYiNl49Dl/iVlQ0HB3hwhIeYeA +- + name: Campari + category: Liqueurs + strength: 25 + description: 'Italian alcoholic liqueur obtained from the infusion of herbs and fruit.' + color: '#ca101e' + origin: Italy + images: + - + resource_path: ingredients/campari.png + copyright: null + placeholder_hash: HomGDQRGaLZzjHf4eIeNb/joCKeGeHZ4dw +- + name: Chambord + category: Liqueurs + strength: 16.5 + description: 'Raspberry liqueur modelled after a liqueur produced in the Loire Valley of France during the late 17th century.' + color: '#6f1123' + origin: Worldwide + images: + - + resource_path: ingredients/chambord.png + copyright: null + placeholder_hash: ExiKDQBJWLh7hXj2WFh/evKYB2h0h3WXhw +- + name: 'Chamomile cordial' + category: Juices + strength: 0 + description: 'Herbal juice made from chamomile.' + color: '#e2dccc' + origin: null + images: + - + resource_path: ingredients/chamomile-cordial.png + copyright: null + placeholder_hash: tQiCBQBWi3BrpZWoZlXAXpQEB5iHeHd4eA +- + name: Champagne + category: Wines + strength: 12 + description: 'Sparkling wine.' + color: '#f6e1b0' + origin: France + images: + - + resource_path: ingredients/champagne.png + copyright: null + placeholder_hash: nQmCDQAzeJV6hY8xB3eOgNf3CNeFeXeIiA +- + name: 'Chilli Pepper' + category: 'Fruits and vegetables' + strength: 0 + description: 'Hot pepper' + color: null + origin: null + images: + - + resource_path: ingredients/chilli-pepper.png + copyright: null + placeholder_hash: WaqCBQISmbqMdoQp9/9rBlB5OmaDeVD7aQ +- + name: 'Club soda' + category: Beverages + strength: 0 + description: 'Club soda is a manufactured form of carbonated water, commonly used as a drink mixer.' + color: '#fff' + origin: Worldwide + images: + - + resource_path: ingredients/club-soda.png + copyright: null + placeholder_hash: 7riFBQQeDJp6eHepZ5qQypCHCAiGhoZ2eA +- + name: 'Coconut Cream' + category: Uncategorized + strength: 0 + description: 'Opaque, milky-white liquid extracted from the grated pulp of mature coconuts.' + color: null + origin: null + images: + - + resource_path: ingredients/coconut-cream.png + copyright: null + placeholder_hash: LQiCBQBJa3BtpXVpmWOgBiN7B1iIh4aIeA +- + name: Coffee + category: Beverages + strength: 0 + description: 'Coffee is a drink prepared from roasted coffee beans.' + color: '#fff' + origin: Africa + images: + - + resource_path: ingredients/coffee.png + copyright: null + placeholder_hash: MwiKDQI52EhzinrzWCd/hfFHJ2eEh4CHVw +- + name: Cognac + category: Spirits + strength: 40 + description: 'A variety of brandy named after the commune of Cognac, France.' + color: '#7b1c0a' + origin: France + images: + - + resource_path: ingredients/cognac.png + copyright: null + placeholder_hash: X0mGDQJGdYuWaI9iF1hzj6UHB4iEeYeYiA +- + name: Cointreau + category: Liqueurs + strength: 40 + description: 'Orange-flavoured triple sec liqueur, it was originally called Curaçao Blanco Triple Sec. Usually more dry tasting than Orange Curaçao.' + color: '#ffffff' + origin: France + images: + - + resource_path: ingredients/cointreau.png + copyright: null + placeholder_hash: oJqOHQQeB2eFe3uHh/iqaf+KBzdwcoEzSA +- + name: Cola + category: Beverages + strength: 0 + description: 'Cola is a carbonated soft drink flavored with vanilla, cinnamon, citrus oils and other flavorings.' + color: '#411919' + origin: Worldwide + images: + - + resource_path: ingredients/cola.png + copyright: null + placeholder_hash: GpmOHQoei1R8h3BYeGeccMUIBQZ2hneHeA +- + name: 'Cranberry juice' + category: Juices + strength: 0 + description: 'Juice made from cranberries.' + color: '#9c0024' + origin: null + images: + - + resource_path: ingredients/cranberry-juice.png + copyright: null + placeholder_hash: EhmHDQBWuQWId4mGiE99pJAXB5iHeHd4eA +- + name: Cream + category: Uncategorized + strength: 0 + description: 'Cream is a dairy product composed of the higher-fat layer skimmed from the top of milk before homogenization.' + color: null + origin: null + images: + - + resource_path: ingredients/cream.png + copyright: null + placeholder_hash: 7BiCBQAjqSV7hHM4+Dh/cvAoCJeFeXHMhw +- + name: 'Crème de Violette' + category: Liqueurs + strength: 16 + description: 'Crème de violette is a delicate, barely-sweet liqueur made from violet flower petals.' + color: '#a5a2fd' + origin: Worldwide + images: + - + resource_path: ingredients/creme-de-violette.png + copyright: null + placeholder_hash: TxeGDQAt2Ad0iXLalwd7eU+JBxeJiYVmZw +- + name: 'Crème de cassis (blackcurrant liqueur)' + category: Liqueurs + strength: 25 + description: 'It is made from blackcurrants that are crushed and soaked in alcohol, with sugar subsequently added.' + color: '#282722' + origin: France + images: + - + resource_path: ingredients/creme-de-cassis-blackcurrant-liqueur.png + copyright: null + placeholder_hash: 2BeGBQBFd4iDfI9hp3Z5f/eHCLeGeHd4dw +- + name: 'Crème de mûre (blackberry liqueur)' + category: Liqueurs + strength: 42.3 + description: 'Crème de mûre is a liqueur made with fresh blackberries.' + color: '#5f1933' + origin: France + images: + - + resource_path: ingredients/creme-de-mure-blackberry-liqueur.png + copyright: null + placeholder_hash: 1QeGBQBGlzmCfI8x6HaHf7b4CJd2eHZ4dw +- + name: 'Dark Crème de Cacao' + category: Liqueurs + strength: 25 + description: 'Dark brown creamy chocolate-flavored liqueur made from cacao seed.' + color: '#0b0504' + origin: France + images: + - + resource_path: ingredients/dark-creme-de-cacao.png + copyright: null + placeholder_hash: EQiGBQBI9zhzd4pWZ4tHv3f1CHdzd3iYeA +- + name: 'Dark Rum' + category: Spirits + strength: 40 + description: 'Rum made from caramelized sugar or molasses.' + color: '#ca5210' + origin: Caribbean + images: + - + resource_path: ingredients/dark-rum.png + copyright: null + placeholder_hash: UiiGDQQPdTZ0eHA4yBiPiPDICAh4eId3dw +- + name: 'Demerara Rum' + category: Spirits + strength: 40 + description: 'Rum made with demerara sugar' + color: '#ca5210' + origin: Caribbean + images: + - + resource_path: ingredients/demerara-rum.png + copyright: null + placeholder_hash: ZhiGBQA0h2iAbnzkl2d4cPdnB8mGeYd4iA +- + name: "Donn's Mix" + category: Syrups + strength: 0 + description: '2 parts fresh yellow grapefruit and 1 part cinnamon syrup' + color: '#c6972c' + origin: null + images: + - + resource_path: ingredients/donns-mix.png + copyright: null + placeholder_hash: XUmGJQpYeJeAfXeYp3h/ivfHB2iHiId3eA +- + name: Drambuie + category: Liqueurs + strength: 40 + description: 'Liqueur made from Scotch whisky, heather honey, herbs and spices.' + color: '#ea7e00' + origin: Scotland + images: + - + resource_path: ingredients/drambuie.png + copyright: null + placeholder_hash: lSiGDQBGaaVyjXj4V1dwfeH3B5iGeHZ4iA +- + name: 'Dry Curaçao' + category: Liqueurs + strength: 40 + description: 'While Curaçao and sweet oranges are the main ingredients, vanilla, prunes and lemon peel are amongst the other botanicals called for in the old recipe.' + color: '#ffc613' + origin: Italy + images: + - + resource_path: ingredients/dry-curacao.png + copyright: null + placeholder_hash: 7EmGHQJJiFeAenU3mFh/jfa3BliHh4d3eA +- + name: 'Dry Sherry' + category: Wines + strength: 17 + description: 'Fortified wine made from white grapes that are grown near the city of Jerez de la Frontera in Andalusia, Spain.' + color: '#8c4122' + origin: Spain + images: + - + resource_path: ingredients/dry-sherry.png + copyright: null + placeholder_hash: mgiKHQIs91aCiHtVlyePeDC4BzeEhXRmeA +- + name: 'Dry Vermouth' + category: Wines + strength: 18 + description: 'Aromatized fortified wine.' + color: '#ffffff' + origin: Worldwide + images: + - + resource_path: ingredients/dry-vermouth.png + copyright: null + placeholder_hash: I+mFDQAziHZwj4iXWHd+gAj4B9iGiYd4hw +- + name: 'Egg White' + category: Uncategorized + strength: 0 + description: 'Chicken egg without yolk.' + color: null + origin: null + images: + - + resource_path: ingredients/egg-white.png + copyright: null + placeholder_hash: HnuCBQA5w0hPppgmWi+k8wFLR3iKeYBXZw +- + name: 'Egg Yolk' + category: Uncategorized + strength: 0 + description: 'Yolk from chicken egg' + color: null + origin: null + images: + - + resource_path: ingredients/egg-yolk.png + copyright: null + placeholder_hash: 3cuCBQAjZWmNZJQL5gOMQaAoB5h4h4B8mA +- + name: 'Elderflower Cordial' + category: Juices + strength: 0 + description: 'Herbal juice made from elderflower.' + color: '#d9cfae' + origin: null + images: + - + resource_path: ingredients/elderflower-cordial.png + copyright: null + placeholder_hash: 8RiCBQBXfGBrhpandsHgjsECB5iHeHd4eA +- + name: Espresso + category: Beverages + strength: 0 + description: 'Espresso is generally thicker than coffee brewed by other methods, with a viscosity similar to that of warm honey.' + color: '#fff' + origin: Italy + images: + - + resource_path: ingredients/espresso.png + copyright: null + placeholder_hash: YxiGBQA0l0h7hYsEGGiPgNYICJd3iHJ7eA +- + name: Falernum + category: Liqueurs + strength: 11 + description: 'Liqueur with flavors of ginger, lime, and almond, and frequently cloves or allspice. It may be thought of as a spicier version of orgeat syrup.' + color: '#f4f2e5' + origin: Caribbean + images: + - + resource_path: ingredients/falernum.png + copyright: null + placeholder_hash: qAiGBQAzeIZ8k4CeSIaQlqgGB9h2iYd4hw +- + name: 'Fernet Branca' + category: Liqueurs + strength: 39 + description: 'Fernet Branca is a bittersweet, herbal liqueur made with a number of different herbs and spices, including myrrh, rhubarb, chamomile, cardamom, aloe, and gentian root.' + color: null + origin: Italy + images: + - + resource_path: ingredients/fernet-branca.png + copyright: null + placeholder_hash: EQiOFQIeN5iAfIe4h1eAexj4CAiHh3d3dw +- + name: Galliano + category: Liqueurs + strength: 42.3 + description: 'Galliano is sweet with vanilla-anise flavour and subtle citrus and woodsy herbal undernotes.' + color: '#caa701' + origin: Italy + images: + - + resource_path: ingredients/galliano.png + copyright: null + placeholder_hash: KBqCDQAzVf6aVYvkp3d/eAiIB9iGeXd4iA +- + name: Gin + category: Spirits + strength: 40 + description: 'Distilled alcoholic drink that derives its flavour from juniper berries.' + color: '#ffffff' + origin: Worldwide + images: + - + resource_path: ingredients/gin.png + copyright: null + placeholder_hash: ZwiGDQBIxgqIdnlGWEeFXwH4B2iGeIZ4eA +- + name: Ginger + category: 'Fruits and vegetables' + strength: 0 + description: 'Ginger root used as a spice' + color: null + origin: null + images: + - + resource_path: ingredients/ginger.png + copyright: null + placeholder_hash: 5xmGDQI1qDWCi3f2t8DAlAhpBnmFiIKaaA +- + name: 'Ginger beer' + category: Beverages + strength: 0 + description: 'Ginger beer is a sweetened and carbonated, usually non-alcoholic beverage.' + color: '#fff' + origin: Worldwide + images: + - + resource_path: ingredients/ginger-beer.png + copyright: null + placeholder_hash: 6ymCBQBFaLd7hYBu15h8gHn3B8h2iId4iA +- + name: 'Ginger syrup' + category: Syrups + strength: 0 + description: 'Syrup made from ginger root.' + color: '#c6972c' + origin: null + images: + - + resource_path: ingredients/ginger-syrup.png + copyright: null + placeholder_hash: 9BiGHQA8aneAeHRpp3d/iPLGCCiHd4d3eA +- + name: 'Gold Rum' + category: Spirits + strength: 40 + description: 'Medium-bodied rum aged in wooden barrels.' + color: '#c79141' + origin: null + images: + - + resource_path: ingredients/gold-rum.png + copyright: null + placeholder_hash: JEqGDQBFh1mGeYT6Z1eOf/W3CLd2iId4dw +- + name: 'Gomme Syrup' + category: Syrups + strength: 0 + description: 'A thicker simple syrup mixed with arabica gum powder.' + color: '#e6dfcc' + origin: null + images: { } +- + name: 'Grand Marnier' + category: Liqueurs + strength: 40 + description: 'Orange-flavored liqueur made from a blend of Cognac brandy, distilled essence of bitter orange, and sugar.' + color: '#f34e02' + origin: France + images: + - + resource_path: ingredients/grand-marnier.png + copyright: null + placeholder_hash: 0FiGDQJHSMh5eH9BiDd/dPI3B4iDiIiIdw +- + name: 'Grapefruit juice' + category: Juices + strength: 0 + description: 'Freshly squeezed grapefruit juice.' + color: '#ed7500' + origin: null + images: + - + resource_path: ingredients/grapefruit-juice.png + copyright: null + placeholder_hash: XdyGDQJH2QCmZVtnug9sY4A1B5iHd3h4iA +- + name: Grappa + category: Spirits + strength: 50 + description: 'Fragrant, grape-based pomace brandy.' + color: '#ffffff' + origin: Italy + images: + - + resource_path: ingredients/grappa.png + copyright: null + placeholder_hash: LgiCBQAiikB3p2E9mXWDYHgICOd2iXaJeA +- + name: 'Green Chartreuse' + category: Liqueurs + strength: 55 + description: 'Green Chartreuse is a naturally green liqueur made from 130 herbs and other plants macerated in alcohol and steeped for about eight hours.' + color: '#85993a' + origin: France + images: + - + resource_path: ingredients/green-chartreuse.png + copyright: null + placeholder_hash: XOmFDQJIh3h/gYlViGePgrkHB3h2iIZ4dw +- + name: 'Grenadine Syrup' + category: Syrups + strength: 0 + description: 'Fruit syrup made from pomegranates.' + color: '#bb0014' + origin: null + images: + - + resource_path: ingredients/grenadine-syrup.png + copyright: null + placeholder_hash: YjiWDQQ8iHiAenioh5yfqviJCCiHd4d3eA +- + name: 'Honey Syrup' + category: Syrups + strength: 0 + description: 'Syrup made from dissolving honey in water.' + color: '#f2a900' + origin: null + images: + - + resource_path: ingredients/honey-syrup.png + copyright: null + placeholder_hash: 7GqOVQo8iGeAeXioh3iPd/Z3CCiHd4d3eA +- + name: 'Irish whiskey' + category: Spirits + strength: 40 + description: 'Whiskey made on the island of Ireland.' + color: '#d54a06' + origin: Ireland + images: + - + resource_path: ingredients/irish-whiskey.png + copyright: null + placeholder_hash: 4QmKDQJYe3KAbHaZl1/ufAd3CHeHeHd4dw +- + name: 'Islay Scotch' + category: Spirits + strength: 40 + description: 'Scotch whisky made on Islay island.' + color: '#d54a06' + origin: Scotland + images: + - + resource_path: ingredients/islay-scotch.png + copyright: null + placeholder_hash: 4giGDQBFeIZ4h3H9eHePdccICLeGeHd4hw +- + name: 'Jamaican Rum' + category: Spirits + strength: 40 + description: 'Rum made in Jamaica.' + color: '#ca5210' + origin: Jamaica + images: + - + resource_path: ingredients/jamaican-rum.png + copyright: null + placeholder_hash: 4XmCBQA0Z8iHeXP8mKCpAYlLB7iFeYaJhw +- + name: 'Kahlua coffee liqueur' + category: Liqueurs + strength: 20 + description: 'Coffee liqueur made with rum, sugar and arabica coffee.' + color: '#1a0d0a' + origin: Mexico + images: + - + resource_path: ingredients/kahlua-coffee-liqueur.png + copyright: null + placeholder_hash: 3lqGHQBXSPWAfoaqh3hwlwUXCId3eHd4dw +- + name: Lemon + category: 'Fruits and vegetables' + strength: 0 + description: 'Lemon fruit' + color: null + origin: null + images: + - + resource_path: ingredients/lemon.png + copyright: null + placeholder_hash: pyyCDQI3VaGlXZK69fLeoQgdKFd2h4CZZw +- + name: 'Lemon juice' + category: Juices + strength: 0 + description: 'Freshly squeezed lemon juice.' + color: '#f3efda' + origin: null + images: + - + resource_path: ingredients/lemon-juice.png + copyright: null + placeholder_hash: NxmCDQBXXKBetaLZNaPALwrrB5iHeHd4eA +- + name: 'Lillet Blanc' + category: Wines + strength: 17 + description: 'Aromatized sweet wine.' + color: '#f7ec77' + origin: France + images: + - + resource_path: ingredients/lillet-blanc.png + copyright: null + placeholder_hash: LRmGJQJKuwOaZKGZqHZ/bPhmCDh3d4d3eA +- + name: Lime + category: 'Fruits and vegetables' + strength: 0 + description: 'Lime fruit' + color: null + origin: null + images: + - + resource_path: ingredients/lime.png + copyright: null + placeholder_hash: YuuFBQI7N81UaiDWuFJ8sAVXSWeVaJCGWQ +- + name: 'Lime juice' + category: Juices + strength: 0 + description: 'Freshly squeezed lime juice.' + color: '#e9f1d7' + origin: null + images: + - + resource_path: ingredients/lime-juice.png + copyright: null + placeholder_hash: 9/iBBQBXi3BfwqHsNmSgjvsFB5iHeHd4eA +- + name: Maraschino + category: Liqueurs + strength: 32 + description: 'Liqueur obtained from the distillation of Marasca cherries. The small, slightly sour fruit of the Tapiwa cherry tree, which grows wild along parts of the Dalmatian coast in Croatia, lends the liqueur its unique aroma.' + color: '#ffffff' + origin: Croatia + images: + - + resource_path: ingredients/maraschino.png + copyright: null + placeholder_hash: KQiKBQItB4h3h4iHh8qAiQOYBxd4eId3eA +- + name: 'Menthe Crème de Cacao' + category: Liqueurs + strength: 25 + description: 'Mint flavored chocolate liqueur.' + color: '#88ad91' + origin: France + images: + - + resource_path: ingredients/menthe-creme-de-cacao.png + copyright: null + placeholder_hash: EeiFBQBJ+DhzhotGZzxpD4WOCHdzd3iYeA +- + name: Mezcal + category: Spirits + strength: 40 + description: 'Distilled alcoholic beverage made from any type of agave.' + color: '#ffffff' + origin: Mexico + images: + - + resource_path: ingredients/mezcal.png + copyright: null + placeholder_hash: cwiGBQAP/IeLd3y12AaEhVBXCAiLi3eHdw +- + name: Mint + category: 'Fruits and vegetables' + strength: 0 + description: 'Mint/mentha leaves.' + color: null + origin: null + images: + - + resource_path: ingredients/mint.png + copyright: null + placeholder_hash: HbuBBQAla9c7w4wGDP545mKwKmeGh2CrZw +- + name: 'Old Tom Gin' + category: Spirits + strength: 40 + description: ' It is slightly sweeter than London Dry, but slightly drier than the Dutch Jenever, thus is sometimes called "the missing link".' + color: '#ffffff' + origin: UK + images: + - + resource_path: ingredients/old-tom-gin.png + copyright: null + placeholder_hash: HTmCBQA0eJd/gYD+p2eCfyf4B8iGeYZ4iA +- + name: 'Oleo Saccharum' + category: Syrups + strength: 0 + description: 'Oil extracted from citrus peels by using sugar.' + color: '#c6972c' + origin: null + images: { } +- + name: Orange + category: 'Fruits and vegetables' + strength: 0 + description: 'Orange fruit' + color: null + origin: null + images: + - + resource_path: ingredients/orange.png + copyright: null + placeholder_hash: 4nuGDQI2j7JphyhWak9q+LOVOXd3h2CJeA +- + name: 'Orange Curaçao' + category: Liqueurs + strength: 20 + description: 'Liqueur flavored with the dried peel of the bitter orange laraha, a citrus fruit grown on the Dutch island of Curaçao. Curaçao is used by liqueur makers overt the world as a generic name for orange-flavoured liqueurs.' + color: '#edaa53' + origin: Netherlands + images: + - + resource_path: ingredients/orange-curacao.png + copyright: null + placeholder_hash: XYqCDQI6SYZ2emc3CPSFNh0JCIeAdIeYeA +- + name: 'Orange Flower Water' + category: Beverages + strength: 0 + description: 'Clear aromatic by-product of the distillation of fresh bitter-orange blossoms for their essential oil.' + color: '#fff' + origin: Mediterranean + images: + - + resource_path: ingredients/orange-flower-water.png + copyright: null + placeholder_hash: aymCBQAieIaIZ38BN4d/c9kHB+iHeIaJdw +- + name: 'Orange bitters' + category: Bitters + strength: 28 + description: 'Orange bitters is a form of bitters, a cocktail flavoring made from such ingredients as the peels of Seville oranges, cardamom, caraway seed, coriander, anise, and burnt sugar in an alcohol base.' + color: '#ed8300' + origin: Worldwide + images: + - + resource_path: ingredients/orange-bitters.png + copyright: null + placeholder_hash: 6CiOPQoPWVhveHeIeXWQSge6AwMkJFRUVQ +- + name: 'Orange juice' + category: Juices + strength: 0 + description: 'Freshly squeezed orange juice.' + color: '#ff9518' + origin: null + images: + - + resource_path: ingredients/orange-juice.png + copyright: null + placeholder_hash: pMyGDQJXfGBqd4eWhwVZI4AUB5iHd3h4iA +- + name: 'Orgeat Syrup' + category: Syrups + strength: 0 + description: 'Sweet syrup made from almonds, sugar, and rose water or orange flower water.' + color: '#d9ca9f' + origin: null + images: + - + resource_path: ingredients/orgeat-syrup.png + copyright: null + placeholder_hash: sxiGFQA8h2hgiYeYqHiPpvV4CCiHd4d3eA +- + name: Ouzo + category: Liqueurs + strength: 35 + description: 'Dry anise-flavored aperitif that is widely consumed in Greece.' + color: '#ffffff' + origin: Greece + images: + - + resource_path: ingredients/ouzo.png + copyright: null + placeholder_hash: MDmCBQAzh3iJdnoFiHhwewcoB9iGeYd4iA +- + name: 'Overproof Rum' + category: Spirits + strength: 50 + description: 'Rum much higher than the standard 40% ABV (80 proof), with many as high as 75% (150 proof) to 80% (160 proof) available.' + color: '#5d201a' + origin: Caribbean + images: + - + resource_path: ingredients/overproof-rum.png + copyright: null + placeholder_hash: 0ViCBQAzd6h4h47CCHSATQe4CNd2iYeIhw +- + name: Passoã + category: Liqueurs + strength: 17 + description: 'Liqueur with passion fruit being the main ingredient.' + color: '#ea5f4c' + origin: France + images: + - + resource_path: ingredients/passoa.png + copyright: null + placeholder_hash: SgiCBQAzh3iHeIj3Z0eBcASoB9iGeYaIhw +- + name: Peach + category: 'Fruits and vegetables' + strength: 0 + description: 'Peach fruit' + color: null + origin: null + images: + - + resource_path: ingredients/peach.png + copyright: null + placeholder_hash: YJuGFQRH3DePc0IG2O9pFNkJSGeFeHCYhw +- + name: 'Peach Schnapps' + category: Spirits + strength: 40 + description: 'Schnapps made from peaches.' + color: '#ffffff' + origin: Worldwide + images: + - + resource_path: ingredients/peach-schnapps.png + copyright: null + placeholder_hash: rziCBQAzeJd1i3YJiHeHcHcICNeGeYaIhw +- + name: 'Peach bitters' + category: Bitters + strength: 35 + description: 'Peach bitters flavored with peaches and herbs.' + color: '#ca7c00' + origin: Worldwide + images: + - + resource_path: ingredients/peach-bitters.png + copyright: null + placeholder_hash: 7SiCBQAziHdwj4Qbd3iHgDgICNd2iHeIdw +- + name: Pelinkovac + category: Liqueurs + strength: 32 + description: 'Pelinkovac is a liqueur based on wormwood, it has a very bitter taste, resembling that of Jägermeister.' + color: '#573f42' + origin: 'Southeast Europe' + images: + - + resource_path: ingredients/pelinkovac.png + copyright: null + placeholder_hash: nSiGDQJGaaaPcn2Sl3eAeAeHCId1iHZ4iA +- + name: Pepper + category: Uncategorized + strength: 0 + description: 'Black pepper' + color: null + origin: null + images: + - + resource_path: ingredients/pepper.png + copyright: null + placeholder_hash: jyiCBQBGp3iLhokFd01L36P0WGh1eHCpiA +- + name: Pernod + category: Liqueurs + strength: 40 + description: 'Anise flavored liqueur' + color: '#c6c0a0' + origin: France + images: + - + resource_path: ingredients/pernod.png + copyright: null + placeholder_hash: Y/mFDQJFabR2mXX6l2t/iGcIB7iGeHd4dw +- + name: 'Peychauds Bitters' + category: Bitters + strength: 35 + description: 'It is a gentian-based bitters, comparable to Angostura bitters, but with a predominant anise aroma combined with a background of mint.' + color: '#622426' + origin: 'North America' + images: + - + resource_path: ingredients/peychauds-bitters.png + copyright: null + placeholder_hash: 3ViKDQZHl1hwjXm3mGePivaYCId2eHd4hw +- + name: Pineapple + category: 'Fruits and vegetables' + strength: 0 + description: 'Pineapple fruit' + color: null + origin: null + images: + - + resource_path: ingredients/pineapple.png + copyright: null + placeholder_hash: Y0uGDQYpzpVHhZDb6e6goQlrBzxBl2DJWQ +- + name: 'Pineapple juice' + category: Juices + strength: 0 + description: 'Juice from pineapple fruit.' + color: '#eadb34' + origin: null + images: + - + resource_path: ingredients/pineapple-juice.png + copyright: null + placeholder_hash: KSyGDQBXi3B7loeIiAxPQ7DhB5iHeHd4dw +- + name: Pisco + category: Spirits + strength: 40 + description: 'Made by distilling fermented grape juice into a high-proof spirit.' + color: '#ffffff' + origin: 'South America' + images: + - + resource_path: ingredients/pisco.png + copyright: null + placeholder_hash: aCmGFQJFiHeDfH4zCId5gJcHB6iHeHZ4eA +- + name: Prosecco + category: Wines + strength: 11 + description: 'Sparkling wine made from Prosecco grapes.' + color: '#a57600' + origin: Italy + images: + - + resource_path: ingredients/prosecco.png + copyright: null + placeholder_hash: 3zqGDQAzd4h2iI9Bh3hyj3CcCNd1iYeIhw +- + name: 'Raspberry Syrup' + category: Syrups + strength: 0 + description: 'Fruit syrup made from raspberries.' + color: '#b71f23' + origin: null + images: + - + resource_path: ingredients/raspberry-syrup.png + copyright: null + placeholder_hash: JKmSHRI8iHhweoioeHmfmPd4CCiHd4d3eA +- + name: 'Red wine' + category: Wines + strength: 11 + description: 'Red wine is a type of wine made from dark-colored grape varieties.' + color: '#801212' + origin: Worldwide + images: + - + resource_path: ingredients/red-wine.png + copyright: null + placeholder_hash: GxiGBQA0imJxnXT6iGePjvfYCNd2iYd4iA +- + name: 'Rhum agricole' + category: Spirits + strength: 50 + description: 'Rum distilled from freshly squeezed sugarcane juice rather than molasses.' + color: '#ffffff' + origin: Caribbean + images: + - + resource_path: ingredients/rhum-agricole.png + copyright: null + placeholder_hash: LimCDQA0epBqpXZZN3aAPARZB8h2iYd4hw +- + name: 'Rye whiskey' + category: Spirits + strength: 40 + description: 'Whiskey made with at least 51 percent rye grain.' + color: '#d54a06' + origin: 'North America' + images: + - + resource_path: ingredients/rye-whiskey.png + copyright: null + placeholder_hash: JDqGHQRHOfiGaoOrp2h/iPd3B4iGiIZ4iA +- + name: Salt + category: Uncategorized + strength: 0 + description: Salt + color: null + origin: null + images: + - + resource_path: ingredients/salt.png + copyright: null + placeholder_hash: 8TiGDQI3GcV3h3GPyMaIb434CFd3eHCJVw +- + name: 'Scotch whiskey' + category: Spirits + strength: 40 + description: 'Malt whisky or grain whisky (or a blend of the two), made in Scotland.' + color: '#d54a06' + origin: Scotland + images: + - + resource_path: ingredients/scotch-whiskey.png + copyright: null + placeholder_hash: Y1qGHQRHzABTuJeZR3eGkIYIB3iEiIaIhw +- + name: 'Simple Syrup' + category: Syrups + strength: 0 + description: 'Syrup made with sugar and water. Usually in 1:1 or 2:1 ratio.' + color: '#e6dfcc' + origin: null + images: + - + resource_path: ingredients/simple-syrup.png + copyright: null + placeholder_hash: shiGBQI8T0SeYnd614pAhweHCCiHd4d3eA +- + name: 'Sloe Gin' + category: Liqueurs + strength: 40 + description: 'Sloe gin is a red liqueur made with gin and sloes. Sloes are the fruit (drupe) of Prunus spinosa, the blackthorn plant, a relative of the plum.' + color: '#d74536' + origin: UK + images: + - + resource_path: ingredients/sloe-gin.png + copyright: null + placeholder_hash: JjiGDQJIR7mCfYj3t3h/h/hXB2iGeHZodw +- + name: St-Germain + category: Liqueurs + strength: 20 + description: 'St-Germain is an elderflower liqueur It is made using the petals of Sambucus nigra from the Savoie region in France, and each bottle is numbered with the year the petals were collected. Petals are collected annually in the spring over a period of three to four weeks, and are often transported by bicycle to collection points to avoid damaging the petals and impacting the flavour.' + color: '#f8e888' + origin: France + images: + - + resource_path: ingredients/st-germain.png + copyright: null + placeholder_hash: YRmKFQIt+DZ5d4pXd/mMjL/ICBh4eIiIhw +- + name: Sugar + category: Uncategorized + strength: 0 + description: 'White sugar' + color: null + origin: null + images: + - + resource_path: ingredients/sugar.png + copyright: null + placeholder_hash: dgiCBQA0mXN8hGoER1NwNAVIB5iId4F7hw +- + name: Suze + category: Liqueurs + strength: 15 + description: 'Bitter flavored drink made with the roots of the plant gentian.' + color: '#ffffff' + origin: Switzerland + images: + - + resource_path: ingredients/suze.png + copyright: null + placeholder_hash: rEmOPQgeZ2d0eHBJeGd7j7f3CwqIiHiIiA +- + name: 'Sweet Vermouth' + category: Wines + strength: 18 + description: 'Aromatized fortified wine.' + color: '#8e4201' + origin: Worldwide + images: + - + resource_path: ingredients/sweet-vermouth.png + copyright: null + placeholder_hash: m0iKDQBXh3iAfYq2eGmfu/XbCId3iHd4dw +- + name: Tabasco + category: Uncategorized + strength: 0 + description: 'Hot sauce made from vinegar, tabasco peppers, and salt.' + color: null + origin: null + images: + - + resource_path: ingredients/tabasco.png + copyright: null + placeholder_hash: ormGHQwtZ4eAj3toeIiPg/g4F2eAg3R1iA +- + name: Tequila + category: Spirits + strength: 40 + description: 'Distilled beverage made from the blue agave plant.' + color: '#ffffff' + origin: Mexico + images: + - + resource_path: ingredients/tequila.png + copyright: null + placeholder_hash: 7uaFDQJIh2mMZHwDGLabf6v4B2iFiIZ4eA +- + name: 'Tequila Añejo' + category: Spirits + strength: 40 + description: 'Tequila aged a minimum of one year, but less than three years in small oak barrels.' + color: '#f5d58a' + origin: Mexico + images: + - + resource_path: ingredients/tequila-anejo.png + copyright: null + placeholder_hash: rTqCFQI5JmSRj4JeN/pxpn92B2hyh4aIhw +- + name: 'Tequila Extra Añejo' + category: Spirits + strength: 40 + description: 'Tequila aged a minimum of three years in oak barrels.' + color: '#e8a934' + origin: Mexico + images: + - + resource_path: ingredients/tequila-extra-anejo.png + copyright: null + placeholder_hash: 4ZuGLQQ5BreOloUqaHaQmA2HCHhydoaIiA +- + name: 'Tequila Reposado' + category: Spirits + strength: 40 + description: 'Tequila aged a minimum of two months, but less than a year in oak barrels of any size.' + color: '#d8cca6' + origin: Mexico + images: + - + resource_path: ingredients/tequila-reposado.png + copyright: null + placeholder_hash: cSmGDQI5Z0aAjYV7d+9a9tpkB2hyh3aIhw +- + name: 'Tomato juice' + category: Juices + strength: 0 + description: 'Juice made from tomatoes.' + color: '#f16624' + origin: null + images: + - + resource_path: ingredients/tomato-juice.png + copyright: null + placeholder_hash: YfuGDQJWmzB5hneXiAlrAkN4B5iHeId4eA +- + name: Tonic + category: Beverages + strength: 0 + description: 'Tonic water (or Indian tonic water) is a carbonated soft drink in which quinine is dissolved.' + color: '#fff' + origin: Worldwide + images: + - + resource_path: ingredients/tonic.png + copyright: null + placeholder_hash: qgiCFQJGh3mLdHC9iHhwjwbnCId2iHd4dw +- + name: 'Triple Sec' + category: Liqueurs + strength: 40 + description: 'Triple sec is usually made from orange peels steeped in a spirit derived from sugar beet due to its neutral flavor.' + color: '#ffffff' + origin: France + images: + - + resource_path: ingredients/triple-sec.png + copyright: null + placeholder_hash: 3QmCBQJFZquJd4D9NwlxguDXCJd1iYaIhw +- + name: 'Vanilla Extract' + category: Uncategorized + strength: 0 + description: 'Solution made by macerating and percolating vanilla pods in a solution of ethanol and water.' + color: null + origin: null + images: + - + resource_path: ingredients/vanilla-extract.png + copyright: null + placeholder_hash: 2AiCDQA0d4qFaob5V4eHcNgICMeHeHaJdw +- + name: 'Vanilla Vodka' + category: Spirits + strength: 40 + description: 'Vodka with added vanilla essence.' + color: '#ffffff' + origin: Russia + images: + - + resource_path: ingredients/vanilla-vodka.png + copyright: null + placeholder_hash: MwiCBQA0Z9qAf4m3eHePeveYCMd3iHd4dw +- + name: Vodka + category: Spirits + strength: 40 + description: 'Clear alcoholic beverage distilled from cereal grains and potatos.' + color: '#ffffff' + origin: Russia + images: + - + resource_path: ingredients/vodka.png + copyright: null + placeholder_hash: cOeJHQIPvIp2iH/0mNdyfy73Bwd3d3VlaA +- + name: 'Vodka Citron' + category: Spirits + strength: 40 + description: 'Vodka with added lemon essence.' + color: '#ffffff' + origin: Sweden + images: + - + resource_path: ingredients/vodka-citron.png + copyright: null + placeholder_hash: dwiCDQAziVRxjXc3CHd6gJcHCMd2iXaJeA +- + name: Water + category: Beverages + strength: 0 + description: "It's water." + color: '#fff' + origin: Worldwide + images: + - + resource_path: ingredients/water.png + copyright: null + placeholder_hash: 9geKFQgeendZeKf1NlptkMQGBwd4eHiIhw +- + name: Whiskey + category: Spirits + strength: 40 + description: 'Distilled alcoholic beverage made from fermented grain mash.' + color: '#d54a06' + origin: Worldwide + images: + - + resource_path: ingredients/whiskey.png + copyright: null + placeholder_hash: zyiCDQJFeoJ8hHBOZ3iPcPcHB7eGeXd4iA +- + name: 'White Crème de Cacao' + category: Liqueurs + strength: 25 + description: 'Milk chocolate flavored liqueur with a hint of vanilla.' + color: '#ffffff' + origin: France + images: + - + resource_path: ingredients/white-creme-de-cacao.png + copyright: null + placeholder_hash: YQiKBQBImGh2iHCsmJ8XvXf0CHdzd4iYeA +- + name: 'White Peach Puree' + category: Uncategorized + strength: 0 + description: 'A purée (or mash) is cooked food, usually vegetables, fruits or legumes, that has been ground, pressed, blended or sieved to the consistency of a creamy paste or liquid.' + color: null + origin: null + images: + - + resource_path: ingredients/white-peach-puree.png + copyright: null + placeholder_hash: oaqCFQRFe5BmuYe3qIlwdgh4B6iGiIeIhw +- + name: 'White Rum' + category: Spirits + strength: 40 + description: 'Liquor made by fermenting and then distilling sugarcane molasses or sugarcane juice.' + color: '#ffffff' + origin: Caribbean + images: + - + resource_path: ingredients/white-rum.png + copyright: null + placeholder_hash: 7QeKDQQPtTqfYHiHVwhziCCHBwd4eId3dw +- + name: 'White wine' + category: Wines + strength: 11 + description: 'Wine is an alcoholic drink typically made from fermented grapes.' + color: '#f6e1b0' + origin: Worldwide + images: + - + resource_path: ingredients/white-wine.png + copyright: null + placeholder_hash: 7OeBBQI0iWR6hXA+iHh/i/e3B9iGeYd4hw +- + name: 'Worcestershire Sauce' + category: Uncategorized + strength: 0 + description: 'Fermented liquid condiment created in the city of Worcester' + color: null + origin: null + images: + - + resource_path: ingredients/worcestershire-sauce.png + copyright: null + placeholder_hash: 2ziGBQAzd4h3iIn2aIdwggkICNd2iIeIhw +- + name: 'Yellow Chartreuse' + category: Liqueurs + strength: 40 + description: 'Yellow Chartreuse has a milder and sweeter flavor and aroma than Green Chartreuse, and is lower in alcohol content.' + color: '#fbfb4b' + origin: France + images: + - + resource_path: ingredients/yellow-chartreuse.png + copyright: null + placeholder_hash: nyyGPQI8CWeNdXSKl0iPg/Q3ByeGh3ZneA +- + name: 'Basil' + category: 'Fruits and vegetables' + strength: 0 + description: 'Basil is an annual, or sometimes perennial, herb used for its leaves.' + color: '#15a905' + origin: Worldwide + images: + - + resource_path: ingredients/basil.png + copyright: null + placeholder_hash: W6qBBQAlpJt1mc8yaVeOcDr3KHaDaGDqVw +- + name: 'Cilantro' + category: 'Fruits and vegetables' + strength: 0 + description: 'An annual herb in the family Apiaceae. All parts of the plant are edible, but the fresh leaves and the dried seeds are the parts most traditionally used in cooking. Most people perceive coriander as having a tart, lemon/lime taste, but to some individuals the leaves taste like dish soap. The perception of a soapy taste in certain aldehydes is linked to a specific gene.' + color: '#0e630a' + origin: Worldwide + images: + - + resource_path: ingredients/cilantro.png + copyright: null + placeholder_hash: 'lKqBDQA38NeYxN+xJ/G1HAeKWHeodXB4hQ' diff --git a/resources/data/base_methods.yml b/resources/data/base_methods.yml new file mode 100644 index 00000000..4fb0344e --- /dev/null +++ b/resources/data/base_methods.yml @@ -0,0 +1,18 @@ +- + name: 'Shake' + dilution_percentage: 25 +- + name: 'Stir' + dilution_percentage: 20 +- + name: 'Build' + dilution_percentage: 10 +- + name: 'Blend' + dilution_percentage: 25 +- + name: 'Muddle' + dilution_percentage: 10 +- + name: 'Layer' + dilution_percentage: 0 diff --git a/resources/data/base_utensils.yml b/resources/data/base_utensils.yml new file mode 100644 index 00000000..63cf5c0c --- /dev/null +++ b/resources/data/base_utensils.yml @@ -0,0 +1,60 @@ +- + name: 'Mixing glass' + description: A glass with a heavy base that doesn't tip over when stirring. +- + name: 'Shaker' + description: 'A recipient in 2 parts to shake cocktails vigorously.' +- + name: 'Bar spoon' + description: 'A long and heavy spiraled spoon used to stir or layer cocktails.' +- + name: 'Julep Strainer' + description: 'A style of strainer used when using a mixing glass to strain the ice out.' +- + name: 'Hawthorne Strainer' + description: 'A style of strainer used when using a shaker to strain the ice out.' +- + name: 'Mesh Strainer' + description: 'A simple mesh strainer used to double strain cocktails in order to avoid any ice in the final drink, or to avoid pulp when juicing fruits.' +- + name: 'Atomizer' + description: 'Refillable glass spray bottle to spray and mist very small amounts of aromatics. Used for absinthe rinses, and bitter sprays.' +- + name: 'Muddler' + description: 'Essential tool to crush fruits, berries and herbs and extract the juice out of them.' +- + name: 'Jigger' + description: 'Small cup used to quickly measure volumes in the bar.' +- + name: 'Zester' + description: 'Rasp used to zest fruits, nuts, or even chocolate for garnishes.' +- + name: 'Channel knife' + description: 'Knife designed to make long and thin citrus peels.' +- + name: 'Y Peeler' + description: 'Kitchen tool designed to peel fruits and vegetables. In the bar, used for large peels to extract the oils from.' +- + name: 'Bar knife' + description: 'A small sharp knife to peel and cut fruits.' +- + name: 'Ice carving knife' + description: 'A knife with a significantly tougher spine designed to handle ice carving.' +- + name: 'Ice chipper' + description: 'A three-pronged tool to chip away and break ice.' +- + name: 'Ice pick' + description: 'A pick to break and chip away at ice.' +- + name: 'Cocktail smoker' + description: 'A device used to add smokey flavor to cocktails by burning different wood escences.' +- + name: 'Juicer' + description: 'Extract juice from citrus fruits.' +- + name: 'Straight tongs' + description: 'Small precision tongs to place garnishes.' +- + name: 'Ice tongs' + description: 'Tongs made to grab ice cubes.' diff --git a/resources/data/cocktails/20th-century.jpg b/resources/data/cocktails/20th-century.jpg index a1ac42c3..f4d23f38 100644 Binary files a/resources/data/cocktails/20th-century.jpg and b/resources/data/cocktails/20th-century.jpg differ diff --git a/resources/data/cocktails/adonis.jpg b/resources/data/cocktails/adonis.jpg index 10b041b2..528e4da0 100644 Binary files a/resources/data/cocktails/adonis.jpg and b/resources/data/cocktails/adonis.jpg differ diff --git a/resources/data/cocktails/airmail.jpg b/resources/data/cocktails/airmail.jpg index 98bc0023..6ea64bbb 100644 Binary files a/resources/data/cocktails/airmail.jpg and b/resources/data/cocktails/airmail.jpg differ diff --git a/resources/data/cocktails/alaska.jpg b/resources/data/cocktails/alaska.jpg index 912ae592..9b89ed45 100644 Binary files a/resources/data/cocktails/alaska.jpg and b/resources/data/cocktails/alaska.jpg differ diff --git a/resources/data/cocktails/alexander.jpg b/resources/data/cocktails/alexander.jpg index 67698fb4..d3a324fb 100644 Binary files a/resources/data/cocktails/alexander.jpg and b/resources/data/cocktails/alexander.jpg differ diff --git a/resources/data/cocktails/amaretto-sour.jpg b/resources/data/cocktails/amaretto-sour.jpg index be5faae8..68c7ea4a 100644 Binary files a/resources/data/cocktails/amaretto-sour.jpg and b/resources/data/cocktails/amaretto-sour.jpg differ diff --git a/resources/data/cocktails/americano.jpg b/resources/data/cocktails/americano.jpg index b9c878b1..8e6b5dc2 100644 Binary files a/resources/data/cocktails/americano.jpg and b/resources/data/cocktails/americano.jpg differ diff --git a/resources/data/cocktails/angel-face.jpg b/resources/data/cocktails/angel-face.jpg index 9df843cb..c26fdf88 100644 Binary files a/resources/data/cocktails/angel-face.jpg and b/resources/data/cocktails/angel-face.jpg differ diff --git a/resources/data/cocktails/antikovac.jpg b/resources/data/cocktails/antikovac.jpg deleted file mode 100644 index 8599baaa..00000000 Binary files a/resources/data/cocktails/antikovac.jpg and /dev/null differ diff --git a/resources/data/cocktails/aviation.jpg b/resources/data/cocktails/aviation.jpg index e7e8c740..35a30a22 100644 Binary files a/resources/data/cocktails/aviation.jpg and b/resources/data/cocktails/aviation.jpg differ diff --git a/resources/data/cocktails/b-52.jpg b/resources/data/cocktails/b-52.jpg index 3f54e215..e3381af3 100644 Binary files a/resources/data/cocktails/b-52.jpg and b/resources/data/cocktails/b-52.jpg differ diff --git a/resources/data/cocktails/bacardi-cocktail.jpg b/resources/data/cocktails/bacardi-cocktail.jpg index 227b5e8b..aed145e5 100644 Binary files a/resources/data/cocktails/bacardi-cocktail.jpg and b/resources/data/cocktails/bacardi-cocktail.jpg differ diff --git a/resources/data/cocktails/barracuda.jpg b/resources/data/cocktails/barracuda.jpg index 0759b112..0834197e 100644 Binary files a/resources/data/cocktails/barracuda.jpg and b/resources/data/cocktails/barracuda.jpg differ diff --git a/resources/data/cocktails/bees-knees.jpg b/resources/data/cocktails/bees-knees.jpg index 7c58d8d6..423e21b2 100644 Binary files a/resources/data/cocktails/bees-knees.jpg and b/resources/data/cocktails/bees-knees.jpg differ diff --git a/resources/data/cocktails/bellini.jpg b/resources/data/cocktails/bellini.jpg index 8405d519..a1788595 100644 Binary files a/resources/data/cocktails/bellini.jpg and b/resources/data/cocktails/bellini.jpg differ diff --git a/resources/data/cocktails/between-the-sheets.jpg b/resources/data/cocktails/between-the-sheets.jpg index 2ae2c80c..51306425 100644 Binary files a/resources/data/cocktails/between-the-sheets.jpg and b/resources/data/cocktails/between-the-sheets.jpg differ diff --git a/resources/data/cocktails/bijou.jpg b/resources/data/cocktails/bijou.jpg index ce5f115b..63d47ae4 100644 Binary files a/resources/data/cocktails/bijou.jpg and b/resources/data/cocktails/bijou.jpg differ diff --git a/resources/data/cocktails/black-russian.jpg b/resources/data/cocktails/black-russian.jpg index cb99afa3..0c89279c 100644 Binary files a/resources/data/cocktails/black-russian.jpg and b/resources/data/cocktails/black-russian.jpg differ diff --git a/resources/data/cocktails/bloody-mary.jpg b/resources/data/cocktails/bloody-mary.jpg index 35f95e5d..126af932 100644 Binary files a/resources/data/cocktails/bloody-mary.jpg and b/resources/data/cocktails/bloody-mary.jpg differ diff --git a/resources/data/cocktails/boulevardier.jpg b/resources/data/cocktails/boulevardier.jpg index dc5be7b9..31bf1924 100644 Binary files a/resources/data/cocktails/boulevardier.jpg and b/resources/data/cocktails/boulevardier.jpg differ diff --git a/resources/data/cocktails/bramble.jpg b/resources/data/cocktails/bramble.jpg index f0e69479..417a47b5 100644 Binary files a/resources/data/cocktails/bramble.jpg and b/resources/data/cocktails/bramble.jpg differ diff --git a/resources/data/cocktails/brandy-crusta.jpg b/resources/data/cocktails/brandy-crusta.jpg index 0bde6fdd..25cd9a3f 100644 Binary files a/resources/data/cocktails/brandy-crusta.jpg and b/resources/data/cocktails/brandy-crusta.jpg differ diff --git a/resources/data/cocktails/caipirinha.jpg b/resources/data/cocktails/caipirinha.jpg index b576d165..c8413b75 100644 Binary files a/resources/data/cocktails/caipirinha.jpg and b/resources/data/cocktails/caipirinha.jpg differ diff --git a/resources/data/cocktails/canchanchara.jpg b/resources/data/cocktails/canchanchara.jpg index afa41ab8..93a52bb2 100644 Binary files a/resources/data/cocktails/canchanchara.jpg and b/resources/data/cocktails/canchanchara.jpg differ diff --git a/resources/data/cocktails/cantaritos.jpg b/resources/data/cocktails/cantaritos.jpg index e6a1a8f9..4de76bb2 100644 Binary files a/resources/data/cocktails/cantaritos.jpg and b/resources/data/cocktails/cantaritos.jpg differ diff --git a/resources/data/cocktails/casino.jpg b/resources/data/cocktails/casino.jpg index f8eecb85..b86dedb8 100644 Binary files a/resources/data/cocktails/casino.jpg and b/resources/data/cocktails/casino.jpg differ diff --git a/resources/data/cocktails/champagne-cocktail.jpg b/resources/data/cocktails/champagne-cocktail.jpg index 08023d35..6c75783d 100644 Binary files a/resources/data/cocktails/champagne-cocktail.jpg and b/resources/data/cocktails/champagne-cocktail.jpg differ diff --git a/resources/data/cocktails/clover-club.jpg b/resources/data/cocktails/clover-club.jpg index 786aad74..f22f6aab 100644 Binary files a/resources/data/cocktails/clover-club.jpg and b/resources/data/cocktails/clover-club.jpg differ diff --git a/resources/data/cocktails/comte-de-sureau.jpg b/resources/data/cocktails/comte-de-sureau.jpg index a6d770f6..44521225 100644 Binary files a/resources/data/cocktails/comte-de-sureau.jpg and b/resources/data/cocktails/comte-de-sureau.jpg differ diff --git a/resources/data/cocktails/corpse-reviver-2.jpg b/resources/data/cocktails/corpse-reviver-2.jpg index ff46d897..36be16e1 100644 Binary files a/resources/data/cocktails/corpse-reviver-2.jpg and b/resources/data/cocktails/corpse-reviver-2.jpg differ diff --git a/resources/data/cocktails/cosmopolitan.jpg b/resources/data/cocktails/cosmopolitan.jpg index 560c3882..d83f7cad 100644 Binary files a/resources/data/cocktails/cosmopolitan.jpg and b/resources/data/cocktails/cosmopolitan.jpg differ diff --git a/resources/data/cocktails/cuba-libre.jpg b/resources/data/cocktails/cuba-libre.jpg index ecb8fa80..9c7a802a 100644 Binary files a/resources/data/cocktails/cuba-libre.jpg and b/resources/data/cocktails/cuba-libre.jpg differ diff --git a/resources/data/cocktails/daiquiri.jpg b/resources/data/cocktails/daiquiri.jpg index 998c0167..347ec6d6 100644 Binary files a/resources/data/cocktails/daiquiri.jpg and b/resources/data/cocktails/daiquiri.jpg differ diff --git a/resources/data/cocktails/dark-n-stormy.jpg b/resources/data/cocktails/dark-n-stormy.jpg index 6ec4faf5..e7b8cea1 100644 Binary files a/resources/data/cocktails/dark-n-stormy.jpg and b/resources/data/cocktails/dark-n-stormy.jpg differ diff --git a/resources/data/cocktails/dfjhjj.jpg b/resources/data/cocktails/dfjhjj.jpg deleted file mode 100644 index b4efc05f..00000000 Binary files a/resources/data/cocktails/dfjhjj.jpg and /dev/null differ diff --git a/resources/data/cocktails/dry-martini.jpg b/resources/data/cocktails/dry-martini.jpg deleted file mode 100644 index 1565871c..00000000 Binary files a/resources/data/cocktails/dry-martini.jpg and /dev/null differ diff --git a/resources/data/cocktails/el-presidente.jpg b/resources/data/cocktails/el-presidente.jpg index 9ac63676..fcdebfcc 100644 Binary files a/resources/data/cocktails/el-presidente.jpg and b/resources/data/cocktails/el-presidente.jpg differ diff --git a/resources/data/cocktails/espresso-martini.jpg b/resources/data/cocktails/espresso-martini.jpg index 6d3f33db..608bc5a3 100644 Binary files a/resources/data/cocktails/espresso-martini.jpg and b/resources/data/cocktails/espresso-martini.jpg differ diff --git a/resources/data/cocktails/fernandito.jpg b/resources/data/cocktails/fernandito.jpg index 7f15d7c6..e2156c99 100644 Binary files a/resources/data/cocktails/fernandito.jpg and b/resources/data/cocktails/fernandito.jpg differ diff --git a/resources/data/cocktails/final-ward.jpg b/resources/data/cocktails/final-ward.jpg new file mode 100644 index 00000000..c1f9fc40 Binary files /dev/null and b/resources/data/cocktails/final-ward.jpg differ diff --git a/resources/data/cocktails/french-75.jpg b/resources/data/cocktails/french-75.jpg index 5de77357..e3fc2e8d 100644 Binary files a/resources/data/cocktails/french-75.jpg and b/resources/data/cocktails/french-75.jpg differ diff --git a/resources/data/cocktails/french-connection.jpg b/resources/data/cocktails/french-connection.jpg index 6fa3dfe8..bbcb5ed6 100644 Binary files a/resources/data/cocktails/french-connection.jpg and b/resources/data/cocktails/french-connection.jpg differ diff --git a/resources/data/cocktails/french-martini.jpg b/resources/data/cocktails/french-martini.jpg index 821bcea3..64840522 100644 Binary files a/resources/data/cocktails/french-martini.jpg and b/resources/data/cocktails/french-martini.jpg differ diff --git a/resources/data/cocktails/garibaldi.jpg b/resources/data/cocktails/garibaldi.jpg index e7ee963a..1a6bf454 100644 Binary files a/resources/data/cocktails/garibaldi.jpg and b/resources/data/cocktails/garibaldi.jpg differ diff --git a/resources/data/cocktails/gin-basil-smash.jpg b/resources/data/cocktails/gin-basil-smash.jpg new file mode 100644 index 00000000..7403ac36 Binary files /dev/null and b/resources/data/cocktails/gin-basil-smash.jpg differ diff --git a/resources/data/cocktails/gin-fizz.jpg b/resources/data/cocktails/gin-fizz.jpg index 22c15984..57684bca 100644 Binary files a/resources/data/cocktails/gin-fizz.jpg and b/resources/data/cocktails/gin-fizz.jpg differ diff --git a/resources/data/cocktails/gin-gimlet.jpg b/resources/data/cocktails/gin-gimlet.jpg index 0d7926c3..b23225de 100644 Binary files a/resources/data/cocktails/gin-gimlet.jpg and b/resources/data/cocktails/gin-gimlet.jpg differ diff --git a/resources/data/cocktails/gin-tonic.jpg b/resources/data/cocktails/gin-tonic.jpg index c2743c0d..95cba8c2 100644 Binary files a/resources/data/cocktails/gin-tonic.jpg and b/resources/data/cocktails/gin-tonic.jpg differ diff --git a/resources/data/cocktails/golden-dream.jpg b/resources/data/cocktails/golden-dream.jpg index 4363348a..f64a09a2 100644 Binary files a/resources/data/cocktails/golden-dream.jpg and b/resources/data/cocktails/golden-dream.jpg differ diff --git a/resources/data/cocktails/grasshopper.jpg b/resources/data/cocktails/grasshopper.jpg index a5533eb4..3982eeb1 100644 Binary files a/resources/data/cocktails/grasshopper.jpg and b/resources/data/cocktails/grasshopper.jpg differ diff --git a/resources/data/cocktails/hanky-panky.jpg b/resources/data/cocktails/hanky-panky.jpg index d13820c2..aed2918d 100644 Binary files a/resources/data/cocktails/hanky-panky.jpg and b/resources/data/cocktails/hanky-panky.jpg differ diff --git a/resources/data/cocktails/hemingway-special.jpg b/resources/data/cocktails/hemingway-special.jpg index 2c48130e..ae11cbc1 100644 Binary files a/resources/data/cocktails/hemingway-special.jpg and b/resources/data/cocktails/hemingway-special.jpg differ diff --git a/resources/data/cocktails/horses-neck.jpg b/resources/data/cocktails/horses-neck.jpg index 44555f3f..095258b1 100644 Binary files a/resources/data/cocktails/horses-neck.jpg and b/resources/data/cocktails/horses-neck.jpg differ diff --git a/resources/data/cocktails/hugo-spritz.jpg b/resources/data/cocktails/hugo-spritz.jpg new file mode 100644 index 00000000..0767c144 Binary files /dev/null and b/resources/data/cocktails/hugo-spritz.jpg differ diff --git a/resources/data/cocktails/illegal.jpg b/resources/data/cocktails/illegal.jpg index fbf6e68f..03c5dd8b 100644 Binary files a/resources/data/cocktails/illegal.jpg and b/resources/data/cocktails/illegal.jpg differ diff --git a/resources/data/cocktails/irish-coffee.jpg b/resources/data/cocktails/irish-coffee.jpg index ba3f8049..c3747f08 100644 Binary files a/resources/data/cocktails/irish-coffee.jpg and b/resources/data/cocktails/irish-coffee.jpg differ diff --git a/resources/data/cocktails/japanese-cocktail.jpg b/resources/data/cocktails/japanese-cocktail.jpg index 61969bd3..76d4c9e9 100644 Binary files a/resources/data/cocktails/japanese-cocktail.jpg and b/resources/data/cocktails/japanese-cocktail.jpg differ diff --git a/resources/data/cocktails/john-collins.jpg b/resources/data/cocktails/john-collins.jpg index 5ebbcd67..7ffce8c6 100644 Binary files a/resources/data/cocktails/john-collins.jpg and b/resources/data/cocktails/john-collins.jpg differ diff --git a/resources/data/cocktails/kir.jpg b/resources/data/cocktails/kir.jpg index 8dc7985e..eb765e31 100644 Binary files a/resources/data/cocktails/kir.jpg and b/resources/data/cocktails/kir.jpg differ diff --git a/resources/data/cocktails/la-louisiane.jpg b/resources/data/cocktails/la-louisiane.jpg index 5b56d7c3..8d25e91b 100644 Binary files a/resources/data/cocktails/la-louisiane.jpg and b/resources/data/cocktails/la-louisiane.jpg differ diff --git a/resources/data/cocktails/last-word.jpg b/resources/data/cocktails/last-word.jpg index fa250938..aec80e87 100644 Binary files a/resources/data/cocktails/last-word.jpg and b/resources/data/cocktails/last-word.jpg differ diff --git a/resources/data/cocktails/lemon-drop-martini.jpg b/resources/data/cocktails/lemon-drop-martini.jpg index c638e8dc..7d260266 100644 Binary files a/resources/data/cocktails/lemon-drop-martini.jpg and b/resources/data/cocktails/lemon-drop-martini.jpg differ diff --git a/resources/data/cocktails/long-island-ice-tea.jpg b/resources/data/cocktails/long-island-ice-tea.jpg index cb402705..a3c304bb 100644 Binary files a/resources/data/cocktails/long-island-ice-tea.jpg and b/resources/data/cocktails/long-island-ice-tea.jpg differ diff --git a/resources/data/cocktails/mai-tai.jpg b/resources/data/cocktails/mai-tai.jpg index 4a1732ba..6b40ff77 100644 Binary files a/resources/data/cocktails/mai-tai.jpg and b/resources/data/cocktails/mai-tai.jpg differ diff --git a/resources/data/cocktails/manhattan.jpg b/resources/data/cocktails/manhattan.jpg index 5bdcca36..3f10b6e4 100644 Binary files a/resources/data/cocktails/manhattan.jpg and b/resources/data/cocktails/manhattan.jpg differ diff --git a/resources/data/cocktails/margarita.jpg b/resources/data/cocktails/margarita.jpg index cf7fb316..1776c4e6 100644 Binary files a/resources/data/cocktails/margarita.jpg and b/resources/data/cocktails/margarita.jpg differ diff --git a/resources/data/cocktails/martinez.jpg b/resources/data/cocktails/martinez.jpg index 2f0a81e9..11e13190 100644 Binary files a/resources/data/cocktails/martinez.jpg and b/resources/data/cocktails/martinez.jpg differ diff --git a/resources/data/cocktails/martini.jpg b/resources/data/cocktails/martini.jpg new file mode 100644 index 00000000..1b7cbbcb Binary files /dev/null and b/resources/data/cocktails/martini.jpg differ diff --git a/resources/data/cocktails/mary-pickford.jpg b/resources/data/cocktails/mary-pickford.jpg index 3c53c198..245d2650 100644 Binary files a/resources/data/cocktails/mary-pickford.jpg and b/resources/data/cocktails/mary-pickford.jpg differ diff --git a/resources/data/cocktails/mimosa.jpg b/resources/data/cocktails/mimosa.jpg index 3520f19a..11f8343b 100644 Binary files a/resources/data/cocktails/mimosa.jpg and b/resources/data/cocktails/mimosa.jpg differ diff --git a/resources/data/cocktails/mint-julep.jpg b/resources/data/cocktails/mint-julep.jpg index 53ea49ce..1963b78f 100644 Binary files a/resources/data/cocktails/mint-julep.jpg and b/resources/data/cocktails/mint-julep.jpg differ diff --git a/resources/data/cocktails/mojito.jpg b/resources/data/cocktails/mojito.jpg index 269dbbf7..ca3bb706 100644 Binary files a/resources/data/cocktails/mojito.jpg and b/resources/data/cocktails/mojito.jpg differ diff --git a/resources/data/cocktails/monkey-gland.jpg b/resources/data/cocktails/monkey-gland.jpg index ee636345..405ca1a0 100644 Binary files a/resources/data/cocktails/monkey-gland.jpg and b/resources/data/cocktails/monkey-gland.jpg differ diff --git a/resources/data/cocktails/moscow-mule.jpg b/resources/data/cocktails/moscow-mule.jpg index acf7030c..bb1006ed 100644 Binary files a/resources/data/cocktails/moscow-mule.jpg and b/resources/data/cocktails/moscow-mule.jpg differ diff --git a/resources/data/cocktails/naked-and-famous.jpg b/resources/data/cocktails/naked-and-famous.jpg index 138a11e6..246a8ff1 100644 Binary files a/resources/data/cocktails/naked-and-famous.jpg and b/resources/data/cocktails/naked-and-famous.jpg differ diff --git a/resources/data/cocktails/negroni.jpg b/resources/data/cocktails/negroni.jpg index 80f6e2ae..c7062ecd 100644 Binary files a/resources/data/cocktails/negroni.jpg and b/resources/data/cocktails/negroni.jpg differ diff --git a/resources/data/cocktails/new-york-sour.jpg b/resources/data/cocktails/new-york-sour.jpg index 1f215f91..6c3939c0 100644 Binary files a/resources/data/cocktails/new-york-sour.jpg and b/resources/data/cocktails/new-york-sour.jpg differ diff --git a/resources/data/cocktails/old-cuban.jpg b/resources/data/cocktails/old-cuban.jpg index ee768284..9e6d1c2b 100644 Binary files a/resources/data/cocktails/old-cuban.jpg and b/resources/data/cocktails/old-cuban.jpg differ diff --git a/resources/data/cocktails/old-fashioned.jpg b/resources/data/cocktails/old-fashioned.jpg index 7ef7dd23..37ebb1c3 100644 Binary files a/resources/data/cocktails/old-fashioned.jpg and b/resources/data/cocktails/old-fashioned.jpg differ diff --git a/resources/data/cocktails/paloma.jpg b/resources/data/cocktails/paloma.jpg index f6648ad1..b0204c28 100644 Binary files a/resources/data/cocktails/paloma.jpg and b/resources/data/cocktails/paloma.jpg differ diff --git a/resources/data/cocktails/paper-plane.jpg b/resources/data/cocktails/paper-plane.jpg index 6244677e..08489951 100644 Binary files a/resources/data/cocktails/paper-plane.jpg and b/resources/data/cocktails/paper-plane.jpg differ diff --git a/resources/data/cocktails/paradise.jpg b/resources/data/cocktails/paradise.jpg index 7efe3c8d..fb027ce4 100644 Binary files a/resources/data/cocktails/paradise.jpg and b/resources/data/cocktails/paradise.jpg differ diff --git a/resources/data/cocktails/penicillin.jpg b/resources/data/cocktails/penicillin.jpg index 78187abf..e4f008ff 100644 Binary files a/resources/data/cocktails/penicillin.jpg and b/resources/data/cocktails/penicillin.jpg differ diff --git a/resources/data/cocktails/picante-de-la-casa.jpg b/resources/data/cocktails/picante-de-la-casa.jpg new file mode 100644 index 00000000..70768d28 Binary files /dev/null and b/resources/data/cocktails/picante-de-la-casa.jpg differ diff --git a/resources/data/cocktails/pina-colada.jpg b/resources/data/cocktails/pina-colada.jpg index 876823cb..d68fa040 100644 Binary files a/resources/data/cocktails/pina-colada.jpg and b/resources/data/cocktails/pina-colada.jpg differ diff --git a/resources/data/cocktails/pisco-sour.jpg b/resources/data/cocktails/pisco-sour.jpg index bbd5b2fa..f7552344 100644 Binary files a/resources/data/cocktails/pisco-sour.jpg and b/resources/data/cocktails/pisco-sour.jpg differ diff --git a/resources/data/cocktails/planters-punch.jpg b/resources/data/cocktails/planters-punch.jpg index 305b9015..73e1531e 100644 Binary files a/resources/data/cocktails/planters-punch.jpg and b/resources/data/cocktails/planters-punch.jpg differ diff --git a/resources/data/cocktails/porn-star-martini.jpg b/resources/data/cocktails/porn-star-martini.jpg index caaf0b68..2feb285c 100644 Binary files a/resources/data/cocktails/porn-star-martini.jpg and b/resources/data/cocktails/porn-star-martini.jpg differ diff --git a/resources/data/cocktails/porto-flip.jpg b/resources/data/cocktails/porto-flip.jpg index 4b9f4b6f..64d149f1 100644 Binary files a/resources/data/cocktails/porto-flip.jpg and b/resources/data/cocktails/porto-flip.jpg differ diff --git a/resources/data/cocktails/queens-park-hotel-super-cocktail.jpg b/resources/data/cocktails/queens-park-hotel-super-cocktail.jpg index 4b03749b..dde773fd 100644 Binary files a/resources/data/cocktails/queens-park-hotel-super-cocktail.jpg and b/resources/data/cocktails/queens-park-hotel-super-cocktail.jpg differ diff --git a/resources/data/cocktails/ramos-fizz.jpg b/resources/data/cocktails/ramos-fizz.jpg index db4b92f5..2c08ac39 100644 Binary files a/resources/data/cocktails/ramos-fizz.jpg and b/resources/data/cocktails/ramos-fizz.jpg differ diff --git a/resources/data/cocktails/russian-spring-punch.jpg b/resources/data/cocktails/russian-spring-punch.jpg index 0a9b4c20..bcee937f 100644 Binary files a/resources/data/cocktails/russian-spring-punch.jpg and b/resources/data/cocktails/russian-spring-punch.jpg differ diff --git a/resources/data/cocktails/rusty-nail.jpg b/resources/data/cocktails/rusty-nail.jpg index b1b3cf14..b33cffd6 100644 Binary files a/resources/data/cocktails/rusty-nail.jpg and b/resources/data/cocktails/rusty-nail.jpg differ diff --git a/resources/data/cocktails/sangria.jpg b/resources/data/cocktails/sangria.jpg index 8a265a03..30876c0b 100644 Binary files a/resources/data/cocktails/sangria.jpg and b/resources/data/cocktails/sangria.jpg differ diff --git a/resources/data/cocktails/sazerac.jpg b/resources/data/cocktails/sazerac.jpg index f90b5303..e062bf16 100644 Binary files a/resources/data/cocktails/sazerac.jpg and b/resources/data/cocktails/sazerac.jpg differ diff --git a/resources/data/cocktails/sea-breeze.jpg b/resources/data/cocktails/sea-breeze.jpg index ac7c46b4..161ad287 100644 Binary files a/resources/data/cocktails/sea-breeze.jpg and b/resources/data/cocktails/sea-breeze.jpg differ diff --git a/resources/data/cocktails/seelbach.jpg b/resources/data/cocktails/seelbach.jpg index e174012d..9eba9b38 100644 Binary files a/resources/data/cocktails/seelbach.jpg and b/resources/data/cocktails/seelbach.jpg differ diff --git a/resources/data/cocktails/sex-on-the-beach.jpg b/resources/data/cocktails/sex-on-the-beach.jpg index 66108d7d..061fcbbf 100644 Binary files a/resources/data/cocktails/sex-on-the-beach.jpg and b/resources/data/cocktails/sex-on-the-beach.jpg differ diff --git a/resources/data/cocktails/sidecar.jpg b/resources/data/cocktails/sidecar.jpg index f70eb5bc..dbfdd9b0 100644 Binary files a/resources/data/cocktails/sidecar.jpg and b/resources/data/cocktails/sidecar.jpg differ diff --git a/resources/data/cocktails/singapore-sling.jpg b/resources/data/cocktails/singapore-sling.jpg index c1455638..71be6671 100644 Binary files a/resources/data/cocktails/singapore-sling.jpg and b/resources/data/cocktails/singapore-sling.jpg differ diff --git a/resources/data/cocktails/southside.jpg b/resources/data/cocktails/southside.jpg index b0b80d82..0fbf494b 100644 Binary files a/resources/data/cocktails/southside.jpg and b/resources/data/cocktails/southside.jpg differ diff --git a/resources/data/cocktails/spicy-fifty.jpg b/resources/data/cocktails/spicy-fifty.jpg index cd228dbe..3bd6a749 100644 Binary files a/resources/data/cocktails/spicy-fifty.jpg and b/resources/data/cocktails/spicy-fifty.jpg differ diff --git a/resources/data/cocktails/spritz.jpg b/resources/data/cocktails/spritz.jpg index e43a2da8..23ac53e5 100644 Binary files a/resources/data/cocktails/spritz.jpg and b/resources/data/cocktails/spritz.jpg differ diff --git a/resources/data/cocktails/stinger.jpg b/resources/data/cocktails/stinger.jpg index 80bb7a9a..f1b0af49 100644 Binary files a/resources/data/cocktails/stinger.jpg and b/resources/data/cocktails/stinger.jpg differ diff --git a/resources/data/cocktails/strukani-pelin.jpg b/resources/data/cocktails/strukani-pelin.jpg index b8daaf79..b50c7a07 100644 Binary files a/resources/data/cocktails/strukani-pelin.jpg and b/resources/data/cocktails/strukani-pelin.jpg differ diff --git a/resources/data/cocktails/suffering-bastard.jpg b/resources/data/cocktails/suffering-bastard.jpg index 38463af7..7bab42c0 100644 Binary files a/resources/data/cocktails/suffering-bastard.jpg and b/resources/data/cocktails/suffering-bastard.jpg differ diff --git a/resources/data/cocktails/tequila-sunrise.jpg b/resources/data/cocktails/tequila-sunrise.jpg index a665aac0..b384b8c7 100644 Binary files a/resources/data/cocktails/tequila-sunrise.jpg and b/resources/data/cocktails/tequila-sunrise.jpg differ diff --git a/resources/data/cocktails/tipperary.jpg b/resources/data/cocktails/tipperary.jpg index ce53c473..8125b0a8 100644 Binary files a/resources/data/cocktails/tipperary.jpg and b/resources/data/cocktails/tipperary.jpg differ diff --git a/resources/data/cocktails/tommys-margarita.jpg b/resources/data/cocktails/tommys-margarita.jpg index 3227673d..99ff876f 100644 Binary files a/resources/data/cocktails/tommys-margarita.jpg and b/resources/data/cocktails/tommys-margarita.jpg differ diff --git a/resources/data/cocktails/trinidad-sour.jpg b/resources/data/cocktails/trinidad-sour.jpg index 275d7cc5..7ba76694 100644 Binary files a/resources/data/cocktails/trinidad-sour.jpg and b/resources/data/cocktails/trinidad-sour.jpg differ diff --git a/resources/data/cocktails/tuxedo.jpg b/resources/data/cocktails/tuxedo.jpg index 4c5638aa..728e2071 100644 Binary files a/resources/data/cocktails/tuxedo.jpg and b/resources/data/cocktails/tuxedo.jpg differ diff --git a/resources/data/cocktails/vento.jpg b/resources/data/cocktails/vento.jpg index b8c4f919..15e61891 100644 Binary files a/resources/data/cocktails/vento.jpg and b/resources/data/cocktails/vento.jpg differ diff --git a/resources/data/cocktails/vesper.jpg b/resources/data/cocktails/vesper.jpg index 5dbd737e..b70df1b6 100644 Binary files a/resources/data/cocktails/vesper.jpg and b/resources/data/cocktails/vesper.jpg differ diff --git a/resources/data/cocktails/vieux-carre.jpg b/resources/data/cocktails/vieux-carre.jpg index 67690ed0..73d4f1b6 100644 Binary files a/resources/data/cocktails/vieux-carre.jpg and b/resources/data/cocktails/vieux-carre.jpg differ diff --git a/resources/data/cocktails/whiskey-sour.jpg b/resources/data/cocktails/whiskey-sour.jpg index d0c516c9..0a8807c3 100644 Binary files a/resources/data/cocktails/whiskey-sour.jpg and b/resources/data/cocktails/whiskey-sour.jpg differ diff --git a/resources/data/cocktails/white-lady.jpg b/resources/data/cocktails/white-lady.jpg index 5e6be999..98612410 100644 Binary files a/resources/data/cocktails/white-lady.jpg and b/resources/data/cocktails/white-lady.jpg differ diff --git a/resources/data/cocktails/white-negroni.jpg b/resources/data/cocktails/white-negroni.jpg index a13ae5d9..f654acf1 100644 Binary files a/resources/data/cocktails/white-negroni.jpg and b/resources/data/cocktails/white-negroni.jpg differ diff --git a/resources/data/cocktails/yellow-bird.jpg b/resources/data/cocktails/yellow-bird.jpg index 1cceb67e..33bbad15 100644 Binary files a/resources/data/cocktails/yellow-bird.jpg and b/resources/data/cocktails/yellow-bird.jpg differ diff --git a/resources/data/cocktails/zombie.jpg b/resources/data/cocktails/zombie.jpg index 391cf631..6aaa5a4f 100644 Binary files a/resources/data/cocktails/zombie.jpg and b/resources/data/cocktails/zombie.jpg differ diff --git a/resources/data/ingredients/absinthe.png b/resources/data/ingredients/absinthe.png index a91b2c8c..d86b8818 100644 Binary files a/resources/data/ingredients/absinthe.png and b/resources/data/ingredients/absinthe.png differ diff --git a/resources/data/ingredients/agave-syrup.png b/resources/data/ingredients/agave-syrup.png index 16250de9..2339a53a 100644 Binary files a/resources/data/ingredients/agave-syrup.png and b/resources/data/ingredients/agave-syrup.png differ diff --git a/resources/data/ingredients/amaretto.png b/resources/data/ingredients/amaretto.png index 408484de..763f150a 100644 Binary files a/resources/data/ingredients/amaretto.png and b/resources/data/ingredients/amaretto.png differ diff --git a/resources/data/ingredients/amaro-nonino.png b/resources/data/ingredients/amaro-nonino.png index 39da4543..80c2ab79 100644 Binary files a/resources/data/ingredients/amaro-nonino.png and b/resources/data/ingredients/amaro-nonino.png differ diff --git a/resources/data/ingredients/angostura-aromatic-bitters.png b/resources/data/ingredients/angostura-aromatic-bitters.png index 35812622..bc56f381 100644 Binary files a/resources/data/ingredients/angostura-aromatic-bitters.png and b/resources/data/ingredients/angostura-aromatic-bitters.png differ diff --git a/resources/data/ingredients/angostura-cocoa-bitters.png b/resources/data/ingredients/angostura-cocoa-bitters.png index c0da4ce9..ad22cb93 100644 Binary files a/resources/data/ingredients/angostura-cocoa-bitters.png and b/resources/data/ingredients/angostura-cocoa-bitters.png differ diff --git a/resources/data/ingredients/aperol.png b/resources/data/ingredients/aperol.png index 58c087c3..d690b1a8 100644 Binary files a/resources/data/ingredients/aperol.png and b/resources/data/ingredients/aperol.png differ diff --git a/resources/data/ingredients/apple.png b/resources/data/ingredients/apple.png index 4cb4e425..edab9eae 100644 Binary files a/resources/data/ingredients/apple.png and b/resources/data/ingredients/apple.png differ diff --git a/resources/data/ingredients/apricot-brandy.png b/resources/data/ingredients/apricot-brandy.png index 93e90191..6bb83f5b 100644 Binary files a/resources/data/ingredients/apricot-brandy.png and b/resources/data/ingredients/apricot-brandy.png differ diff --git a/resources/data/ingredients/baileys-irish-cream.png b/resources/data/ingredients/baileys-irish-cream.png index 835954fe..f2efcec1 100644 Binary files a/resources/data/ingredients/baileys-irish-cream.png and b/resources/data/ingredients/baileys-irish-cream.png differ diff --git a/resources/data/ingredients/basil.png b/resources/data/ingredients/basil.png new file mode 100644 index 00000000..37fd8feb Binary files /dev/null and b/resources/data/ingredients/basil.png differ diff --git a/resources/data/ingredients/benedictine.png b/resources/data/ingredients/benedictine.png index 68a4d0b9..9e0b2777 100644 Binary files a/resources/data/ingredients/benedictine.png and b/resources/data/ingredients/benedictine.png differ diff --git a/resources/data/ingredients/blue-curacao.png b/resources/data/ingredients/blue-curacao.png index b32cb092..e4ff9f70 100644 Binary files a/resources/data/ingredients/blue-curacao.png and b/resources/data/ingredients/blue-curacao.png differ diff --git a/resources/data/ingredients/bourbon-whiskey.png b/resources/data/ingredients/bourbon-whiskey.png index 88b2f360..571cae54 100644 Binary files a/resources/data/ingredients/bourbon-whiskey.png and b/resources/data/ingredients/bourbon-whiskey.png differ diff --git a/resources/data/ingredients/brandy.png b/resources/data/ingredients/brandy.png index 7e39c4fa..a2c507fd 100644 Binary files a/resources/data/ingredients/brandy.png and b/resources/data/ingredients/brandy.png differ diff --git a/resources/data/ingredients/cachaca.png b/resources/data/ingredients/cachaca.png index 94d7b543..12b292ef 100644 Binary files a/resources/data/ingredients/cachaca.png and b/resources/data/ingredients/cachaca.png differ diff --git a/resources/data/ingredients/calvados.png b/resources/data/ingredients/calvados.png index c543849a..c208d5e0 100644 Binary files a/resources/data/ingredients/calvados.png and b/resources/data/ingredients/calvados.png differ diff --git a/resources/data/ingredients/campari.png b/resources/data/ingredients/campari.png index 729e6a83..0fb70cfc 100644 Binary files a/resources/data/ingredients/campari.png and b/resources/data/ingredients/campari.png differ diff --git a/resources/data/ingredients/chambord.png b/resources/data/ingredients/chambord.png index 12d2edcf..454d9e91 100644 Binary files a/resources/data/ingredients/chambord.png and b/resources/data/ingredients/chambord.png differ diff --git a/resources/data/ingredients/chamomile-cordial.png b/resources/data/ingredients/chamomile-cordial.png index d4249812..b246e540 100644 Binary files a/resources/data/ingredients/chamomile-cordial.png and b/resources/data/ingredients/chamomile-cordial.png differ diff --git a/resources/data/ingredients/champagne.png b/resources/data/ingredients/champagne.png index f039a9de..fd3c782a 100644 Binary files a/resources/data/ingredients/champagne.png and b/resources/data/ingredients/champagne.png differ diff --git a/resources/data/ingredients/chilli-pepper.png b/resources/data/ingredients/chilli-pepper.png index 82de4c51..7363d19a 100644 Binary files a/resources/data/ingredients/chilli-pepper.png and b/resources/data/ingredients/chilli-pepper.png differ diff --git a/resources/data/ingredients/cilantro.png b/resources/data/ingredients/cilantro.png new file mode 100644 index 00000000..210efbb8 Binary files /dev/null and b/resources/data/ingredients/cilantro.png differ diff --git a/resources/data/ingredients/club-soda.png b/resources/data/ingredients/club-soda.png index 148308c2..ac82c37c 100644 Binary files a/resources/data/ingredients/club-soda.png and b/resources/data/ingredients/club-soda.png differ diff --git a/resources/data/ingredients/coconut-cream.png b/resources/data/ingredients/coconut-cream.png index c91cf3b5..2589fc8a 100644 Binary files a/resources/data/ingredients/coconut-cream.png and b/resources/data/ingredients/coconut-cream.png differ diff --git a/resources/data/ingredients/coffee.png b/resources/data/ingredients/coffee.png index d8ef86bc..24b1f469 100644 Binary files a/resources/data/ingredients/coffee.png and b/resources/data/ingredients/coffee.png differ diff --git a/resources/data/ingredients/cognac.png b/resources/data/ingredients/cognac.png index 19b690a8..465d93a5 100644 Binary files a/resources/data/ingredients/cognac.png and b/resources/data/ingredients/cognac.png differ diff --git a/resources/data/ingredients/cointreau.png b/resources/data/ingredients/cointreau.png index 25aa240d..1ec6f6ba 100644 Binary files a/resources/data/ingredients/cointreau.png and b/resources/data/ingredients/cointreau.png differ diff --git a/resources/data/ingredients/cola.png b/resources/data/ingredients/cola.png index 52568500..4af185e8 100644 Binary files a/resources/data/ingredients/cola.png and b/resources/data/ingredients/cola.png differ diff --git a/resources/data/ingredients/cranberry-juice.png b/resources/data/ingredients/cranberry-juice.png index 2da1eed4..dfcb9487 100644 Binary files a/resources/data/ingredients/cranberry-juice.png and b/resources/data/ingredients/cranberry-juice.png differ diff --git a/resources/data/ingredients/cream.png b/resources/data/ingredients/cream.png index b43ede78..dd37336d 100644 Binary files a/resources/data/ingredients/cream.png and b/resources/data/ingredients/cream.png differ diff --git a/resources/data/ingredients/creme-de-cassis-blackcurrant-liqueur.png b/resources/data/ingredients/creme-de-cassis-blackcurrant-liqueur.png index 37157948..f950073d 100644 Binary files a/resources/data/ingredients/creme-de-cassis-blackcurrant-liqueur.png and b/resources/data/ingredients/creme-de-cassis-blackcurrant-liqueur.png differ diff --git a/resources/data/ingredients/creme-de-mure-blackberry-liqueur.png b/resources/data/ingredients/creme-de-mure-blackberry-liqueur.png index 1156e2d8..e1622ebb 100644 Binary files a/resources/data/ingredients/creme-de-mure-blackberry-liqueur.png and b/resources/data/ingredients/creme-de-mure-blackberry-liqueur.png differ diff --git a/resources/data/ingredients/creme-de-violette.png b/resources/data/ingredients/creme-de-violette.png index cb697a1b..9f8ccfd1 100644 Binary files a/resources/data/ingredients/creme-de-violette.png and b/resources/data/ingredients/creme-de-violette.png differ diff --git a/resources/data/ingredients/dark-creme-de-cacao.png b/resources/data/ingredients/dark-creme-de-cacao.png index 19eb0e81..e686cbfa 100644 Binary files a/resources/data/ingredients/dark-creme-de-cacao.png and b/resources/data/ingredients/dark-creme-de-cacao.png differ diff --git a/resources/data/ingredients/dark-rum.png b/resources/data/ingredients/dark-rum.png index 56f7c0a3..21c7f9df 100644 Binary files a/resources/data/ingredients/dark-rum.png and b/resources/data/ingredients/dark-rum.png differ diff --git a/resources/data/ingredients/demerara-rum.png b/resources/data/ingredients/demerara-rum.png index 96049f60..3b18ea0f 100644 Binary files a/resources/data/ingredients/demerara-rum.png and b/resources/data/ingredients/demerara-rum.png differ diff --git a/resources/data/ingredients/donns-mix.png b/resources/data/ingredients/donns-mix.png index 3b0b3617..d111e7b8 100644 Binary files a/resources/data/ingredients/donns-mix.png and b/resources/data/ingredients/donns-mix.png differ diff --git a/resources/data/ingredients/drambuie.png b/resources/data/ingredients/drambuie.png index 677a3ea6..f1e3d44e 100644 Binary files a/resources/data/ingredients/drambuie.png and b/resources/data/ingredients/drambuie.png differ diff --git a/resources/data/ingredients/dry-curacao.png b/resources/data/ingredients/dry-curacao.png index 701ea9c7..70f503af 100644 Binary files a/resources/data/ingredients/dry-curacao.png and b/resources/data/ingredients/dry-curacao.png differ diff --git a/resources/data/ingredients/dry-sherry.png b/resources/data/ingredients/dry-sherry.png index 0dea9f22..1ab2b629 100644 Binary files a/resources/data/ingredients/dry-sherry.png and b/resources/data/ingredients/dry-sherry.png differ diff --git a/resources/data/ingredients/dry-vermouth.png b/resources/data/ingredients/dry-vermouth.png index afe48ca0..61eaf610 100644 Binary files a/resources/data/ingredients/dry-vermouth.png and b/resources/data/ingredients/dry-vermouth.png differ diff --git a/resources/data/ingredients/egg-white.png b/resources/data/ingredients/egg-white.png index 864d377c..8ade6f0f 100644 Binary files a/resources/data/ingredients/egg-white.png and b/resources/data/ingredients/egg-white.png differ diff --git a/resources/data/ingredients/egg-yolk.png b/resources/data/ingredients/egg-yolk.png index 7d2b9b2f..aabeab51 100644 Binary files a/resources/data/ingredients/egg-yolk.png and b/resources/data/ingredients/egg-yolk.png differ diff --git a/resources/data/ingredients/elderflower-cordial.png b/resources/data/ingredients/elderflower-cordial.png index be799861..cf5273eb 100644 Binary files a/resources/data/ingredients/elderflower-cordial.png and b/resources/data/ingredients/elderflower-cordial.png differ diff --git a/resources/data/ingredients/espresso.png b/resources/data/ingredients/espresso.png index 6644f84c..af928b80 100644 Binary files a/resources/data/ingredients/espresso.png and b/resources/data/ingredients/espresso.png differ diff --git a/resources/data/ingredients/falernum.png b/resources/data/ingredients/falernum.png index b292af4f..9cf02331 100644 Binary files a/resources/data/ingredients/falernum.png and b/resources/data/ingredients/falernum.png differ diff --git a/resources/data/ingredients/fernet-branca.png b/resources/data/ingredients/fernet-branca.png index d3ef7c9f..accd1eb7 100644 Binary files a/resources/data/ingredients/fernet-branca.png and b/resources/data/ingredients/fernet-branca.png differ diff --git a/resources/data/ingredients/galliano.png b/resources/data/ingredients/galliano.png index 550a17ad..6810ca8f 100644 Binary files a/resources/data/ingredients/galliano.png and b/resources/data/ingredients/galliano.png differ diff --git a/resources/data/ingredients/gin.png b/resources/data/ingredients/gin.png index 557b49e1..10dad125 100644 Binary files a/resources/data/ingredients/gin.png and b/resources/data/ingredients/gin.png differ diff --git a/resources/data/ingredients/ginger-beer.png b/resources/data/ingredients/ginger-beer.png index f15fbac5..58a6c7dc 100644 Binary files a/resources/data/ingredients/ginger-beer.png and b/resources/data/ingredients/ginger-beer.png differ diff --git a/resources/data/ingredients/ginger-syrup.png b/resources/data/ingredients/ginger-syrup.png index 6ab26243..28c8af46 100644 Binary files a/resources/data/ingredients/ginger-syrup.png and b/resources/data/ingredients/ginger-syrup.png differ diff --git a/resources/data/ingredients/ginger.png b/resources/data/ingredients/ginger.png index ed7d1876..ae34c2b9 100644 Binary files a/resources/data/ingredients/ginger.png and b/resources/data/ingredients/ginger.png differ diff --git a/resources/data/ingredients/gold-rum.png b/resources/data/ingredients/gold-rum.png index 67709128..7ddb794a 100644 Binary files a/resources/data/ingredients/gold-rum.png and b/resources/data/ingredients/gold-rum.png differ diff --git a/resources/data/ingredients/grand-marnier.png b/resources/data/ingredients/grand-marnier.png index 9fd3ede5..ba116604 100644 Binary files a/resources/data/ingredients/grand-marnier.png and b/resources/data/ingredients/grand-marnier.png differ diff --git a/resources/data/ingredients/grapefruit-juice.png b/resources/data/ingredients/grapefruit-juice.png index 2e4f17de..980205fa 100644 Binary files a/resources/data/ingredients/grapefruit-juice.png and b/resources/data/ingredients/grapefruit-juice.png differ diff --git a/resources/data/ingredients/grappa.png b/resources/data/ingredients/grappa.png index b5109f6a..5316fa37 100644 Binary files a/resources/data/ingredients/grappa.png and b/resources/data/ingredients/grappa.png differ diff --git a/resources/data/ingredients/green-chartreuse.png b/resources/data/ingredients/green-chartreuse.png index b3c001a1..72129041 100644 Binary files a/resources/data/ingredients/green-chartreuse.png and b/resources/data/ingredients/green-chartreuse.png differ diff --git a/resources/data/ingredients/grenadine-syrup.png b/resources/data/ingredients/grenadine-syrup.png index 89eba1cc..c2c70e53 100644 Binary files a/resources/data/ingredients/grenadine-syrup.png and b/resources/data/ingredients/grenadine-syrup.png differ diff --git a/resources/data/ingredients/honey-syrup.png b/resources/data/ingredients/honey-syrup.png index ca05297e..91e01fb2 100644 Binary files a/resources/data/ingredients/honey-syrup.png and b/resources/data/ingredients/honey-syrup.png differ diff --git a/resources/data/ingredients/irish-whiskey.png b/resources/data/ingredients/irish-whiskey.png index f69086c1..96ec8e47 100644 Binary files a/resources/data/ingredients/irish-whiskey.png and b/resources/data/ingredients/irish-whiskey.png differ diff --git a/resources/data/ingredients/islay-scotch.png b/resources/data/ingredients/islay-scotch.png index 3daebbe3..28180d9b 100644 Binary files a/resources/data/ingredients/islay-scotch.png and b/resources/data/ingredients/islay-scotch.png differ diff --git a/resources/data/ingredients/jamaican-rum.png b/resources/data/ingredients/jamaican-rum.png index 4ec1b1fd..be06c439 100644 Binary files a/resources/data/ingredients/jamaican-rum.png and b/resources/data/ingredients/jamaican-rum.png differ diff --git a/resources/data/ingredients/kahlua-coffee-liqueur.png b/resources/data/ingredients/kahlua-coffee-liqueur.png index 166ebace..d7630d15 100644 Binary files a/resources/data/ingredients/kahlua-coffee-liqueur.png and b/resources/data/ingredients/kahlua-coffee-liqueur.png differ diff --git a/resources/data/ingredients/lemon-juice.png b/resources/data/ingredients/lemon-juice.png index f1680c93..adc4c292 100644 Binary files a/resources/data/ingredients/lemon-juice.png and b/resources/data/ingredients/lemon-juice.png differ diff --git a/resources/data/ingredients/lemon.png b/resources/data/ingredients/lemon.png index 11111ea5..ab67bc88 100644 Binary files a/resources/data/ingredients/lemon.png and b/resources/data/ingredients/lemon.png differ diff --git a/resources/data/ingredients/lillet-blanc.png b/resources/data/ingredients/lillet-blanc.png index 6b3f6295..0a8ba52c 100644 Binary files a/resources/data/ingredients/lillet-blanc.png and b/resources/data/ingredients/lillet-blanc.png differ diff --git a/resources/data/ingredients/lime-juice.png b/resources/data/ingredients/lime-juice.png index 7d25048f..1b0f9e06 100644 Binary files a/resources/data/ingredients/lime-juice.png and b/resources/data/ingredients/lime-juice.png differ diff --git a/resources/data/ingredients/lime.png b/resources/data/ingredients/lime.png index 152e443b..39f41868 100644 Binary files a/resources/data/ingredients/lime.png and b/resources/data/ingredients/lime.png differ diff --git a/resources/data/ingredients/maraschino.png b/resources/data/ingredients/maraschino.png index d8bb293a..194d4ee2 100644 Binary files a/resources/data/ingredients/maraschino.png and b/resources/data/ingredients/maraschino.png differ diff --git a/resources/data/ingredients/menthe-creme-de-cacao.png b/resources/data/ingredients/menthe-creme-de-cacao.png index d34617a1..d88efb8a 100644 Binary files a/resources/data/ingredients/menthe-creme-de-cacao.png and b/resources/data/ingredients/menthe-creme-de-cacao.png differ diff --git a/resources/data/ingredients/mezcal.png b/resources/data/ingredients/mezcal.png index 1cdd18b5..e7b1f604 100644 Binary files a/resources/data/ingredients/mezcal.png and b/resources/data/ingredients/mezcal.png differ diff --git a/resources/data/ingredients/mint.png b/resources/data/ingredients/mint.png index cf5ef0ae..4a5aa87b 100644 Binary files a/resources/data/ingredients/mint.png and b/resources/data/ingredients/mint.png differ diff --git a/resources/data/ingredients/old-tom-gin.png b/resources/data/ingredients/old-tom-gin.png index 02703827..46db5f59 100644 Binary files a/resources/data/ingredients/old-tom-gin.png and b/resources/data/ingredients/old-tom-gin.png differ diff --git a/resources/data/ingredients/orange-bitters.png b/resources/data/ingredients/orange-bitters.png index dd990d90..767bdeef 100644 Binary files a/resources/data/ingredients/orange-bitters.png and b/resources/data/ingredients/orange-bitters.png differ diff --git a/resources/data/ingredients/orange-curacao.png b/resources/data/ingredients/orange-curacao.png index ed215ced..50ae95b6 100644 Binary files a/resources/data/ingredients/orange-curacao.png and b/resources/data/ingredients/orange-curacao.png differ diff --git a/resources/data/ingredients/orange-flower-water.png b/resources/data/ingredients/orange-flower-water.png index 09a1b72b..e2ef9611 100644 Binary files a/resources/data/ingredients/orange-flower-water.png and b/resources/data/ingredients/orange-flower-water.png differ diff --git a/resources/data/ingredients/orange-juice.png b/resources/data/ingredients/orange-juice.png index 6394ec44..73b69b88 100644 Binary files a/resources/data/ingredients/orange-juice.png and b/resources/data/ingredients/orange-juice.png differ diff --git a/resources/data/ingredients/orange.png b/resources/data/ingredients/orange.png index 375aa040..b3f51d92 100644 Binary files a/resources/data/ingredients/orange.png and b/resources/data/ingredients/orange.png differ diff --git a/resources/data/ingredients/orgeat-syrup.png b/resources/data/ingredients/orgeat-syrup.png index 993724ea..ff96cf7a 100644 Binary files a/resources/data/ingredients/orgeat-syrup.png and b/resources/data/ingredients/orgeat-syrup.png differ diff --git a/resources/data/ingredients/ouzo.png b/resources/data/ingredients/ouzo.png index f0bee759..4c91e99b 100644 Binary files a/resources/data/ingredients/ouzo.png and b/resources/data/ingredients/ouzo.png differ diff --git a/resources/data/ingredients/overproof-rum.png b/resources/data/ingredients/overproof-rum.png index d1cd6715..ac5ee6ee 100644 Binary files a/resources/data/ingredients/overproof-rum.png and b/resources/data/ingredients/overproof-rum.png differ diff --git a/resources/data/ingredients/passoa.png b/resources/data/ingredients/passoa.png index 0bae5780..28b22b13 100644 Binary files a/resources/data/ingredients/passoa.png and b/resources/data/ingredients/passoa.png differ diff --git a/resources/data/ingredients/peach-bitters.png b/resources/data/ingredients/peach-bitters.png index 82d6b6c3..d0fbb17b 100644 Binary files a/resources/data/ingredients/peach-bitters.png and b/resources/data/ingredients/peach-bitters.png differ diff --git a/resources/data/ingredients/peach-schnapps.png b/resources/data/ingredients/peach-schnapps.png index 143ce7f8..22ff6879 100644 Binary files a/resources/data/ingredients/peach-schnapps.png and b/resources/data/ingredients/peach-schnapps.png differ diff --git a/resources/data/ingredients/peach.png b/resources/data/ingredients/peach.png index 779e83d2..1bdf87be 100644 Binary files a/resources/data/ingredients/peach.png and b/resources/data/ingredients/peach.png differ diff --git a/resources/data/ingredients/pelinkovac.png b/resources/data/ingredients/pelinkovac.png index f026581f..ea0afcd8 100644 Binary files a/resources/data/ingredients/pelinkovac.png and b/resources/data/ingredients/pelinkovac.png differ diff --git a/resources/data/ingredients/pepper.png b/resources/data/ingredients/pepper.png index 51f0e8a5..c8feaff3 100644 Binary files a/resources/data/ingredients/pepper.png and b/resources/data/ingredients/pepper.png differ diff --git a/resources/data/ingredients/pernod.png b/resources/data/ingredients/pernod.png index 8cbd290a..5becb9e3 100644 Binary files a/resources/data/ingredients/pernod.png and b/resources/data/ingredients/pernod.png differ diff --git a/resources/data/ingredients/peychauds-bitters.png b/resources/data/ingredients/peychauds-bitters.png index ac0f5c78..5327361c 100644 Binary files a/resources/data/ingredients/peychauds-bitters.png and b/resources/data/ingredients/peychauds-bitters.png differ diff --git a/resources/data/ingredients/pineapple-juice.png b/resources/data/ingredients/pineapple-juice.png index 02b1e1c7..b23c8d8b 100644 Binary files a/resources/data/ingredients/pineapple-juice.png and b/resources/data/ingredients/pineapple-juice.png differ diff --git a/resources/data/ingredients/pineapple.png b/resources/data/ingredients/pineapple.png index 89254c07..035b1a0e 100644 Binary files a/resources/data/ingredients/pineapple.png and b/resources/data/ingredients/pineapple.png differ diff --git a/resources/data/ingredients/pisco.png b/resources/data/ingredients/pisco.png index e88c6ba6..18fe461d 100644 Binary files a/resources/data/ingredients/pisco.png and b/resources/data/ingredients/pisco.png differ diff --git a/resources/data/ingredients/prosecco.png b/resources/data/ingredients/prosecco.png index b1e533d7..e33084fa 100644 Binary files a/resources/data/ingredients/prosecco.png and b/resources/data/ingredients/prosecco.png differ diff --git a/resources/data/ingredients/raspberry-syrup.png b/resources/data/ingredients/raspberry-syrup.png index b4d5fc7e..fd388838 100644 Binary files a/resources/data/ingredients/raspberry-syrup.png and b/resources/data/ingredients/raspberry-syrup.png differ diff --git a/resources/data/ingredients/red-wine.png b/resources/data/ingredients/red-wine.png index 1841f97f..18a6510b 100644 Binary files a/resources/data/ingredients/red-wine.png and b/resources/data/ingredients/red-wine.png differ diff --git a/resources/data/ingredients/rhum-agricole.png b/resources/data/ingredients/rhum-agricole.png index 58876493..11eb179e 100644 Binary files a/resources/data/ingredients/rhum-agricole.png and b/resources/data/ingredients/rhum-agricole.png differ diff --git a/resources/data/ingredients/rye-whiskey.png b/resources/data/ingredients/rye-whiskey.png index 0190bf55..22f5b0ba 100644 Binary files a/resources/data/ingredients/rye-whiskey.png and b/resources/data/ingredients/rye-whiskey.png differ diff --git a/resources/data/ingredients/salt.png b/resources/data/ingredients/salt.png index 767e22de..cceedaf9 100644 Binary files a/resources/data/ingredients/salt.png and b/resources/data/ingredients/salt.png differ diff --git a/resources/data/ingredients/scotch-whiskey.png b/resources/data/ingredients/scotch-whiskey.png index fd131119..0eaf70a3 100644 Binary files a/resources/data/ingredients/scotch-whiskey.png and b/resources/data/ingredients/scotch-whiskey.png differ diff --git a/resources/data/ingredients/simple-syrup.png b/resources/data/ingredients/simple-syrup.png index ad3e1c4e..34a0066d 100644 Binary files a/resources/data/ingredients/simple-syrup.png and b/resources/data/ingredients/simple-syrup.png differ diff --git a/resources/data/ingredients/sloe-gin.png b/resources/data/ingredients/sloe-gin.png index 284ff9fb..a03d5688 100644 Binary files a/resources/data/ingredients/sloe-gin.png and b/resources/data/ingredients/sloe-gin.png differ diff --git a/resources/data/ingredients/st-germain.png b/resources/data/ingredients/st-germain.png index 12e37abb..cc1f9042 100644 Binary files a/resources/data/ingredients/st-germain.png and b/resources/data/ingredients/st-germain.png differ diff --git a/resources/data/ingredients/sugar.png b/resources/data/ingredients/sugar.png index fb3ec71a..ed97fa54 100644 Binary files a/resources/data/ingredients/sugar.png and b/resources/data/ingredients/sugar.png differ diff --git a/resources/data/ingredients/suze.png b/resources/data/ingredients/suze.png index d8147509..a4cf3b0a 100644 Binary files a/resources/data/ingredients/suze.png and b/resources/data/ingredients/suze.png differ diff --git a/resources/data/ingredients/sweet-vermouth.png b/resources/data/ingredients/sweet-vermouth.png index 6c758469..5586cb18 100644 Binary files a/resources/data/ingredients/sweet-vermouth.png and b/resources/data/ingredients/sweet-vermouth.png differ diff --git a/resources/data/ingredients/tabasco.png b/resources/data/ingredients/tabasco.png index ac684e5c..3699085b 100644 Binary files a/resources/data/ingredients/tabasco.png and b/resources/data/ingredients/tabasco.png differ diff --git a/resources/data/ingredients/tequila-anejo.png b/resources/data/ingredients/tequila-anejo.png index a73bfbe8..32a50953 100644 Binary files a/resources/data/ingredients/tequila-anejo.png and b/resources/data/ingredients/tequila-anejo.png differ diff --git a/resources/data/ingredients/tequila-extra-anejo.png b/resources/data/ingredients/tequila-extra-anejo.png index 6c20d791..3462724a 100644 Binary files a/resources/data/ingredients/tequila-extra-anejo.png and b/resources/data/ingredients/tequila-extra-anejo.png differ diff --git a/resources/data/ingredients/tequila-reposado.png b/resources/data/ingredients/tequila-reposado.png index d121058d..e311985f 100644 Binary files a/resources/data/ingredients/tequila-reposado.png and b/resources/data/ingredients/tequila-reposado.png differ diff --git a/resources/data/ingredients/tequila.png b/resources/data/ingredients/tequila.png index 7dbad1a5..6318587c 100644 Binary files a/resources/data/ingredients/tequila.png and b/resources/data/ingredients/tequila.png differ diff --git a/resources/data/ingredients/tomato-juice.png b/resources/data/ingredients/tomato-juice.png index 6cf31995..adcde9e2 100644 Binary files a/resources/data/ingredients/tomato-juice.png and b/resources/data/ingredients/tomato-juice.png differ diff --git a/resources/data/ingredients/tonic.png b/resources/data/ingredients/tonic.png index 205eee45..c4735152 100644 Binary files a/resources/data/ingredients/tonic.png and b/resources/data/ingredients/tonic.png differ diff --git a/resources/data/ingredients/triple-sec.png b/resources/data/ingredients/triple-sec.png index eda74d9e..2e7030d2 100644 Binary files a/resources/data/ingredients/triple-sec.png and b/resources/data/ingredients/triple-sec.png differ diff --git a/resources/data/ingredients/vanilla-extract.png b/resources/data/ingredients/vanilla-extract.png index a1d048e4..954bc22a 100644 Binary files a/resources/data/ingredients/vanilla-extract.png and b/resources/data/ingredients/vanilla-extract.png differ diff --git a/resources/data/ingredients/vanilla-vodka.png b/resources/data/ingredients/vanilla-vodka.png index 787c5653..65ea6079 100644 Binary files a/resources/data/ingredients/vanilla-vodka.png and b/resources/data/ingredients/vanilla-vodka.png differ diff --git a/resources/data/ingredients/vodka-citron.png b/resources/data/ingredients/vodka-citron.png index 350c63d0..94c523a3 100644 Binary files a/resources/data/ingredients/vodka-citron.png and b/resources/data/ingredients/vodka-citron.png differ diff --git a/resources/data/ingredients/vodka.png b/resources/data/ingredients/vodka.png index 3d369384..938f9c57 100644 Binary files a/resources/data/ingredients/vodka.png and b/resources/data/ingredients/vodka.png differ diff --git a/resources/data/ingredients/water.png b/resources/data/ingredients/water.png index 2f0610cb..e60bc913 100644 Binary files a/resources/data/ingredients/water.png and b/resources/data/ingredients/water.png differ diff --git a/resources/data/ingredients/whiskey.png b/resources/data/ingredients/whiskey.png index 3f174fcf..873c4c27 100644 Binary files a/resources/data/ingredients/whiskey.png and b/resources/data/ingredients/whiskey.png differ diff --git a/resources/data/ingredients/white-creme-de-cacao.png b/resources/data/ingredients/white-creme-de-cacao.png index bf2d4e77..10f0cc57 100644 Binary files a/resources/data/ingredients/white-creme-de-cacao.png and b/resources/data/ingredients/white-creme-de-cacao.png differ diff --git a/resources/data/ingredients/white-peach-puree.png b/resources/data/ingredients/white-peach-puree.png index 91894fd2..a94718d7 100644 Binary files a/resources/data/ingredients/white-peach-puree.png and b/resources/data/ingredients/white-peach-puree.png differ diff --git a/resources/data/ingredients/white-rum.png b/resources/data/ingredients/white-rum.png index de719f16..3de3ec19 100644 Binary files a/resources/data/ingredients/white-rum.png and b/resources/data/ingredients/white-rum.png differ diff --git a/resources/data/ingredients/white-wine.png b/resources/data/ingredients/white-wine.png index 6d973670..b4bee129 100644 Binary files a/resources/data/ingredients/white-wine.png and b/resources/data/ingredients/white-wine.png differ diff --git a/resources/data/ingredients/worcestershire-sauce.png b/resources/data/ingredients/worcestershire-sauce.png index bba8b7a2..2b3fe9af 100644 Binary files a/resources/data/ingredients/worcestershire-sauce.png and b/resources/data/ingredients/worcestershire-sauce.png differ diff --git a/resources/data/ingredients/yellow-chartreuse.png b/resources/data/ingredients/yellow-chartreuse.png index 20488a31..f082d8f0 100644 Binary files a/resources/data/ingredients/yellow-chartreuse.png and b/resources/data/ingredients/yellow-chartreuse.png differ diff --git a/resources/data/popular_cocktails.yml b/resources/data/popular_cocktails.yml deleted file mode 100644 index f8003ce6..00000000 --- a/resources/data/popular_cocktails.yml +++ /dev/null @@ -1,787 +0,0 @@ -- - name: 'Japanese cocktail' - description: |- - The Japanese cocktail is notorious for a handful of things: it's a very old cocktail, published in Jerry Thomas’s landmark 1862 book How to Mix Drinks; it was the first mixed drink to be named something other than “Whiskey Cocktail”, “Brandy Cocktail”, or some other similarly obvious epithet; it might be the only cocktail Thomas invented himself; and finally, it was the first cocktail to feature more than a dash of a fancy sweetener, orgeat. - instructions: |- - 1. Combine all ingredients in a mixing glass with ice and stir - 2. Strain into a coupe, serve up - garnish: 'Lemon peel' - source: null - images: - - copyright: 'The Drink Blog' - glass: Coupe - method: Stir - tags: - - Brandy - ingredients: - - - amount: 60 - units: ml - name: Brandy - optional: false - - - amount: 15 - units: ml - name: Orgeat Syrup - optional: false - - - amount: 2 - units: dashes - name: Angostura aromatic bitters - optional: false -- - name: 'Porn Star Martini' - description: |- - This easy passion fruit cocktail is bursting with zingy flavours and is perfect for celebrating with friends. - instructions: |- - 1. Scoop the seeds from one of the passion fruits into the glass of a cocktail shaker - 2. Add the vodka, passoa, lime juice and sugar syrup. - 3. Add a handful of ice and shake well - 4. Strain into a martini glass - 5. Serve with shot of chilled Champagne on the side - garnish: 'Half a passion fruit on top' - source: 'Douglas Ankrah, The Townhouse | London' - images: - - copyright: 'Punch / Jamie Lau' - glass: Coupe - method: Shake - tags: - - Vodka - ingredients: - - - amount: 60 - units: ml - name: Vanilla Vodka - optional: false - substitutes: [Vodka] - - - amount: 15 - units: ml - name: Passoã - optional: false - - - amount: 15 - units: ml - name: Simple syrup - optional: false - - - amount: 15 - units: ml - name: Lime juice - optional: false - - - amount: 60 - units: ml - name: Champagne - optional: true - substitutes: [Prosecco] -- - name: 'Amaretto Sour' - description: |- - Amaretto is an Italian liqueur that’s typically flavored with almonds or apricot stones. Its distinctive flavor can be incorporated into numerous cocktails, but it’s best known for the Amaretto Sour, a drink that tends to get a bad rap. That’s because, too often, the cocktail is overly sweet and relies on premade sour mix. - instructions: |- - 1. Combine all ingredients and, if using an egg white, dry shake - 2. Add ice and shake for 10 sec - 3. Strain into a coupe, serve up - garnish: 'Spray aromatic bitters over foaming cocktail from atomiser and then garnish with lemon & cherry sail (lemon slice & Luxardo Maraschino cherry on stick)' - source: '1974' - images: - - copyright: 'The Spruce Eats' - glass: Lowball - method: Shake - tags: - - Sour - ingredients: - - - amount: 60 - units: ml - name: Amaretto - optional: false - - - amount: 30 - units: ml - name: Lemon juice - optional: false - - - amount: 1 - units: dash - name: Angostura aromatic bitters - optional: false - - - amount: 15 - units: ml - name: Egg white - optional: true -- - name: 'Cantaritos' - description: |- - Cantaritos are Mexican tequila cocktails served in clay cups! Similar to the Paloma, this drink stars grapefruit soda and citrus. - instructions: |- - 1. If using the traditional clay cup for serving, soak it in cold water for 10 minutes before using. Otherwise, use a highball glass. - 2. Combine the tequila, orange juice, lemon juice and lime juice in the glass with a pinch of salt. - 3. Fill the glass with ice and top with grapefruit soda. - garnish: 'Citrus wedges' - source: 'Mexico' - images: - - copyright: 'A Couple Cooks' - glass: Highball - method: Build - tags: - - Tequila - ingredients: - - - amount: 60 - units: ml - name: Tequila reposado - optional: false - - - amount: 45 - units: ml - name: Orange juice - optional: false - - - amount: 15 - units: ml - name: Lime juice - optional: false - - - amount: 15 - units: ml - name: Lemon juice - optional: false - - - amount: 90 - units: ml - name: Grapefruit juice - optional: false - - - amount: 2 - units: pinch - name: Salt - optional: true -- - name: 'White Negroni' - description: |- - The White Negroni is a fabulous use of the gentian's powers, and a downright brilliant twist on a bonafide classic. All of the flavor components of a classic Negroni are present, but only the gin remains verbatim. - instructions: |- - 1. Combine all ingredients with ice and stir - 2. Strain into a lowball glass - garnish: 'Lemon peel' - source: 'https://tuxedono2.com/white-negroni-cocktail-recipe' - images: - - copyright: 'A Couple Cooks' - glass: Lowball - method: Stir - tags: - - Gin - ingredients: - - - amount: 30 - units: ml - name: Gin - optional: false - - - amount: 30 - units: ml - name: Suze - optional: false - - - amount: 30 - units: ml - name: Lillet Blanc - optional: false -- - name: 'B-52' - description: |- - The origins of the B-52 are not well documented, but one claim is that the B-52 was invented by Peter Fich, a head bartender at the Banff Springs Hotel in Alberta, Canada. Fich named all of his new drinks after favorite bands, albums, and songs, and he supposedly named the drink after the band of the same name, not directly after the US B-52 Stratofortress bomber after which the band was named. - instructions: |- - Refrigerate ingredients then layer in chilled glass by carefully pouring in the ingredient order. - garnish: null - source: 'https://en.wikipedia.org/wiki/B-52_(cocktail)' - images: - - copyright: Alchetron - glass: Shot - method: Layer - tags: - - Shot - ingredients: - - - amount: 15 - units: ml - name: Kahlua coffee liqueur - optional: false - - - amount: 15 - units: ml - name: Baileys Irish Cream - optional: false - - - amount: 15 - units: ml - name: Grand Marnier - optional: false -- - name: 'Bacardi Cocktail' - description: |- - The Bacardí Cocktail was originally the same as the Daiquiri, containing rum, lime juice, and sugar; The Grenadine version of the Bacardí Cocktail originated in the US, while the original non-red Bacardí company recipe originated from Cuba. - On April 28, 1936 the New York Supreme Court ruled that the drink must contain Bacardí rum in order to be called a Bacardí cocktail. - instructions: |- - Shake together with ice. Strain into glass and serve - garnish: 'Lime' - source: 'https://en.wikipedia.org/wiki/Bacardi_cocktail' - images: - - copyright: 'Liquor.com / Tim Nusog' - glass: Coupe - method: Shake - tags: - - Rum - ingredients: - - - amount: 60 - units: ml - name: White rum - optional: false - - - amount: 15 - units: ml - name: Lime juice - optional: false - - - amount: 7.5 - units: ml - name: Grenadine Syrup - optional: false - - - amount: 1 - units: barspoon - name: Simple syrup - optional: false -- - name: 'Bijou' - description: |- - This cocktail was invented by Harry Johnson, "the father of professional bartending", who called it bijou because it combined the colors of three jewels: gin for diamond, vermouth for ruby, and chartreuse for emerald. - instructions: |- - Stir in mixing glass with ice and strain - garnish: 'Maraschino cherry' - source: 'https://en.wikipedia.org/wiki/Bijou_(cocktail)' - images: - - copyright: 'A Couple Cooks' - glass: 'Nick and Nora' - method: Stir - tags: - - Gin - ingredients: - - - amount: 30 - units: ml - name: Gin - optional: false - - - amount: 30 - units: ml - name: Sweet vermouth - optional: false - - - amount: 30 - units: ml - name: Green Chartreuse - optional: false - - - amount: 2 - units: dashes - name: Orange bitters - optional: false -- - name: 'Gin & Tonic' - description: |- - Called a “G and T” or gin tonic in some countries, this refreshing drink is made in countries all around the world. The Gin and Tonic was invented in the 1850’s by British soldiers, who mixed gin with their tonic water as a way to drink quinine (which was thought to cure malaria). Tonic water of today no longer has quinine, but the drink stuck around! - instructions: |- - 1. Add lots of ice to a large cocktail or wine glass and stir to chill the glass. Drain any melted water. - 2. Pour in the gin. Add the garnishes. Pour the tonic water onto a bar spoon into the glass (to increase the bubbles). Stir once and serve. - garnish: 'Any of the following to spice your cocktail: Lime, lemon, cucumber, mint, orange peel, juniper berries, blood orange slice, rosemary' - source: 'https://www.acouplecooks.com/best-gin-and-tonic/' - images: - - copyright: 'A Couple Cooks' - glass: 'Wine' - method: Build - tags: - - Gin - ingredients: - - - amount: 60 - units: ml - name: Gin - optional: false - - - amount: 120 - units: ml - name: Tonic - optional: false -- - name: 'Gin Gimlet' - description: |- - The word "gimlet" used in this sense is first attested in 1928. The most obvious derivation is from the tool for drilling small holes, a word also used figuratively to describe something as sharp or piercing. Thus, the cocktail may have been named for its "penetrating" effects on the drinker. - instructions: |- - 1. Add gin, lime juice, and syrup to a cocktail shaker. Fill with ice and shake until cold. - 2. Strain into glass and top with a splash of soda water, if desired. - garnish: 'Garnish with a lime wheel' - source: 'https://www.acouplecooks.com/gin-gimlet-cocktail/' - images: - - copyright: 'A Couple Cooks' - glass: 'Coupe' - method: Shake - tags: - - Gin - ingredients: - - - amount: 60 - units: ml - name: Gin - optional: false - - - amount: 15 - units: ml - name: Lime juice - optional: false - - - amount: 15 - units: ml - name: Simple syrup - optional: false - - - amount: 1 - units: splash - name: Club soda - optional: true -- - name: 'Sangria' - description: |- - A punch, sangria traditionally consists of red wine and chopped fruit, often with other ingredients or spirits. - - Sangria is very popular among foreign tourists in Spain even if locals do not consume the beverage that much. It is commonly served in bars, restaurants, and chiringuitos and at festivities throughout Portugal and Spain. - instructions: |- - 1. Chop the orange (leaving the peel on) and apple into bite-sized chunks, then add them to the bottom of a pitcher. Sprinkle them with sugar and stir. Let them stand for 20 minutes at room temperature. - 2. After 20 minutes, pour in the red wine, brandy, orange liqueur, and lemon rounds. Stir and refrigerate 1 to 4 hours. (Don’t go beyond 4 hours or the fruit texture starts to degrade.) - 3. Pour the sangria into ice filled glasses and top with a splash of sparkling water (if desired). Add fruit to each glass, preferably on long skewers for easy snacking. - garnish: 'Drop fruit chunks into a glass' - source: 'https://www.acouplecooks.com/gin-gimlet-cocktail/' - images: - - copyright: 'A Couple Cooks' - glass: 'Wine' - method: Build - tags: - - Wine - ingredients: - - - amount: 1000 - units: ml - name: Red Wine - optional: false - - - amount: 1 - units: whole - name: Orange - optional: false - - - amount: 1 - units: whole - name: Apple - optional: false - - - amount: 3 - units: tablespoons - name: Sugar - optional: false - - - amount: 10 - units: ml - name: Brandy - optional: false - - - amount: 10 - units: ml - name: Cointreau - optional: false - - - amount: 1 - units: whole - name: Lemon - optional: false -- - name: '20th Century' - description: |- - The 20th Century is a cocktail created in 1937 by a British bartender named C.A. Tuck, and named in honor of the celebrated 20th Century Limited train which ran between New York City and Chicago from 1902 until 1967. - instructions: |- - 1. Combine all ingredients with ice and shake - 2. Strain into a coupe, serve up - garnish: 'Lemon twist' - source: 'https://en.wikipedia.org/wiki/20th_Century_(cocktail)' - images: - - copyright: 'Imbibe Magazine' - glass: 'Nick and Nora' - method: Shake - tags: - - Gin - ingredients: - - - amount: 45 - units: ml - name: Gin - optional: false - - - amount: 22.5 - units: ml - name: White Crème de Cacao - optional: false - - - amount: 22.5 - units: ml - name: Lillet Blanc - optional: false - - - amount: 15 - units: ml - name: Lemon juice - optional: false -- - name: 'Alaska' - description: |- - One of the great Chartreuse cocktails and a fundamental three-ingredient recipe - instructions: |- - 1. Combine all ingredients with ice in a mixing glass and stir at length, until the sides of the glass are frosty - 2. Strain into a cocktail glass and serve up - garnish: null - source: 'https://tuxedono2.com/alaska-cocktail-recipe' - images: - - copyright: Punch - glass: 'Nick and Nora' - method: Stir - tags: - - Gin - ingredients: - - - amount: 45 - units: ml - name: Gin - optional: false - - - amount: 15 - units: ml - name: Yellow Chartreuse - optional: false - - - amount: 1 - units: dash - name: Orange bitters - optional: false -- - name: 'Airmail' - description: |- - An endlessly drinkable sparkler of debated origin - instructions: |- - 1. Combine all ingredients except for the champagne in a mixer and shake for ten seconds - 2. Strain into a flute and top with champagne - garnish: null - source: 'https://en.wikipedia.org/wiki/Airmail_(cocktail)' - images: - - copyright: 'Imbibe Magazine' - glass: 'Cocktail' - method: Shake - tags: - - Rum - ingredients: - - - amount: 30 - units: ml - name: White Rum - optional: false - - - amount: 30 - units: ml - name: Champagne - optional: false - - - amount: 15 - units: ml - name: Lime juice - optional: false - - - amount: 15 - units: ml - name: Honey syrup - optional: false - substitutes: ['Simple syrup'] -- - name: 'Comte de Sureau' - description: null - instructions: 'Stir with ice and strain into a chilled rocks glass over ice.' - garnish: 'Garnish with orange and lemon twists.' - source: 'https://www.diffordsguide.com/cocktails/recipe/7257/comte-de-sureau' - images: - - copyright: "Eric's Cocktail Guide" - tags: - - Gin - glass: Lowball - method: Stir - ingredients: - - - amount: 45 - units: ml - name: Gin - optional: false - - - amount: 25 - units: ml - name: 'St-Germain' - optional: false - substitutes: ['Elderflower Cordial'] - - - amount: 7.5 - units: ml - name: Campari - optional: false -- - name: Adonis - description: 'The cocktail was created in honor of the 1884 musical Adonis after the show reached the milestone of more than 500 shows on Broadway.' - instructions: 'Stir all ingredients with ice and strain into chilled glass.' - garnish: 'Orange zest and peel' - source: 'https://en.wikipedia.org/wiki/Adonis_(cocktail)' - images: - - copyright: 'Liquor.com / Tim Nusog' - tags: - - Wine - glass: Coupe - method: Stir - ingredients: - - - amount: 45 - units: ml - name: 'Dry Sherry' - optional: false - - - amount: 45 - units: ml - name: 'Sweet Vermouth' - optional: false - - - amount: 2 - units: dashes - name: 'Orange bitters' - optional: false -- - name: 'La Louisiane' - description: 'The La Louisiane cocktail is an improvement on the Sazerac! Absinthe, rye whiskey and vermouth make this spirit-forward cocktail a stunner.' - instructions: 'Add all ingredients to a cocktail mixing glass (or any other type of glass). Fill the mixing glass with 1 handful ice and stir continuously for 30 seconds until very cold.' - garnish: 'Garnish with a Luxardo cherry.' - source: 'https://www.acouplecooks.com/la-louisiane-cocktail/' - images: - - copyright: 'A couple cooks' - tags: - - Whiskey - glass: Cocktail - method: Stir - ingredients: - - - amount: 60 - units: ml - name: 'Rye whiskey' - optional: false - - - amount: 30 - units: ml - name: 'Sweet Vermouth' - optional: false - - - amount: 30 - units: ml - name: Bénédictine - optional: false - - - amount: 5 - units: ml - name: Absinthe - optional: false - - - amount: 3 - units: dashes - name: 'Peychauds Bitters' - optional: false -- - name: "Queen's Park Hotel Super Cocktail" - description: null - instructions: 'Shake all ingredients with ice and strain into chilled glass.' - garnish: 'Lime zest' - source: 'https://www.cocktailexplorer.co/cocktails/queens-park-hotel-super-cocktail/anders-erickson/' - images: - - copyright: Anders Erickson - tags: - - Rum - glass: Coupe - method: Shake - ingredients: - - - amount: 45 - units: ml - name: 'White Rum' - optional: false - - - amount: 15 - units: ml - name: 'Sweet Vermouth' - optional: false - - - amount: 15 - units: ml - name: 'Grenadine Syrup' - optional: false - - - amount: 15 - units: ml - name: 'Lime juice' - optional: false - - - amount: 2 - units: dashes - name: 'Angostura aromatic bitters' - optional: false -- - name: Seelbach - description: "The Seelbach Cocktail may not be a julep, but it doesn't have to be: it's respectable, powerful, and delicious right down to the bottom of the glass." - instructions: |- - 1. Add the bourbon, Cointreau, Angostura bitters and Peychaud’s bitters into a mixing glass with ice and stir until well-chilled. - 2. Strain into a chilled flute. - 3. Top with cold Champagne or other sparkling wine. - 3. Garnish with an orange twist. - garnish: 'Orange twist' - source: 'https://www.seriouseats.com/seelbach-cocktail-recipe' - images: - - copyright: The Educated Barfly - tags: - - Whiskey - glass: Champagne - method: Stir - ingredients: - - - amount: 30 - units: ml - name: 'Bourbon Whiskey' - optional: false - - - amount: 15 - units: ml - name: 'Cointreau' - optional: false - - - amount: 5 - units: dashes - name: 'Angostura aromatic bitters' - optional: false - - - amount: 5 - units: dashes - name: 'Peychauds Bitters' - optional: false - - - amount: 90 - units: ml - name: Champagne - optional: false -- - name: El Presidente - description: The El Presidente earned its acclaim in Havana during the 1920s through the 1940s during the American Prohibition. It quickly became the preferred drink of the Cuban upper class. - instructions: |- - Add all ingredients to a mixing glass with ice and stir until well-chilled. - garnish: null - source: 'https://www.liquor.com/recipes/el-presidente/' - images: - - copyright: A Couple Cooks - tags: - - Rum - glass: Cocktail - method: Stir - ingredients: - - - amount: 45 - units: ml - name: 'White rum' - optional: false - - - amount: 22.5 - units: ml - name: 'Dry Vermouth' - optional: false - substitutes: ['Lillet Blanc'] - - - amount: 7.5 - units: ml - name: 'Orange Curaçao' - optional: false - - - amount: 1 - units: teaspoon - name: 'Grenadine Syrup' - optional: false -- - name: Štrukani Pelin - description: Štrukani pelinkovac (or štrukani pelin) is a somewhat novel Croatian drink that combines pelinkovac (traditional herbal liqueur) with lemon juice. In its basic form, the drink is made with pelinkovac that is poured into an ice-filled glass. - instructions: |- - 1. A lemon slice is then squeezed directly into the glass and mixed. - - The amount of lemon juice can vary, sometimes resulting in a drink with equal amounts of both ingredients. More elaborate versions often add orange juice, citrus zest, or spices. - garnish: Lemon wedge - source: 'https://www.journal.hr/lifestyle/gastro/antique-pelinkovac-strukani-pelin-jesenski-koktel-cool-pice/' - images: - - copyright: Tasteatlas - tags: [] - glass: Lowball - method: Build - ingredients: - - - amount: 30 - units: ml - name: Pelinkovac - optional: false - - - amount: 15 - units: ml - name: 'Lemon Juice' - optional: false - - - amount: 50 - units: ml - name: 'Tonic' - optional: true -- - name: Antikovac - description: null - instructions: |- - 1. Shake all the ingredients - 2. Strain into a lowball glass - garnish: Orange zest - source: 'https://www.journal.hr/lifestyle/gastro/antique-pelinkovac-strukani-pelin-jesenski-koktel-cool-pice/' - images: - - copyright: Tasteatlas - tags: [] - glass: Lowball - method: Shake - ingredients: - - - amount: 50 - units: ml - name: Pelinkovac - optional: false - - - amount: 20 - units: ml - name: 'Orgeat syrup' - optional: false - - - amount: 10 - units: ml - name: 'Lemon juice' - optional: false - - - amount: 50 - units: ml - name: 'Orange juice' - optional: false diff --git a/resources/docker/entrypoint.sh b/resources/docker/entrypoint.sh index 6a295467..250aee07 100644 --- a/resources/docker/entrypoint.sh +++ b/resources/docker/entrypoint.sh @@ -1,9 +1,25 @@ #!/bin/bash - set -e +# Get PUID/PGID +PUID=${PUID:-1000} +PGID=${PGID:-1000} + cd /var/www/cocktails +echo "Starting Bar Assistant, this can take a few minutes depending on the system..." + +echo " +User uid: $PUID +User gid: $PGID +" + +groupmod -o -g $PGID www-data +usermod -o -u $PUID www-data +chown -R www-data:www-data /var/www/cocktails + gosu www-data ./resources/docker/run.sh -exec "$@" +php-fpm & nginx -g 'daemon off;' + +# exec "$@" diff --git a/resources/docker/run.sh b/resources/docker/run.sh index ce31d900..cb53bcb1 100644 --- a/resources/docker/run.sh +++ b/resources/docker/run.sh @@ -3,29 +3,30 @@ set -e first_time_check() { + mkdir -p /var/www/cocktails/storage/bar-assistant/uploads/{cocktails,ingredients,temp} + mkdir -p /var/www/cocktails/storage/bar-assistant/backups + if [ ! -f /var/www/cocktails/.env ]; then cp .env.dist .env php artisan key:generate php artisan storage:link - if [ ! -f /var/www/cocktails/storage/bar-assistant/database.sqlite ]; then - echo "Database not found, creating a new database..." - touch /var/www/cocktails/storage/bar-assistant/database.sqlite - php artisan migrate:fresh --force - echo "Opening new Bar" - php artisan bar:open + if [[ $DB_CONNECTION == "sqlite" && $DB_DATABASE ]]; then + if [ ! -f "$DB_DATABASE" ]; then + echo "SQLite database not found, creating a new one..." + touch "$DB_DATABASE" + fi fi fi } start_system() { - mkdir -p /var/www/cocktails/storage/bar-assistant/uploads/{cocktails,ingredients,temp} first_time_check - php artisan migrate --force + php artisan migrate --force --isolated - php artisan bar:refresh-search + php artisan bar:refresh-search --clear echo "Adding routes and config to cache..." diff --git a/routes/api.php b/routes/api.php index 03246b1b..55c90f19 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,27 +1,30 @@ name('auth.login'); @@ -57,24 +55,24 @@ Route::get('/cocktails/{ulid}', [ExploreController::class, 'cocktail']); }); -Route::middleware($authMiddleware)->group(function() { +Route::middleware('auth:sanctum')->group(function() { Route::post('logout', [AuthController::class, 'logout'])->name('auth.logout'); - Route::get('/user', [ProfileController::class, 'show']); - Route::post('/user', [ProfileController::class, 'update']); + Route::get('/profile', [ProfileController::class, 'show']); + Route::post('/profile', [ProfileController::class, 'update']); Route::prefix('shelf')->group(function() { - Route::get('/cocktails', [ShelfController::class, 'cocktails']); - Route::get('/ingredients', [ShelfController::class, 'ingredients']); - Route::post('/ingredients', [ShelfController::class, 'batch']); - Route::post('/ingredients/{ingredientId}', [ShelfController::class, 'save']); - Route::delete('/ingredients/{ingredientId}', [ShelfController::class, 'delete']); + Route::get('/cocktails', [ShelfController::class, 'cocktails'])->middleware(EnsureRequestHasBarQuery::class); + Route::get('/cocktail-favorites', [ShelfController::class, 'favorites'])->middleware(EnsureRequestHasBarQuery::class); + Route::get('/ingredients', [ShelfController::class, 'ingredients'])->middleware(EnsureRequestHasBarQuery::class); + Route::post('/ingredients/batch-store', [ShelfController::class, 'batchStore'])->middleware(EnsureRequestHasBarQuery::class); + Route::post('/ingredients/batch-delete', [ShelfController::class, 'batchDelete'])->middleware(EnsureRequestHasBarQuery::class); }); Route::prefix('ingredients')->group(function() { - Route::get('/', [IngredientController::class, 'index']); - Route::post('/', [IngredientController::class, 'store']); + Route::get('/', [IngredientController::class, 'index'])->middleware(EnsureRequestHasBarQuery::class); + Route::post('/', [IngredientController::class, 'store'])->middleware(EnsureRequestHasBarQuery::class); Route::get('/{id}', [IngredientController::class, 'show'])->name('ingredients.show'); Route::put('/{id}', [IngredientController::class, 'update']); Route::delete('/{id}', [IngredientController::class, 'delete']); @@ -82,19 +80,19 @@ }); Route::prefix('ingredient-categories')->group(function() { - Route::get('/', [IngredientCategoryController::class, 'index']); - Route::post('/', [IngredientCategoryController::class, 'store']); + Route::get('/', [IngredientCategoryController::class, 'index'])->middleware(EnsureRequestHasBarQuery::class); + Route::post('/', [IngredientCategoryController::class, 'store'])->middleware(EnsureRequestHasBarQuery::class); Route::get('/{id}', [IngredientCategoryController::class, 'show'])->name('ingredient-categories.show'); Route::put('/{id}', [IngredientCategoryController::class, 'update']); Route::delete('/{id}', [IngredientCategoryController::class, 'delete']); }); Route::prefix('cocktails')->group(function() { - Route::get('/', [CocktailController::class, 'index'])->name('cocktails.index'); + Route::get('/', [CocktailController::class, 'index'])->name('cocktails.index')->middleware(EnsureRequestHasBarQuery::class); Route::get('/{id}', [CocktailController::class, 'show'])->name('cocktails.show'); Route::get('/{id}/share', [CocktailController::class, 'share'])->name('cocktails.share'); Route::post('/{id}/toggle-favorite', [CocktailController::class, 'toggleFavorite'])->name('cocktails.favorite'); - Route::post('/', [CocktailController::class, 'store'])->name('cocktails.store'); + Route::post('/', [CocktailController::class, 'store'])->name('cocktails.store')->middleware(EnsureRequestHasBarQuery::class); Route::delete('/{id}', [CocktailController::class, 'delete'])->name('cocktails.delete'); Route::put('/{id}', [CocktailController::class, 'update'])->name('cocktails.update'); Route::post('/{id}/public-link', [CocktailController::class, 'makePublic'])->name('cocktails.make-public'); @@ -103,41 +101,38 @@ }); Route::prefix('images')->group(function() { - Route::get('/', [ImageController::class, 'index']); Route::get('/{id}', [ImageController::class, 'show']); - // Route::get('/{id}/thumb', [ImageController::class, 'thumb']); Route::post('/', [ImageController::class, 'store']); Route::post('/{id}', [ImageController::class, 'update']); Route::delete('/{id}', [ImageController::class, 'delete']); }); Route::prefix('shopping-list')->group(function() { - Route::get('/', [ShoppingListController::class, 'index']); - Route::get('/share', [ShoppingListController::class, 'share']); - Route::post('/batch-store', [ShoppingListController::class, 'batchStore']); - Route::post('/batch-delete', [ShoppingListController::class, 'batchDelete']); + Route::get('/', [ShoppingListController::class, 'index'])->middleware(EnsureRequestHasBarQuery::class); + Route::get('/share', [ShoppingListController::class, 'share'])->middleware(EnsureRequestHasBarQuery::class); + Route::post('/batch-store', [ShoppingListController::class, 'batchStore'])->middleware(EnsureRequestHasBarQuery::class); + Route::post('/batch-delete', [ShoppingListController::class, 'batchDelete'])->middleware(EnsureRequestHasBarQuery::class); }); Route::prefix('glasses')->group(function() { - Route::get('/find', [GlassController::class, 'find']); - Route::get('/', [GlassController::class, 'index']); - Route::post('/', [GlassController::class, 'store']); + Route::get('/', [GlassController::class, 'index'])->middleware(EnsureRequestHasBarQuery::class); + Route::post('/', [GlassController::class, 'store'])->middleware(EnsureRequestHasBarQuery::class); Route::get('/{id}', [GlassController::class, 'show'])->name('glasses.show'); Route::put('/{id}', [GlassController::class, 'update']); Route::delete('/{id}', [GlassController::class, 'delete']); }); Route::prefix('utensils')->group(function() { - Route::get('/', [UtensilsController::class, 'index']); - Route::post('/', [UtensilsController::class, 'store']); + Route::get('/', [UtensilsController::class, 'index'])->middleware(EnsureRequestHasBarQuery::class); + Route::post('/', [UtensilsController::class, 'store'])->middleware(EnsureRequestHasBarQuery::class); Route::get('/{id}', [UtensilsController::class, 'show'])->name('utensils.show'); Route::put('/{id}', [UtensilsController::class, 'update']); Route::delete('/{id}', [UtensilsController::class, 'delete']); }); Route::prefix('tags')->group(function() { - Route::get('/', [TagController::class, 'index']); - Route::post('/', [TagController::class, 'store']); + Route::get('/', [TagController::class, 'index'])->middleware(EnsureRequestHasBarQuery::class); + Route::post('/', [TagController::class, 'store'])->middleware(EnsureRequestHasBarQuery::class); Route::get('/{id}', [TagController::class, 'show'])->name('tags.show'); Route::put('/{id}', [TagController::class, 'update']); Route::delete('/{id}', [TagController::class, 'delete']); @@ -149,38 +144,35 @@ }); Route::prefix('users')->group(function() { - Route::get('/', [UsersController::class, 'index']); - Route::post('/', [UsersController::class, 'store']); - Route::get('/{id}', [UsersController::class, 'show'])->name('users.show'); - Route::put('/{id}', [UsersController::class, 'update']); + Route::get('/', [UsersController::class, 'index'])->middleware(EnsureRequestHasBarQuery::class); + Route::post('/', [UsersController::class, 'store'])->middleware(EnsureRequestHasBarQuery::class); + Route::get('/{id}', [UsersController::class, 'show'])->middleware(EnsureRequestHasBarQuery::class)->name('users.show'); + Route::put('/{id}', [UsersController::class, 'update'])->middleware(EnsureRequestHasBarQuery::class); Route::delete('/{id}', [UsersController::class, 'delete']); }); Route::prefix('stats')->group(function() { - Route::get('/', [StatsController::class, 'index']); + Route::get('/', [StatsController::class, 'index'])->middleware(EnsureRequestHasBarQuery::class); }); Route::prefix('cocktail-methods')->group(function() { - Route::get('/', [CocktailMethodController::class, 'index']); - Route::post('/', [CocktailMethodController::class, 'store']); + Route::get('/', [CocktailMethodController::class, 'index'])->middleware(EnsureRequestHasBarQuery::class); + Route::post('/', [CocktailMethodController::class, 'store'])->middleware(EnsureRequestHasBarQuery::class); Route::get('/{id}', [CocktailMethodController::class, 'show'])->name('cocktail-methods.show'); Route::put('/{id}', [CocktailMethodController::class, 'update']); Route::delete('/{id}', [CocktailMethodController::class, 'delete']); }); - Route::prefix('scrape')->group(function() { - Route::post('/cocktail', [ScrapeController::class, 'cocktail']); - }); - Route::prefix('notes')->group(function() { + Route::get('/', [NoteController::class, 'index']); Route::post('/', [NoteController::class, 'store']); Route::get('/{id}', [NoteController::class, 'show'])->name('notes.show'); Route::delete('/{id}', [NoteController::class, 'delete']); }); Route::prefix('collections')->group(function() { - Route::get('/', [CollectionController::class, 'index']); - Route::post('/', [CollectionController::class, 'store']); + Route::get('/', [CollectionController::class, 'index'])->middleware(EnsureRequestHasBarQuery::class); + Route::post('/', [CollectionController::class, 'store'])->middleware(EnsureRequestHasBarQuery::class); Route::get('/{id}', [CollectionController::class, 'show'])->name('collection.show'); Route::put('/{id}', [CollectionController::class, 'update']); Route::delete('/{id}', [CollectionController::class, 'delete']); @@ -191,7 +183,20 @@ }); Route::prefix('import')->group(function() { - Route::post('/cocktail', [ImportController::class, 'cocktail']); + Route::post('/cocktail', [ImportController::class, 'cocktail'])->middleware(EnsureRequestHasBarQuery::class); + }); + + Route::prefix('bars')->group(function() { + Route::get('/', [BarController::class, 'index']); + Route::post('/', [BarController::class, 'store']); + Route::post('/join', [BarController::class, 'join']); + Route::get('/{id}', [BarController::class, 'show'])->name('bars.show'); + Route::put('/{id}', [BarController::class, 'update']); + Route::delete('/{id}', [BarController::class, 'delete']); + Route::get('/{id}/memberships', [BarController::class, 'memberships']); + Route::delete('/{id}/memberships', [BarController::class, 'leave']); + Route::delete('/{id}/memberships/{userId}', [BarController::class, 'removeMembership']); + Route::delete('/{id}/memberships/{userId}', [BarController::class, 'removeMembership']); }); }); diff --git a/tests/Feature/AuthControllerTest.php b/tests/Contract/AuthContractTest.php similarity index 54% rename from tests/Feature/AuthControllerTest.php rename to tests/Contract/AuthContractTest.php index 86d37fbf..22252aa7 100644 --- a/tests/Feature/AuthControllerTest.php +++ b/tests/Contract/AuthContractTest.php @@ -1,25 +1,21 @@ create([ - 'id' => 2, 'email' => 'test@test.com', 'password' => Hash::make('my-test-password'), ]); @@ -29,30 +25,39 @@ public function test_authenticate_response() 'password' => 'my-test-password' ]); - $response->assertStatus(200); + $response->assertValidResponse(200); } - public function test_logout_response() + public function test_contract_logout(): void { $this->actingAs( User::factory()->create() ); - // Logout and check headers $response = $this->postJson('/api/logout'); - $response->assertStatus(200); + $response->assertValidResponse(204); } - public function test_register_response() + public function test_contract_register_response_200(): void { - // Logout and check headers $response = $this->postJson('/api/register', [ 'email' => 'test@test.com', 'password' => 'test-password', 'name' => 'Test Guy', ]); - $response->assertSuccessful(); + $response + ->assertValidRequest() + ->assertValidResponse(201); + } + + public function test_contract_register_response_422(): void + { + $response = $this->postJson('/api/register', [ + 'name' => 'Test Guy', + ]); + + $response->assertValidResponse(422); } } diff --git a/tests/Contract/CocktailContractTest.php b/tests/Contract/CocktailContractTest.php index 7d3654d6..23697f2d 100644 --- a/tests/Contract/CocktailContractTest.php +++ b/tests/Contract/CocktailContractTest.php @@ -18,20 +18,23 @@ public function setUp(): void $this->actingAs( User::factory()->create() ); + + $this->setupBar(); } - public function test_contract_cocktails() + public function test_contract_cocktails(): void { - Cocktail::factory()->count(10)->create(); + Cocktail::factory()->count(10)->create(['bar_id' => 1]); - $response = $this->getJson('/api/cocktails'); + $response = $this->getJson('/api/cocktails?bar_id=1'); $response->assertValidResponse(200); } - public function test_contract_cocktail_show() + public function test_contract_cocktail_show(): void { $cocktail = Cocktail::factory()->create([ - 'name' => 'Test Case' + 'name' => 'Test Case', + 'bar_id' => 1 ]); $response = $this->getJson('/api/cocktails/' . $cocktail->id); @@ -44,23 +47,23 @@ public function test_contract_cocktail_show() $response->assertValidResponse(200); } - public function test_contract_cocktail_create() + public function test_contract_cocktail_create(): void { - $response = $this->postJson('/api/cocktails', [ + $response = $this->postJson('/api/cocktails?bar_id=1', [ 'name' => "Cocktail name", 'instructions' => "1. Step\n2. Step" ]); $response->assertValidRequest()->assertValidResponse(201); - $response = $this->postJson('/api/cocktails', [ + $response = $this->postJson('/api/cocktails?bar_id=1', [ 'instructions' => "1. Step\n2. Step" ]); $response->assertValidRequest()->assertValidResponse(422); } - public function test_contract_cocktail_update() + public function test_contract_cocktail_update(): void { - $cocktail = Cocktail::factory()->create(); + $cocktail = Cocktail::factory()->create(['bar_id' => 1, 'created_user_id' => 1]); $response = $this->putJson('/api/cocktails/' . $cocktail->id, [ 'name' => "Cocktail name", @@ -74,9 +77,9 @@ public function test_contract_cocktail_update() $response->assertValidRequest()->assertValidResponse(422); } - public function test_contract_cocktail_delete() + public function test_contract_cocktail_delete(): void { - $cocktail = Cocktail::factory()->create(); + $cocktail = Cocktail::factory()->create(['bar_id' => 1, 'created_user_id' => 1]); $response = $this->deleteJson('/api/cocktails/' . $cocktail->id); $response->assertValidResponse(204); diff --git a/tests/Contract/ProfileContractTest.php b/tests/Contract/ProfileContractTest.php new file mode 100644 index 00000000..64fc9874 --- /dev/null +++ b/tests/Contract/ProfileContractTest.php @@ -0,0 +1,24 @@ +create(); + $this->actingAs($user); + + $response = $this->getJson('/api/profile'); + + $response->assertValidResponse(200); + } +} diff --git a/tests/Feature/Http/AuthControllerTest.php b/tests/Feature/Http/AuthControllerTest.php new file mode 100644 index 00000000..4b4ea389 --- /dev/null +++ b/tests/Feature/Http/AuthControllerTest.php @@ -0,0 +1,72 @@ +create([ + 'email' => 'test@test.com', + 'password' => Hash::make('my-test-password'), + ]); + + $response = $this->postJson('/api/login', [ + 'email' => $user->email, + 'password' => 'my-test-password' + ]); + + $response->assertOk(); + $this->assertNotNull($response['data']['token']); + } + + public function test_authenticate_not_found_response(): void + { + User::factory()->create([ + 'email' => 'test@test.com', + 'password' => Hash::make('my-test-password'), + ]); + + $response = $this->postJson('/api/login', [ + 'email' => 'test@test2.com', + 'password' => 'my-test-password' + ]); + + $response->assertNotFound(); + } + + public function test_logout_response(): void + { + $this->actingAs( + User::factory()->create() + ); + + // Logout and check headers + $response = $this->postJson('/api/logout'); + + $response->assertNoContent(); + } + + public function test_register_response(): void + { + // Logout and check headers + $response = $this->postJson('/api/register', [ + 'email' => 'test@test.com', + 'password' => 'test-password', + 'name' => 'Test Guy', + ]); + + $response->assertSuccessful(); + $response->assertJsonPath('data.name', 'Test Guy'); + $response->assertJsonPath('data.email', 'test@test.com'); + } +} diff --git a/tests/Feature/CocktailControllerTest.php b/tests/Feature/Http/CocktailControllerTest.php similarity index 64% rename from tests/Feature/CocktailControllerTest.php rename to tests/Feature/Http/CocktailControllerTest.php index b20bfe63..d4307291 100644 --- a/tests/Feature/CocktailControllerTest.php +++ b/tests/Feature/Http/CocktailControllerTest.php @@ -1,12 +1,16 @@ count(55)->create(); + $this->setupBar(); + + Cocktail::factory()->count(55)->create(['bar_id' => 1]); - $response = $this->getJson('/api/cocktails'); + $response = $this->getJson('/api/cocktails?bar_id=1'); $response->assertStatus(200); $response->assertJsonCount(25, 'data'); @@ -42,90 +48,98 @@ public function test_cocktails_response() $response->assertJsonPath('meta.per_page', 25); $response->assertJsonPath('meta.total', 55); - $response = $this->getJson('/api/cocktails?page=2'); + $response = $this->getJson('/api/cocktails?bar_id=1&page=2'); $response->assertJsonPath('meta.current_page', 2); - $response = $this->getJson('/api/cocktails?per_page=5'); + $response = $this->getJson('/api/cocktails?bar_id=1&per_page=5'); $response->assertJsonPath('meta.last_page', 11); } - public function test_cocktails_response_filters() + public function test_cocktails_response_with_filters(): void { + $this->setupBar(); + $user = User::factory()->create(); Cocktail::factory()->createMany([ - ['name' => 'Old Fashioned'], - ['name' => 'XXXX'], - ['name' => 'Test', 'user_id' => $user->id], - ['name' => 'public', 'public_id' => 'UUID'], + ['bar_id' => 1, 'name' => 'Old Fashioned'], + ['bar_id' => 1, 'name' => 'XXXX'], + ['bar_id' => 1, 'name' => 'Test', 'created_user_id' => $user->id], + ['bar_id' => 1, 'name' => 'public', 'public_id' => 'UUID'], ]); - Cocktail::factory()->hasTags(1)->create(['name' => 'test 1']); + Cocktail::factory()->hasTags(1)->create(['name' => 'test 1', 'bar_id' => 1]); Cocktail::factory()->has( CocktailIngredient::factory()->for( Ingredient::factory()->state(['name' => 'absinthe'])->create() ), 'ingredients' )->create([ - 'abv' => 33.3 + 'abv' => 33.3, + 'bar_id' => 1 ]); - $cocktailFavorited = Cocktail::factory()->create(); + $cocktailFavorited = Cocktail::factory()->create(['bar_id' => 1]); $favorite = new CocktailFavorite(); $favorite->cocktail_id = $cocktailFavorited->id; - auth()->user()->favorites()->save($favorite); + $favorite->bar_membership_id = 1; + $favorite->save(); - $response = $this->getJson('/api/cocktails?filter[name]=old'); + $response = $this->getJson('/api/cocktails?bar_id=1&filter[name]=old'); $response->assertJsonCount(1, 'data'); - $response = $this->getJson('/api/cocktails?filter[name]=old,xx'); + $response = $this->getJson('/api/cocktails?bar_id=1&filter[name]=old,xx'); $response->assertJsonCount(2, 'data'); - $response = $this->getJson('/api/cocktails?filter[tag_id]=1'); + $response = $this->getJson('/api/cocktails?bar_id=1&filter[tag_id]=1'); $response->assertJsonCount(1, 'data'); - $response = $this->getJson('/api/cocktails?filter[user_id]=' . $user->id); + $response = $this->getJson('/api/cocktails?bar_id=1&filter[created_user_id]=' . $user->id); $response->assertJsonCount(1, 'data'); - $response = $this->getJson('/api/cocktails?filter[on_shelf]=true'); + $response = $this->getJson('/api/cocktails?bar_id=1&filter[on_shelf]=true'); $response->assertJsonCount(0, 'data'); - $response = $this->getJson('/api/cocktails?filter[favorites]=true'); + $response = $this->getJson('/api/cocktails?bar_id=1&filter[favorites]=true'); $response->assertJsonCount(1, 'data'); - $response = $this->getJson('/api/cocktails?filter[is_public]=true'); + $response = $this->getJson('/api/cocktails?bar_id=1&filter[is_public]=true'); $response->assertJsonCount(1, 'data'); - $response = $this->getJson('/api/cocktails?filter[ingredient_name]=absinthe'); + $response = $this->getJson('/api/cocktails?bar_id=1&filter[ingredient_name]=absinthe'); $response->assertJsonCount(1, 'data'); - $response = $this->getJson('/api/cocktails?filter[id]=1,2'); + $response = $this->getJson('/api/cocktails?bar_id=1&filter[id]=1,2'); $response->assertJsonCount(2, 'data'); - $response = $this->getJson('/api/cocktails?filter[ingredient_id]=1'); + $response = $this->getJson('/api/cocktails?bar_id=1&filter[ingredient_id]=1'); $response->assertJsonCount(1, 'data'); - $response = $this->getJson('/api/cocktails?filter[abv_min]=30'); + $response = $this->getJson('/api/cocktails?bar_id=1&filter[abv_min]=30'); $response->assertJsonCount(1, 'data'); - $response = $this->getJson('/api/cocktails?filter[abv_min]=34'); + $response = $this->getJson('/api/cocktails?bar_id=1&filter[abv_min]=34'); $response->assertJsonCount(0, 'data'); - $response = $this->getJson('/api/cocktails?filter[abv_max]=30'); + $response = $this->getJson('/api/cocktails?bar_id=1&filter[abv_max]=30'); $response->assertJsonCount(0, 'data'); - $response = $this->getJson('/api/cocktails?filter[abv_max]=50'); + $response = $this->getJson('/api/cocktails?bar_id=1&filter[abv_max]=50'); $response->assertJsonCount(1, 'data'); } - public function test_cocktails_response_sorts() + public function test_cocktails_response_with_sorts(): void { + $this->setupBar(); + Cocktail::factory()->createMany([ - ['name' => 'B Cocktail'], - ['name' => 'A Cocktail'], - ['name' => 'C Cocktail'], + ['bar_id' => 1, 'name' => 'B Cocktail'], + ['bar_id' => 1, 'name' => 'A Cocktail'], + ['bar_id' => 1, 'name' => 'C Cocktail'], ]); - $response = $this->getJson('/api/cocktails?sort=name'); + $response = $this->getJson('/api/cocktails?bar_id=1&sort=name'); $response->assertJsonPath('data.0.name', 'A Cocktail'); $response->assertJsonPath('data.1.name', 'B Cocktail'); $response->assertJsonPath('data.2.name', 'C Cocktail'); - $response = $this->getJson('/api/cocktails?sort=-name'); + $response = $this->getJson('/api/cocktails?bar_id=1&sort=-name'); $response->assertJsonPath('data.0.name', 'C Cocktail'); $response->assertJsonPath('data.1.name', 'B Cocktail'); $response->assertJsonPath('data.2.name', 'A Cocktail'); } - public function test_cocktail_show_response() + public function test_cocktail_show_response(): void { - $glass = Glass::factory()->create(); - $method = CocktailMethod::factory()->create(); + $bar = $this->setupBar(); + + $glass = Glass::factory()->create(['bar_id' => $bar->id]); + $method = CocktailMethod::factory()->create(['bar_id' => $bar->id]); $cocktail = Cocktail::factory() ->has(CocktailIngredient::factory()->count(3), 'ingredients') ->hasRatings(1, [ @@ -144,7 +158,8 @@ public function test_cocktail_show_response() 'garnish' => '# Lemon twist', 'description' => 'A short description', 'source' => 'http://test.com', - 'user_id' => auth()->user()->id, + 'created_user_id' => auth()->user()->id, + 'bar_id' => $bar->id ]); $response = $this->getJson('/api/cocktails/' . $cocktail->id); @@ -155,18 +170,18 @@ public function test_cocktail_show_response() $json ->has('data.id') ->where('data.name', 'A cocktail name') - ->where('data.slug', 'a-cocktail-name') + ->where('data.slug', 'a-cocktail-name-1') ->where('data.instructions', "1. Step 1\n2. Step two") ->where('data.garnish', '# Lemon twist') ->where('data.description', 'A short description') ->where('data.source', 'http://test.com') - ->where('data.has_public_link', false) ->where('data.public_id', null) ->where('data.main_image_id', null) ->where('data.images', []) ->has('data.tags', 5) - ->where('data.user_rating', 4) - ->where('data.average_rating', 3) + ->where('data.rating.user', 4) + ->where('data.rating.average', 3) + ->where('data.rating.total_votes', 2) ->where('data.glass.id', $glass->id) ->where('data.method.id', $method->id) ->has('data.abv') @@ -182,17 +197,30 @@ public function test_cocktail_show_response() ); } - public function test_cocktail_show_using_slug_response() + public function test_cocktail_show_using_slug_response(): void { - $cocktail = Cocktail::factory()->create(); + $this->setupBar(); + + $cocktail = Cocktail::factory()->create(['bar_id' => 1]); + + $response = $this->getJson('/api/cocktails/' . $cocktail->slug); + + $response->assertStatus(200); + + $cocktail = Cocktail::factory()->create([ + 'slug' => '200', + 'bar_id' => 1, + ]); $response = $this->getJson('/api/cocktails/' . $cocktail->slug); $response->assertStatus(200); } - public function test_cocktail_create_response() + public function test_cocktail_create_response(): void { + $this->setupBar(); + $gin = Ingredient::factory() ->state([ 'name' => 'Gin', @@ -203,9 +231,10 @@ public function test_cocktail_create_response() $ing3 = Ingredient::factory()->create(); $method = CocktailMethod::factory()->create(); $glass = Glass::factory()->create(); - $image = Image::factory()->create(['user_id' => auth()->user()->id]); + $image = Image::factory()->create(['created_user_id' => auth()->user()->id]); + Utensil::factory()->count(5)->create(); - $response = $this->postJson('/api/cocktails', [ + $response = $this->postJson('/api/cocktails?bar_id=1', [ 'name' => "Cocktail name", 'instructions' => "1. Step\n2. Step", 'description' => "Cocktail description", @@ -215,7 +244,7 @@ public function test_cocktail_create_response() 'glass_id' => $glass->id, 'images' => [$image->id], 'tags' => ['Test', 'Gin'], - 'utensils' => [2, 5, 7], + 'utensils' => [2, 5, 3], 'ingredients' => [ [ 'ingredient_id' => $gin->id, @@ -230,7 +259,9 @@ public function test_cocktail_create_response() 'units' => 'ml', 'optional' => false, 'sort' => 2, - 'substitutes' => [$ing3->id] + 'substitutes' => [ + ['id' => $ing3->id] + ] ] ] ]); @@ -242,15 +273,15 @@ public function test_cocktail_create_response() $json ->has('data.id') ->has('data.created_at') - ->where('data.slug', 'cocktail-name') + ->where('data.slug', 'cocktail-name-1') ->where('data.name', 'Cocktail name') ->where('data.description', 'Cocktail description') ->where('data.garnish', 'Lemon peel') - ->where('data.has_public_link', false) ->where('data.public_id', null) ->where('data.main_image_id', $image->id) - ->where('data.user_rating', null) - ->where('data.average_rating', 0) + ->where('data.rating.user', null) + ->where('data.rating.average', 0) + ->where('data.rating.total_votes', 0) ->where('data.source', 'https://karlomikus.com') ->where('data.method.id', $method->id) ->where('data.glass.id', $glass->id) @@ -277,16 +308,19 @@ public function test_cocktail_create_response() ); } - public function test_cocktail_update_response() + public function test_cocktail_update_response(): void { - $cocktail = Cocktail::factory()->create(); + $this->setupBar(); + + $cocktail = Cocktail::factory()->create(['bar_id' => 1, 'created_user_id' => 1]); + Utensil::factory()->count(5)->create(['bar_id' => 1]); $gin = Ingredient::factory() ->state([ 'name' => 'Gin', 'strength' => 40, ]) - ->create(); + ->create(['bar_id' => 1]); $response = $this->putJson('/api/cocktails/' . $cocktail->id, [ 'name' => "Cocktail name", @@ -313,25 +347,29 @@ public function test_cocktail_update_response() fn (AssertableJson $json) => $json ->where('data.id', $cocktail->id) - ->where('data.slug', 'cocktail-name') + ->where('data.slug', 'cocktail-name-1') ->where('data.name', 'Cocktail name') ->has('data.utensils', 2) ->etc() ); } - public function test_cocktail_delete_response() + public function test_cocktail_delete_response(): void { - $cocktail = Cocktail::factory()->create(); + $this->setupBar(); + + $cocktail = Cocktail::factory()->create(['created_user_id' => auth()->user()->id, 'bar_id' => 1]); $response = $this->deleteJson('/api/cocktails/' . $cocktail->id); $response->assertNoContent(); } - public function test_cocktail_delete_deletes_images_response() + public function test_cocktail_delete_deletes_images_response(): void { - $cocktail = Cocktail::factory()->create(); + $this->setupBar(); + + $cocktail = Cocktail::factory()->create(['created_user_id' => auth()->user()->id, 'bar_id' => 1]); $storage = Storage::fake('bar-assistant'); $imageFile = UploadedFile::fake()->image('image1.jpg'); $image = Image::factory()->for($cocktail, 'imageable')->create([ @@ -339,7 +377,7 @@ public function test_cocktail_delete_deletes_images_response() 'file_extension' => $imageFile->extension(), 'copyright' => 'initial', 'sort' => 7, - 'user_id' => auth()->user()->id + 'created_user_id' => auth()->user()->id ]); $this->assertTrue($storage->exists($image->file_path)); @@ -349,9 +387,11 @@ public function test_cocktail_delete_deletes_images_response() $this->assertFalse($storage->exists($image->file_path)); } - public function test_make_cocktail_public_link_response() + public function test_make_cocktail_public_link_response(): void { - $cocktail = Cocktail::factory()->create(); + $this->setupBar(); + + $cocktail = Cocktail::factory()->create(['created_user_id' => auth()->user()->id, 'bar_id' => 1]); $response = $this->postJson('/api/cocktails/' . $cocktail->id . '/public-link'); @@ -368,9 +408,11 @@ public function test_make_cocktail_public_link_response() $this->assertNotNull($cocktail->public_id); } - public function test_delete_cocktail_public_link_response() + public function test_delete_cocktail_public_link_response(): void { - $cocktail = Cocktail::factory()->create(); + $this->setupBar(); + + $cocktail = Cocktail::factory()->create(['created_user_id' => auth()->user()->id, 'bar_id' => 1]); $response = $this->deleteJson('/api/cocktails/' . $cocktail->id . '/public-link'); @@ -380,8 +422,10 @@ public function test_delete_cocktail_public_link_response() $this->assertNull($cocktail->public_id); } - public function test_cocktail_share_response() + public function test_cocktail_share_response(): void { + $this->setupBar(); + $cocktail = Cocktail::factory() ->has(CocktailIngredient::factory()->count(3), 'ingredients') ->create([ @@ -390,11 +434,30 @@ public function test_cocktail_share_response() 'garnish' => '# Lemon twist', 'description' => 'A short description', 'source' => 'http://test.com', - 'user_id' => auth()->user()->id, + 'created_user_id' => auth()->user()->id, + 'bar_id' => 1, ]); $response = $this->getJson('/api/cocktails/' . $cocktail->id . '/share'); $response->assertStatus(200); } + + public function test_cocktail_share_forbidden_response(): void + { + $user = User::factory()->create(); + $bar = Bar::factory()->create(['created_user_id' => $user->id]); + + $cocktail = Cocktail::factory() + ->create([ + 'name' => 'A cocktail name', + 'instructions' => "1. Step 1\n2. Step two", + 'created_user_id' => $user->id, + 'bar_id' => $bar->id, + ]); + + $response = $this->getJson('/api/cocktails/' . $cocktail->id . '/share'); + + $response->assertForbidden(); + } } diff --git a/tests/Feature/CocktailMethodControllerTest.php b/tests/Feature/Http/CocktailMethodControllerTest.php similarity index 71% rename from tests/Feature/CocktailMethodControllerTest.php rename to tests/Feature/Http/CocktailMethodControllerTest.php index 215019dd..5bcfa3d0 100644 --- a/tests/Feature/CocktailMethodControllerTest.php +++ b/tests/Feature/Http/CocktailMethodControllerTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Tests\Feature; +namespace Tests\Feature\Http; use Tests\TestCase; -use Spectator\Spectator; +use Kami\Cocktail\Models\Bar; use Kami\Cocktail\Models\User; use Kami\Cocktail\Models\CocktailMethod; use Illuminate\Testing\Fluent\AssertableJson; @@ -24,9 +24,14 @@ public function setUp(): void ); } - public function test_list_methods_response() + public function test_list_methods_response(): void { - $response = $this->getJson('/api/cocktail-methods'); + $bar = $this->setupBar(); + CocktailMethod::factory()->count(6)->create(['bar_id' => $bar->id]); + $anotherBar = Bar::factory()->create(['created_user_id' => auth()->user()->id]); + CocktailMethod::factory()->count(6)->create(['bar_id' => $anotherBar->id]); + + $response = $this->getJson('/api/cocktail-methods?bar_id=1'); $response->assertStatus(200); $response->assertJson( @@ -37,10 +42,13 @@ public function test_list_methods_response() ); } - public function test_show_method_response() + public function test_show_method_response(): void { + $bar = $this->setupBar(); + $model = CocktailMethod::factory()->create([ - 'name' => 'Test method' + 'name' => 'Test method', + 'bar_id' => $bar->id, ]); $response = $this->getJson('/api/cocktail-methods/' . $model->id); @@ -57,9 +65,11 @@ public function test_show_method_response() ); } - public function test_create_method_response() + public function test_create_method_response(): void { - $response = $this->postJson('/api/cocktail-methods/', [ + $this->setupBar(); + + $response = $this->postJson('/api/cocktail-methods?bar_id=1', [ 'name' => 'Test method', 'dilution_percentage' => 32, ]); @@ -77,11 +87,14 @@ public function test_create_method_response() ); } - public function test_update_method_response() + public function test_update_method_response(): void { + $bar = $this->setupBar(); + $model = CocktailMethod::factory()->create([ 'name' => 'Start method', 'dilution_percentage' => 32, + 'bar_id' => $bar->id, ]); $response = $this->putJson('/api/cocktail-methods/' . $model->id, [ @@ -101,10 +114,13 @@ public function test_update_method_response() ); } - public function test_delete_method_response() + public function test_delete_method_response(): void { + $bar = $this->setupBar(); + $method = CocktailMethod::factory()->create([ 'name' => 'Start method', + 'bar_id' => $bar->id, ]); $response = $this->delete('/api/cocktail-methods/' . $method->id); diff --git a/tests/Feature/CollectionControllerTest.php b/tests/Feature/Http/CollectionControllerTest.php similarity index 61% rename from tests/Feature/CollectionControllerTest.php rename to tests/Feature/Http/CollectionControllerTest.php index 064084bf..dc2b30a9 100644 --- a/tests/Feature/CollectionControllerTest.php +++ b/tests/Feature/Http/CollectionControllerTest.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace Tests\Feature; +namespace Tests\Feature\Http; use Tests\TestCase; +use Kami\Cocktail\Models\Bar; use Kami\Cocktail\Models\User; use Kami\Cocktail\Models\Cocktail; use Kami\Cocktail\Models\Collection; @@ -22,13 +23,15 @@ public function setUp(): void $this->actingAs(User::factory()->create()); } - public function test_list_user_collections_response() + public function test_list_user_collections_response(): void { + $this->setupBar(); + Collection::factory()->count(10)->create([ - 'user_id' => auth()->user()->id, + 'bar_membership_id' => 1, ]); - $response = $this->getJson('/api/collections/'); + $response = $this->getJson('/api/collections?bar_id=1'); $response->assertOk(); $response->assertJson( @@ -39,12 +42,14 @@ public function test_list_user_collections_response() ); } - public function test_show_user_collection_response() + public function test_show_user_collection_response(): void { + $this->setupBar(); + $collection = Collection::factory()->create([ 'name' => 'TEST', 'description' => 'Description', - 'user_id' => auth()->user()->id, + 'bar_membership_id' => 1, ]); $response = $this->getJson('/api/collections/' . $collection->id); @@ -62,11 +67,15 @@ public function test_show_user_collection_response() ); } - public function test_create_collection_response() + public function test_create_collection_response(): void { - $response = $this->postJson('/api/collections/', [ + $bar = $this->setupBar(); + + $cocktail = Cocktail::factory()->create(['bar_id' => $bar->id]); + $response = $this->postJson('/api/collections?bar_id=1', [ 'name' => 'TEST', 'description' => 'Description', + 'cocktails' => [$cocktail->id] ]); $response->assertCreated(); @@ -78,17 +87,46 @@ public function test_create_collection_response() ->has('data.id') ->where('data.name', 'TEST') ->where('data.description', 'Description') - ->where('data.cocktails', []) + ->where('data.cocktails', [$cocktail->id]) ->etc() ); } - public function test_update_collections_response() + public function test_create_collection_does_not_add_cocktail_from_another_bar_response(): void { + $bar = $this->setupBar(); + $anotherBar = Bar::factory()->create(['created_user_id' => auth()->user()->id]); + + $cocktail1 = Cocktail::factory()->create(['bar_id' => $bar->id]); + $cocktail2 = Cocktail::factory()->create(['bar_id' => $anotherBar->id]); + $response = $this->postJson('/api/collections?bar_id=1', [ + 'name' => 'TEST', + 'description' => 'Description', + 'cocktails' => [$cocktail1->id, $cocktail2->id] + ]); + + $response->assertCreated(); + $this->assertNotEmpty($response->headers->get('Location')); + $response->assertJson( + fn (AssertableJson $json) => + $json + ->has('data') + ->has('data.id') + ->where('data.name', 'TEST') + ->where('data.description', 'Description') + ->where('data.cocktails', [$cocktail1->id]) + ->etc() + ); + } + + public function test_update_collections_response(): void + { + $this->setupBar(); + $model = Collection::factory()->create([ 'name' => 'TEST', 'description' => 'Description', - 'user_id' => auth()->user()->id, + 'bar_membership_id' => 1, ]); $response = $this->putJson('/api/collections/' . $model->id, [ @@ -109,12 +147,14 @@ public function test_update_collections_response() ); } - public function test_delete_collection_response() + public function test_delete_collection_response(): void { + $this->setupBar(); + $model = Collection::factory()->create([ 'name' => 'TEST', 'description' => 'Description', - 'user_id' => auth()->user()->id, + 'bar_membership_id' => 1, ]); $response = $this->delete('/api/collections/' . $model->id); @@ -124,13 +164,15 @@ public function test_delete_collection_response() $this->assertDatabaseMissing('collections', ['id' => $model->id]); } - public function test_add_cocktail_to_collection() + public function test_add_cocktail_to_collection(): void { - $cocktail = Cocktail::factory()->create(); + $bar = $this->setupBar(); + + $cocktail = Cocktail::factory()->create(['bar_id' => $bar->id]); $collection = Collection::factory()->create([ 'name' => 'TEST', 'description' => 'Description', - 'user_id' => auth()->user()->id, + 'bar_membership_id' => 1, ]); $response = $this->putJson('/api/collections/' . $collection->id . '/cocktails/' . $cocktail->id); diff --git a/tests/Feature/ExploreControllerTest.php b/tests/Feature/Http/ExploreControllerTest.php similarity index 89% rename from tests/Feature/ExploreControllerTest.php rename to tests/Feature/Http/ExploreControllerTest.php index 50fca0a0..e065bd0e 100644 --- a/tests/Feature/ExploreControllerTest.php +++ b/tests/Feature/Http/ExploreControllerTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Feature; +namespace Tests\Feature\Http; use Tests\TestCase; use Kami\Cocktail\Models\Cocktail; @@ -12,7 +12,7 @@ class ExploreControllerTest extends TestCase { use RefreshDatabase; - public function test_show_cocktail_with_public_link() + public function test_show_cocktail_with_public_link(): void { Cocktail::factory()->create([ 'public_id' => 'TEST123456789' @@ -23,7 +23,7 @@ public function test_show_cocktail_with_public_link() $response->assertOk(); } - public function test_dont_show_cocktail_without_public_link() + public function test_dont_show_cocktail_without_public_link(): void { Cocktail::factory()->create(); @@ -32,7 +32,7 @@ public function test_dont_show_cocktail_without_public_link() $response->assertNotFound(); } - public function test_dont_show_cocktail_with_wrong_public_link() + public function test_dont_show_cocktail_with_wrong_public_link(): void { Cocktail::factory()->create([ 'public_id' => 'TEST123456789' diff --git a/tests/Feature/GlassControllerTest.php b/tests/Feature/Http/GlassControllerTest.php similarity index 71% rename from tests/Feature/GlassControllerTest.php rename to tests/Feature/Http/GlassControllerTest.php index 28ed875c..c7641483 100644 --- a/tests/Feature/GlassControllerTest.php +++ b/tests/Feature/Http/GlassControllerTest.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace Tests\Feature; +namespace Tests\Feature\Http; use Tests\TestCase; +use Kami\Cocktail\Models\Bar; use Kami\Cocktail\Models\User; use Kami\Cocktail\Models\Glass; use Illuminate\Testing\Fluent\AssertableJson; @@ -21,11 +22,13 @@ public function setUp(): void $this->actingAs(User::factory()->create()); } - public function test_list_all_glasses_response() + public function test_list_all_glasses_response(): void { - Glass::factory()->count(10)->create(); + $bar = $this->setupBar(); - $response = $this->getJson('/api/glasses'); + Glass::factory()->count(10)->create(['bar_id' => $bar->id]); + + $response = $this->getJson('/api/glasses?bar_id=1'); $response->assertOk(); $response->assertJson( @@ -36,11 +39,14 @@ public function test_list_all_glasses_response() ); } - public function test_show_glass_response() + public function test_show_glass_response(): void { + $this->setupBar(); + $glass = Glass::factory()->create([ 'name' => 'Glass 1', 'description' => 'Glass 1 Description', + 'bar_id' => 1, ]); $response = $this->getJson('/api/glasses/' . $glass->id); @@ -56,9 +62,11 @@ public function test_show_glass_response() ); } - public function test_save_glass_response() + public function test_save_glass_response(): void { - $response = $this->postJson('/api/glasses/', [ + $this->setupBar(); + + $response = $this->postJson('/api/glasses?bar_id=1', [ 'name' => 'Glass 1', 'description' => 'Glass 1 Description', ]); @@ -74,22 +82,26 @@ public function test_save_glass_response() ); } - public function test_save_glass_forbidden_response() + public function test_save_glass_forbidden_response(): void { - $this->actingAs(User::factory()->create(['is_admin' => false])); + $this->setupBar(); + $anotherBar = Bar::factory()->create(); - $response = $this->postJson('/api/glasses/', [ + $response = $this->postJson('/api/glasses?bar_id=' . $anotherBar->id, [ 'name' => 'Glass 1' ]); $response->assertForbidden(); } - public function test_update_glass_response() + public function test_update_glass_response(): void { + $bar = $this->setupBar(); + $glass = Glass::factory()->create([ 'name' => 'Glass 1', 'description' => 'Glass 1 Description', + 'bar_id' => $bar->id, ]); $response = $this->putJson('/api/glasses/' . $glass->id, [ @@ -108,11 +120,14 @@ public function test_update_glass_response() ); } - public function test_delete_glass_response() + public function test_delete_glass_response(): void { + $bar = $this->setupBar(); + $glass = Glass::factory()->create([ 'name' => 'Glass 1', 'description' => 'Glass 1 Description', + 'bar_id' => $bar->id, ]); $response = $this->deleteJson('/api/glasses/' . $glass->id); diff --git a/tests/Feature/ImageControllerTest.php b/tests/Feature/Http/ImageControllerTest.php similarity index 80% rename from tests/Feature/ImageControllerTest.php rename to tests/Feature/Http/ImageControllerTest.php index f657d990..a2b9bdba 100644 --- a/tests/Feature/ImageControllerTest.php +++ b/tests/Feature/Http/ImageControllerTest.php @@ -1,6 +1,6 @@ for(Cocktail::factory(), 'imageable')->count(45)->create(['user_id' => auth()->user()->id]); - - $response = $this->get('/api/images'); - $response->assertOk(); - $response->assertJsonCount(15, 'data'); - $response->assertJsonPath('meta.current_page', 1); - $response->assertJsonPath('meta.last_page', 3); - $response->assertJsonPath('meta.per_page', 15); - $response->assertJsonPath('meta.total', 45); - - $response = $this->getJson('/api/images?page=2'); - $response->assertJsonPath('meta.current_page', 2); - - $response = $this->getJson('/api/images?per_page=5'); - $response->assertJsonPath('meta.last_page', 9); - } - - public function test_list_images_response_forbidden() - { - $this->actingAs( - User::factory()->create(['is_admin' => false]) - ); - Image::factory()->for(Cocktail::factory(), 'imageable')->count(45)->create(['user_id' => auth()->user()->id]); - - $response = $this->get('/api/images'); - $response->assertForbidden(); - } - - public function test_single_image_upload() + public function test_single_image_upload(): void { Storage::fake('bar-assistant'); $response = $this->post('/api/images', [ @@ -81,7 +51,7 @@ public function test_single_image_upload() Storage::disk('bar-assistant')->assertExists($filename); } - public function test_multiple_image_upload() + public function test_multiple_image_upload(): void { Storage::fake('bar-assistant'); $response = $this->post('/api/images', [ @@ -118,7 +88,7 @@ public function test_multiple_image_upload() Storage::disk('bar-assistant')->assertExists($response->json('data.2.file_path')); } - public function test_multiple_image_upload_fails() + public function test_multiple_image_upload_fails(): void { Storage::fake('bar-assistant'); $response = $this->post('/api/images', [ @@ -134,7 +104,7 @@ public function test_multiple_image_upload_fails() $response->assertUnprocessable(); } - public function test_multiple_image_upload_by_url() + public function test_multiple_image_upload_by_url(): void { $response = $this->post('/api/images', [ 'images' => [ @@ -157,7 +127,7 @@ public function test_multiple_image_upload_by_url() }); } - public function test_multiple_image_upload_by_url_fails() + public function test_multiple_image_upload_by_url_fails(): void { $response = $this->post('/api/images', [ 'images' => [ @@ -172,7 +142,7 @@ public function test_multiple_image_upload_by_url_fails() $response->assertUnprocessable(); } - public function test_image_thumb() + public function test_image_thumb(): void { Storage::fake('bar-assistant'); $imageFile = UploadedFile::fake()->image('image1.jpg'); @@ -180,7 +150,7 @@ public function test_image_thumb() $cocktailImage = Image::factory()->for(Cocktail::factory(), 'imageable')->create([ 'file_path' => $imageFile->storeAs('temp', 'image1.jpg', 'bar-assistant'), 'file_extension' => $imageFile->extension(), - 'user_id' => auth()->user()->id + 'created_user_id' => auth()->user()->id ]); $response = $this->get('/api/images/' . $cocktailImage->id . '/thumb'); @@ -188,16 +158,17 @@ public function test_image_thumb() $response->assertOk(); } - public function test_image_update() + public function test_image_update(): void { + $bar = $this->setupBar(); Storage::fake('bar-assistant'); $imageFile = UploadedFile::fake()->image('image1.jpg'); - $cocktailImage = Image::factory()->for(Cocktail::factory(), 'imageable')->create([ + $cocktailImage = Image::factory()->for(Cocktail::factory()->create(['bar_id' => $bar->id]), 'imageable')->create([ 'file_path' => $imageFile->storeAs('temp', 'image1.jpg', 'bar-assistant'), 'file_extension' => $imageFile->extension(), 'copyright' => 'initial', 'sort' => 7, - 'user_id' => auth()->user()->id + 'created_user_id' => auth()->user()->id ]); $response = $this->postJson('/api/images/' . $cocktailImage->id, [ @@ -225,7 +196,7 @@ public function test_image_update() $response->assertJsonPath('data.sort', 1); } - public function test_image_update_fails() + public function test_image_update_fails(): void { Storage::fake('bar-assistant'); $imageFile = UploadedFile::fake()->image('image1.jpg'); @@ -234,7 +205,7 @@ public function test_image_update_fails() 'file_extension' => $imageFile->extension(), 'copyright' => 'initial', 'sort' => 7, - 'user_id' => auth()->user()->id + 'created_user_id' => auth()->user()->id ]); $response = $this->post('/api/images/' . $cocktailImage->id, [ diff --git a/tests/Feature/ImportControllerTest.php b/tests/Feature/Http/ImportControllerTest.php similarity index 75% rename from tests/Feature/ImportControllerTest.php rename to tests/Feature/Http/ImportControllerTest.php index 48f1f8c3..8038ad27 100644 --- a/tests/Feature/ImportControllerTest.php +++ b/tests/Feature/Http/ImportControllerTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Feature; +namespace Tests\Feature\Http; use Tests\TestCase; use Kami\Cocktail\Models\User; @@ -23,7 +23,8 @@ public function setUp(): void public function test_cocktail_scrape_from_valid_url(): void { - $response = $this->postJson('/api/import/cocktail', [ + $this->setupBar(); + $response = $this->postJson('/api/import/cocktail?bar_id=1', [ 'source' => 'https://punchdrink.com/recipes/whiskey-peach-smash/' ]); @@ -35,7 +36,8 @@ public function test_cocktail_scrape_from_valid_url(): void public function test_cocktail_scrape_fails_safely_for_unknown_url(): void { - $response = $this->postJson('/api/import/cocktail', [ + $this->setupBar(); + $response = $this->postJson('/api/import/cocktail?bar_id=1', [ 'source' => 'https://google.com' ]); @@ -44,20 +46,20 @@ public function test_cocktail_scrape_fails_safely_for_unknown_url(): void public function test_cocktail_scrape_from_json(): void { + $this->setupBar(); $source = file_get_contents(base_path('tests/fixtures/import.json')); - $response = $this->postJson('/api/import/cocktail?type=json', [ + $response = $this->postJson('/api/import/cocktail?bar_id=1&type=json', [ 'source' => $source ]); $response->assertOk(); - $response->assertJsonPath('data.name', 'Old Fashioned'); - $response->assertJsonCount(4, 'data.ingredients'); - $response->assertJsonCount(1, 'data.images'); + $response->assertJsonPath('data.name', 'Cocktail name'); } public function test_cocktail_scrape_from_json_fails_bad_format(): void { - $response = $this->postJson('/api/import/cocktail?type=json', [ + $this->setupBar(); + $response = $this->postJson('/api/import/cocktail?bar_id=1&type=json', [ 'source' => 'TEST' ]); @@ -66,8 +68,9 @@ public function test_cocktail_scrape_from_json_fails_bad_format(): void public function test_cocktail_scrape_from_yaml(): void { + $this->setupBar(); $source = file_get_contents(base_path('tests/fixtures/import.yaml')); - $response = $this->postJson('/api/import/cocktail?type=yaml', [ + $response = $this->postJson('/api/import/cocktail?bar_id=1&type=yaml', [ 'source' => $source ]); @@ -79,7 +82,8 @@ public function test_cocktail_scrape_from_yaml(): void public function test_cocktail_scrape_from_yaml_fails_bad_format(): void { - $response = $this->postJson('/api/import/cocktail?type=yaml', [ + $this->setupBar(); + $response = $this->postJson('/api/import/cocktail?type=yaml&bar_id=1', [ 'source' => "{-- Test \n}\n-test" ]); diff --git a/tests/Feature/IngredientCategoryControllerTest.php b/tests/Feature/Http/IngredientCategoryControllerTest.php similarity index 77% rename from tests/Feature/IngredientCategoryControllerTest.php rename to tests/Feature/Http/IngredientCategoryControllerTest.php index 9e9e69f5..d516de80 100644 --- a/tests/Feature/IngredientCategoryControllerTest.php +++ b/tests/Feature/Http/IngredientCategoryControllerTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Feature; +namespace Tests\Feature\Http; use Tests\TestCase; use Kami\Cocktail\Models\User; @@ -23,11 +23,12 @@ public function setUp(): void ); } - public function test_list_categories_response() + public function test_list_categories_response(): void { - IngredientCategory::factory()->count(10)->create(); + $this->setupBar(); + IngredientCategory::factory()->count(10)->create(['bar_id' => 1]); - $response = $this->getJson('/api/ingredient-categories'); + $response = $this->getJson('/api/ingredient-categories?bar_id=1'); $response->assertStatus(200); $response->assertJson( @@ -38,11 +39,13 @@ public function test_list_categories_response() ); } - public function test_show_category_response() + public function test_show_category_response(): void { + $bar = $this->setupBar(); $cat = IngredientCategory::factory()->create([ 'name' => 'Test cat', 'description' => 'Test cat desc', + 'bar_id' => $bar->id, ]); $response = $this->getJson('/api/ingredient-categories/' . $cat->id); @@ -59,9 +62,10 @@ public function test_show_category_response() ); } - public function test_create_category_response() + public function test_create_category_response(): void { - $response = $this->postJson('/api/ingredient-categories/', [ + $this->setupBar(); + $response = $this->postJson('/api/ingredient-categories?bar_id=1', [ 'name' => 'Test cat', 'description' => 'Test cat desc', ]); @@ -79,11 +83,13 @@ public function test_create_category_response() ); } - public function test_update_category_response() + public function test_update_category_response(): void { + $bar = $this->setupBar(); $cat = IngredientCategory::factory()->create([ 'name' => 'Start cat', 'description' => 'Start cat desc', + 'bar_id' => $bar->id, ]); $response = $this->putJson('/api/ingredient-categories/' . $cat->id, [ @@ -103,11 +109,13 @@ public function test_update_category_response() ); } - public function test_delete_category_response() + public function test_delete_category_response(): void { + $bar = $this->setupBar(); $cat = IngredientCategory::factory()->create([ 'name' => 'Start cat', 'description' => 'Start cat desc', + 'bar_id' => $bar->id, ]); $response = $this->delete('/api/ingredient-categories/' . $cat->id); diff --git a/tests/Feature/IngredientControllerTest.php b/tests/Feature/Http/IngredientControllerTest.php similarity index 57% rename from tests/Feature/IngredientControllerTest.php rename to tests/Feature/Http/IngredientControllerTest.php index 723d5121..a65243bf 100644 --- a/tests/Feature/IngredientControllerTest.php +++ b/tests/Feature/Http/IngredientControllerTest.php @@ -2,13 +2,15 @@ declare(strict_types=1); -namespace Tests\Feature; +namespace Tests\Feature\Http; use Tests\TestCase; +use Kami\Cocktail\Models\Bar; use Kami\Cocktail\Models\User; use Kami\Cocktail\Models\Cocktail; use Kami\Cocktail\Models\Ingredient; use Kami\Cocktail\Models\UserIngredient; +use Kami\Cocktail\Models\UserShoppingList; use Kami\Cocktail\Models\CocktailIngredient; use Kami\Cocktail\Models\IngredientCategory; use Illuminate\Testing\Fluent\AssertableJson; @@ -27,97 +29,126 @@ public function setUp(): void ); } - public function test_list_ingredients_response() + public function test_paginate_ingredients_response(): void { - Ingredient::factory()->count(55)->create(); + $bar = $this->setupBar(); + Ingredient::factory()->count(55)->create(['bar_id' => $bar->id]); - $response = $this->getJson('/api/ingredients'); + $response = $this->getJson('/api/ingredients?bar_id=' . $bar->id); - $response->assertStatus(200); + $response->assertOk(); $response->assertJsonCount(50, 'data'); $response->assertJsonPath('meta.current_page', 1); $response->assertJsonPath('meta.last_page', 2); $response->assertJsonPath('meta.per_page', 50); $response->assertJsonPath('meta.total', 55); - $response = $this->getJson('/api/ingredients?page=2'); + $response = $this->getJson('/api/ingredients?page=2&bar_id=' . $bar->id); $response->assertJsonPath('meta.current_page', 2); - $response = $this->getJson('/api/ingredients?per_page=5'); + $response = $this->getJson('/api/ingredients?per_page=5&bar_id=' . $bar->id); $response->assertJsonPath('meta.last_page', 11); } - public function test_list_ingredients_response_filters() + public function test_list_ingredients_unknown_bar_response(): void { + Bar::factory()->create(['id' => 2]); + Ingredient::factory()->count(1)->create(); + + $response = $this->getJson('/api/ingredients?bar_id=2'); + + $response->assertForbidden(); + } + + public function test_list_ingredients_response_filters(): void + { + $bar = $this->setupBar(); $user = User::factory()->create(); - $ingCat = IngredientCategory::factory()->create(); + $ingredientCategory = IngredientCategory::factory()->create(); Ingredient::factory()->createMany([ - ['name' => 'Whiskey', 'origin' => 'America', 'strength' => 35.5], - ['name' => 'XXXX', 'strength' => 0], - ['name' => 'Test', 'user_id' => $user->id, 'strength' => 40], - ['name' => 'Test 2', 'ingredient_category_id' => $ingCat->id, 'strength' => 0], + ['bar_id' => $bar->id, 'name' => 'Whiskey', 'origin' => 'fix-string', 'strength' => 35.5], + ['bar_id' => $bar->id, 'name' => 'XXXX', 'strength' => 0], + ['bar_id' => $bar->id, 'name' => 'Test', 'created_user_id' => $user->id, 'strength' => 40], + ['bar_id' => $bar->id, 'name' => 'Test 2', 'ingredient_category_id' => $ingredientCategory->id, 'strength' => 0], ]); - $response = $this->getJson('/api/ingredients'); + Cocktail::factory() + ->has(CocktailIngredient::factory()->state([ + 'ingredient_id' => 1, + 'sort' => 1, + ]), 'ingredients') + ->create([ + 'name' => 'A cocktail name', + 'bar_id' => $bar->id, + ]); - $response = $this->getJson('/api/ingredients?filter[name]=whi'); + $response = $this->getJson('/api/ingredients?bar_id=1&filter[name]=whi'); $response->assertJsonCount(1, 'data'); - $response = $this->getJson('/api/ingredients?filter[name]=whi,xx'); + $response = $this->getJson('/api/ingredients?bar_id=1&filter[name]=whi,xx'); $response->assertJsonCount(2, 'data'); - $response = $this->getJson('/api/ingredients?filter[user_id]=' . $user->id); - $response->assertJsonCount(1, 'data'); - $response = $this->getJson('/api/ingredients?filter[category_id]=' . $ingCat->id); + $response = $this->getJson('/api/ingredients?bar_id=1&filter[created_user_id]=' . $user->id); $response->assertJsonCount(1, 'data'); - $response = $this->getJson('/api/ingredients?filter[category_id]=' . $ingCat->id); + $response = $this->getJson('/api/ingredients?bar_id=1&filter[category_id]=' . $ingredientCategory->id); $response->assertJsonCount(1, 'data'); - $response = $this->getJson('/api/ingredients?filter[origin]=america'); + $response = $this->getJson('/api/ingredients?bar_id=1&filter[origin]=fix-string'); $response->assertJsonCount(1, 'data'); - $response = $this->getJson('/api/ingredients?filter[strength_min]=30'); + $response = $this->getJson('/api/ingredients?bar_id=1&filter[strength_min]=30'); $response->assertJsonCount(2, 'data'); - $response = $this->getJson('/api/ingredients?filter[strength_max]=39'); + $response = $this->getJson('/api/ingredients?bar_id=1&filter[strength_max]=39'); $response->assertJsonCount(3, 'data'); - $response = $this->getJson('/api/ingredients?filter[on_shelf]=true'); + $response = $this->getJson('/api/ingredients?bar_id=1&filter[on_shelf]=true'); $response->assertJsonCount(0, 'data'); - $response = $this->getJson('/api/ingredients?filter[on_shopping_list]=true'); + $response = $this->getJson('/api/ingredients?bar_id=1&filter[on_shopping_list]=true'); $response->assertJsonCount(0, 'data'); + $response = $this->getJson('/api/ingredients?bar_id=1&filter[main_ingredients]=true'); + $response->assertJsonCount(1, 'data'); + $response = $this->getJson('/api/ingredients?bar_id=1&sort=-total_cocktails'); + $response->assertJsonPath('data.0.name', 'Whiskey'); + $response = $this->getJson('/api/ingredients?bar_id=1&sort=-strength'); + $response->assertJsonPath('data.0.name', 'Test'); + $response = $this->getJson('/api/ingredients?bar_id=1&sort=-created_at'); + $response->assertJsonPath('data.0.name', 'Whiskey'); } - public function test_list_ingredients_response_filter_by_category() - { - Ingredient::factory()->count(5)->create(); - $ingCat = IngredientCategory::factory()->create(); - Ingredient::factory()->count(2)->create([ - 'ingredient_category_id' => $ingCat->id, - ]); - - $response = $this->getJson('/api/ingredients?filter[category_id]=' . $ingCat->id); - - $response->assertStatus(200); - $response->assertJsonCount(2, 'data'); - } - - public function test_list_ingredients_response_filter_by_shopping_list() + public function test_list_ingredients_response_filter_by_shopping_list(): void { - Ingredient::factory()->count(5)->create(); - - $response = $this->getJson('/api/ingredients?filter[on_shopping_list]=true'); + $bar = $this->setupBar(); + $ingredients = Ingredient::factory()->count(5)->create(['bar_id' => $bar->id]); + foreach ($ingredients as $ing) { + $rel = new UserShoppingList(); + $rel->ingredient_id = $ing->id; + $rel->bar_membership_id = 1; + $rel->save(); + } + Ingredient::factory()->count(5)->create(['bar_id' => $bar->id]); + + $response = $this->getJson('/api/ingredients?bar_id=1&filter[on_shopping_list]=true'); $response->assertStatus(200); - $response->assertJsonCount(0, 'data'); + $response->assertJsonCount(5, 'data'); } - public function test_list_ingredients_response_filter_by_shelf() + public function test_list_ingredients_response_filter_by_shelf(): void { - Ingredient::factory()->count(5)->create(); - - $response = $this->getJson('/api/ingredients?filter[on_shelf]=true'); + $bar = $this->setupBar(); + $ingredients = Ingredient::factory()->count(5)->create(['bar_id' => $bar->id]); + foreach ($ingredients as $ing) { + $rel = new UserIngredient(); + $rel->ingredient_id = $ing->id; + $rel->bar_membership_id = 1; + $rel->save(); + } + Ingredient::factory()->count(5)->create(['bar_id' => $bar->id]); + + $response = $this->getJson('/api/ingredients?bar_id=1&filter[on_shelf]=true'); $response->assertStatus(200); - $response->assertJsonCount(0, 'data'); + $response->assertJsonCount(5, 'data'); } - public function test_ingredient_show_response() + public function test_ingredient_show_response(): void { + $bar = $this->setupBar(); $ingredient = Ingredient::factory() ->state([ 'name' => 'Test ingredient', @@ -125,6 +156,7 @@ public function test_ingredient_show_response() 'description' => 'Test', 'origin' => 'Croatia', 'color' => '#fff', + 'bar_id' => $bar->id, ]) ->create(); @@ -132,7 +164,8 @@ public function test_ingredient_show_response() ->state([ 'name' => 'Child ingredient', 'strength' => 45.5, - 'parent_ingredient_id' => $ingredient->id + 'parent_ingredient_id' => $ingredient->id, + 'bar_id' => $bar->id, ]) ->create(); @@ -148,7 +181,7 @@ public function test_ingredient_show_response() $response->assertStatus(200); $response->assertJsonPath('data.id', 1); - $response->assertJsonPath('data.slug', 'test-ingredient'); + $response->assertJsonPath('data.slug', 'test-ingredient-1'); $response->assertJsonPath('data.name', 'Test ingredient'); $response->assertJsonPath('data.strength', 45.5); $response->assertJsonPath('data.description', 'Test'); @@ -158,11 +191,12 @@ public function test_ingredient_show_response() $response->assertJsonPath('data.category.id', 1); $response->assertJsonPath('data.parent_ingredient.id', null); $response->assertJsonPath('data.color', '#fff'); + $response->assertJsonPath('data.cocktails_count', 1); $response->assertJsonCount(1, 'data.cocktails'); $response->assertJsonCount(1, 'data.varieties'); } - public function test_ingredient_show_not_found_response() + public function test_ingredient_show_not_found_response(): void { $response = $this->getJson('/api/ingredients/404'); @@ -175,11 +209,12 @@ public function test_ingredient_show_not_found_response() ); } - public function test_ingredient_store_response() + public function test_ingredient_store_response(): void { - $ingCat = IngredientCategory::factory()->create(); + $this->setupBar(); + $ingCat = IngredientCategory::factory()->create(['bar_id' => 1]); - $response = $this->postJson('/api/ingredients', [ + $response = $this->postJson('/api/ingredients?bar_id=1', [ 'name' => "Ingredient name", 'strength' => 12.2, 'description' => "Description text", @@ -204,9 +239,11 @@ public function test_ingredient_store_response() ); } - public function test_ingredient_store_fails_validation_response() + public function test_ingredient_store_fails_validation_response(): void { - $response = $this->postJson('/api/ingredients', [ + $this->setupBar(); + + $response = $this->postJson('/api/ingredients?bar_id=1', [ 'strength' => 12.2, ]); @@ -220,13 +257,16 @@ public function test_ingredient_store_fails_validation_response() ); } - public function test_ingredient_update_response() + public function test_ingredient_update_response(): void { + $this->setupBar(); $ing = Ingredient::factory() ->state([ 'name' => 'Test ingredient', 'strength' => 45.5, - 'description' => 'Test' + 'description' => 'Test', + 'bar_id' => 1, + 'created_user_id' => auth()->user()->id, ]) ->create(); @@ -252,7 +292,7 @@ public function test_ingredient_update_response() ); } - public function test_ingredient_update_fails_validation_response() + public function test_ingredient_update_fails_validation_response(): void { $ing = Ingredient::factory() ->state([ @@ -276,13 +316,16 @@ public function test_ingredient_update_fails_validation_response() ); } - public function test_ingredient_delete_response() + public function test_ingredient_delete_response(): void { + $this->setupBar(); $ing = Ingredient::factory() ->state([ 'name' => 'Test ingredient', 'strength' => 45.5, - 'description' => 'Test' + 'description' => 'Test', + 'bar_id' => 1, + 'created_user_id' => auth()->user()->id, ]) ->create(); @@ -292,14 +335,16 @@ public function test_ingredient_delete_response() $this->assertDatabaseMissing('ingredients', ['id' => $ing->id]); } - public function test_ingredients_extra_response() + public function test_ingredients_extra_response(): void { - $ingredient1 = Ingredient::factory()->create(); - $ingredient2 = Ingredient::factory()->create(); + $this->setupBar(); + $ingredient1 = Ingredient::factory()->create(['bar_id' => 1]); + $ingredient2 = Ingredient::factory()->create(['bar_id' => 1]); $userIngredient = new UserIngredient(); $userIngredient->ingredient_id = $ingredient1->id; - auth()->user()->shelfIngredients()->save($userIngredient); + $userIngredient->bar_membership_id = 1; + $userIngredient->save(); $cocktail = Cocktail::factory() ->has(CocktailIngredient::factory()->for( @@ -308,12 +353,12 @@ public function test_ingredients_extra_response() ->has(CocktailIngredient::factory()->for( $ingredient2 ), 'ingredients') - ->create(); + ->create(['bar_id' => 1]); - $response = $this->getJson('/api/ingredients/' . $ingredient1->id . '/extra'); + $response = $this->getJson('/api/ingredients/' . $ingredient1->id . '/extra?bar_id=1'); $response->assertJsonCount(0, 'data'); - $response = $this->getJson('/api/ingredients/' . $ingredient2->id . '/extra'); + $response = $this->getJson('/api/ingredients/' . $ingredient2->id . '/extra?bar_id=1'); $response->assertJson( fn (AssertableJson $json) => diff --git a/tests/Feature/NoteControllerTest.php b/tests/Feature/Http/NoteControllerTest.php similarity index 61% rename from tests/Feature/NoteControllerTest.php rename to tests/Feature/Http/NoteControllerTest.php index c42c0acc..df82cb9c 100644 --- a/tests/Feature/NoteControllerTest.php +++ b/tests/Feature/Http/NoteControllerTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Feature; +namespace Tests\Feature\Http; use Tests\TestCase; use Kami\Cocktail\Models\User; @@ -21,7 +21,33 @@ public function setUp(): void $this->actingAs(User::factory()->create()); } - public function test_show_note_response() + public function test_list_notes_response(): void + { + $cocktail = Cocktail::factory()->create(); + $cocktail->addNote('Test note 1', auth()->user()->id); + $cocktail->addNote('Test note 2', auth()->user()->id); + $cocktail->addNote('Test note 3', auth()->user()->id); + + $response = $this->getJson('/api/notes'); + + $response->assertOk(); + $response->assertJsonCount(3, 'data'); + } + + public function test_list_notes_by_cocktail_response(): void + { + $cocktail1 = Cocktail::factory()->create(); + $cocktail2 = Cocktail::factory()->create(); + $cocktail1->addNote('Test note 1', auth()->user()->id); + $cocktail2->addNote('Test note 2', auth()->user()->id); + + $response = $this->getJson('/api/notes?filter[cocktail_id]=' . $cocktail1->id); + + $response->assertOk(); + $response->assertJsonCount(1, 'data'); + } + + public function test_show_note_response(): void { $cocktail = Cocktail::factory()->create(); $note = $cocktail->addNote('Test note', auth()->user()->id); @@ -40,9 +66,10 @@ public function test_show_note_response() ); } - public function test_save_cocktail_note_response() + public function test_save_cocktail_note_response(): void { - $cocktail = Cocktail::factory()->create(); + $this->setupBar(); + $cocktail = Cocktail::factory()->create(['bar_id' => 1]); $response = $this->postJson('/api/notes/', [ 'note' => 'A new note', 'resource_id' => $cocktail->id, @@ -61,14 +88,14 @@ public function test_save_cocktail_note_response() ); } - public function test_save_cocktail_note_forbidden_response() + public function test_save_cocktail_note_forbidden_response(): void { - $cocktailUser = User::factory()->create(['is_admin' => false]); + $cocktailUser = User::factory()->create(); $cocktail = Cocktail::factory()->create([ - 'user_id' => $cocktailUser->id, + 'created_user_id' => $cocktailUser->id, ]); - $this->actingAs(User::factory()->create(['is_admin' => false])); + $this->actingAs(User::factory()->create()); $response = $this->postJson('/api/notes/', [ 'note' => 'A new note', @@ -79,7 +106,7 @@ public function test_save_cocktail_note_forbidden_response() $response->assertForbidden(); } - public function test_delete_cocktail_note_response() + public function test_delete_cocktail_note_response(): void { $cocktail = Cocktail::factory()->create(); $note = $cocktail->addNote('Test note', auth()->user()->id); diff --git a/tests/Feature/ProfileControllerTest.php b/tests/Feature/Http/ProfileControllerTest.php similarity index 85% rename from tests/Feature/ProfileControllerTest.php rename to tests/Feature/Http/ProfileControllerTest.php index 682a5bd3..478c5b3d 100644 --- a/tests/Feature/ProfileControllerTest.php +++ b/tests/Feature/Http/ProfileControllerTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Feature; +namespace Tests\Feature\Http; use Tests\TestCase; use Kami\Cocktail\Models\User; @@ -18,12 +18,12 @@ public function setUp(): void parent::setUp(); } - public function test_current_user_response() + public function test_current_user_response(): void { $user = User::factory()->create(); $this->actingAs($user); - $response = $this->getJson('/api/user'); + $response = $this->getJson('/api/profile'); $response->assertOk(); $response->assertJson( @@ -37,12 +37,12 @@ public function test_current_user_response() ); } - public function test_update_current_user_response() + public function test_update_current_user_response(): void { $user = User::factory()->create(); $this->actingAs($user); - $response = $this->postJson('/api/user', [ + $response = $this->postJson('/api/profile', [ 'email' => 'new@example.com', 'name' => 'Test Guy', ]); @@ -59,12 +59,12 @@ public function test_update_current_user_response() ); } - public function test_update_current_user_with_password_response() + public function test_update_current_user_with_password_response(): void { $user = User::factory()->create(); $this->actingAs($user); - $response = $this->postJson('/api/user', [ + $response = $this->postJson('/api/profile', [ 'email' => 'new@example.com', 'name' => 'Test Guy', 'password' => '12345', @@ -83,12 +83,12 @@ public function test_update_current_user_with_password_response() ); } - public function test_update_current_user_with_password_fail_response() + public function test_update_current_user_with_password_fail_response(): void { $user = User::factory()->create(); $this->actingAs($user); - $response = $this->postJson('/api/user', [ + $response = $this->postJson('/api/profile', [ 'email' => 'new@example.com', 'name' => 'Test Guy', 'password' => '12345', diff --git a/tests/Feature/RatingControllerTest.php b/tests/Feature/Http/RatingControllerTest.php similarity index 75% rename from tests/Feature/RatingControllerTest.php rename to tests/Feature/Http/RatingControllerTest.php index fcbe9209..3d6318a9 100644 --- a/tests/Feature/RatingControllerTest.php +++ b/tests/Feature/Http/RatingControllerTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Feature; +namespace Tests\Feature\Http; use Tests\TestCase; use Kami\Cocktail\Models\User; @@ -23,9 +23,10 @@ public function setUp(): void ); } - public function test_rate_cocktail_response() + public function test_rate_cocktail_response(): void { - $cocktail = Cocktail::factory()->create(); + $this->setupBar(); + $cocktail = Cocktail::factory()->create(['bar_id' => 1]); $response = $this->postJson('/api/ratings/cocktails/' . $cocktail->id, [ 'rating' => 3 @@ -36,16 +37,17 @@ public function test_rate_cocktail_response() fn (AssertableJson $json) => $json ->has('data') - ->has('data.id') ->where('data.rating', 3) ->where('data.rateable_id', $cocktail->id) + ->where('data.user_id', 1) ->etc() ); } - public function test_rate_cocktail_updates_existing_rating_response() + public function test_rate_cocktail_updates_existing_rating_response(): void { - $cocktail = Cocktail::factory()->create(); + $this->setupBar(); + $cocktail = Cocktail::factory()->create(['bar_id' => 1]); $cocktail->rate(2, auth()->user()->id); @@ -58,16 +60,17 @@ public function test_rate_cocktail_updates_existing_rating_response() fn (AssertableJson $json) => $json ->has('data') - ->has('data.id') ->where('data.rating', 4) ->where('data.rateable_id', $cocktail->id) + ->where('data.user_id', 1) ->etc() ); } - public function test_delete_cocktail_rating_response() + public function test_delete_cocktail_rating_response(): void { - $cocktail = Cocktail::factory()->create(); + $this->setupBar(); + $cocktail = Cocktail::factory()->create(['bar_id' => 1]); $cocktail->rate(2, auth()->user()->id); $response = $this->delete('/api/ratings/cocktails/' . $cocktail->id); diff --git a/tests/Feature/ServerControllerTest.php b/tests/Feature/Http/ServerControllerTest.php similarity index 75% rename from tests/Feature/ServerControllerTest.php rename to tests/Feature/Http/ServerControllerTest.php index c16a875a..e35ea3d5 100644 --- a/tests/Feature/ServerControllerTest.php +++ b/tests/Feature/Http/ServerControllerTest.php @@ -1,6 +1,6 @@ getJson('/api/server/version'); $response->assertStatus(200); - $this->assertSame('Bar Assistant', $response['data']['name']); $this->assertNotNull($response['data']['version']); $this->assertNotNull($response['data']['search_host']); $this->assertNotNull($response['data']['search_version']); } - public function test_openapi_response() + public function test_openapi_response(): void { $response = $this->getJson('/api/server/openapi'); diff --git a/tests/Feature/Http/ShelfControllerTest.php b/tests/Feature/Http/ShelfControllerTest.php new file mode 100644 index 00000000..c1836ad4 --- /dev/null +++ b/tests/Feature/Http/ShelfControllerTest.php @@ -0,0 +1,148 @@ +create(); + $this->actingAs($user); + $this->setupBar(); + } + + public function test_list_ingredients_on_shelf_response(): void + { + $ingredients = Ingredient::factory()->count(5)->create(['bar_id' => 1]); + + foreach ($ingredients as $ingredient) { + $userIngredient = new UserIngredient(); + $userIngredient->ingredient_id = $ingredient->id; + $userIngredient->bar_membership_id = 1; + $userIngredient->save(); + } + + $response = $this->getJson('/api/shelf/ingredients?bar_id=1'); + + $response->assertSuccessful(); + $response->assertJson( + fn (AssertableJson $json) => + $json + ->has('data', 5) + ->etc() + ); + } + + public function test_add_multiple_ingredients_to_shelf_response(): void + { + $newIngredients = Ingredient::factory()->count(2)->create(['bar_id' => 1]); + + $response = $this->postJson('/api/shelf/ingredients/batch-store?bar_id=1', [ + 'ingredient_ids' => $newIngredients->pluck('id')->toArray() + ]); + + $response->assertSuccessful(); + $response->assertJson( + fn (AssertableJson $json) => + $json + ->has('data', 2) + ->etc() + ); + } + + public function test_add_multiple_ingredients_from_another_bar_to_shelf_response(): void + { + Bar::factory()->create(['id' => 2]); + DB::table('bar_memberships')->insert(['id' => 2, 'bar_id' => 2, 'user_id' => 1, 'user_role_id' => UserRoleEnum::Admin->value]); + $ing1 = Ingredient::factory()->create(['bar_id' => 1]); + $ing2 = Ingredient::factory()->create(['bar_id' => 2]); + + $response = $this->postJson('/api/shelf/ingredients/batch-store?bar_id=1', [ + 'ingredient_ids' => [$ing1->id, $ing2->id] + ]); + + $response->assertSuccessful(); + $response->assertJson( + fn (AssertableJson $json) => + $json + ->has('data', 1) + ->where('data.0.ingredient_id', $ing1->id) + ->etc() + ); + } + + public function test_delete_multiple_ingredients_from_shelf_response(): void + { + $ing1 = Ingredient::factory()->create(['bar_id' => 1]); + $ing2 = Ingredient::factory()->create(['bar_id' => 1]); + + $userIngredient = new UserIngredient(); + $userIngredient->ingredient_id = $ing1->id; + $userIngredient->bar_membership_id = 1; + $userIngredient->save(); + + $userIngredient = new UserIngredient(); + $userIngredient->ingredient_id = $ing2->id; + $userIngredient->bar_membership_id = 1; + $userIngredient->save(); + + $response = $this->postJson('/api/shelf/ingredients/batch-delete?bar_id=1', [ + 'ingredient_ids' => [$ing1->id, $ing2->id] + ]); + + $response->assertNoContent(); + + $this->assertDatabaseMissing('user_ingredients', ['ingredient_id' => $ing1->id]); + $this->assertDatabaseMissing('user_ingredients', ['ingredient_id' => $ing2->id]); + } + + public function test_delete_multiple_ingredients_from_another_bar_response(): void + { + Bar::factory()->create(['id' => 2]); + DB::table('bar_memberships')->insert(['id' => 2, 'bar_id' => 2, 'user_id' => 1, 'user_role_id' => UserRoleEnum::Admin->value]); + $ing1 = Ingredient::factory()->create(['bar_id' => 1]); + $ing2 = Ingredient::factory()->create(['bar_id' => 2]); + + $userIngredient = new UserIngredient(); + $userIngredient->ingredient_id = $ing1->id; + $userIngredient->bar_membership_id = 1; + $userIngredient->save(); + + $userIngredient = new UserIngredient(); + $userIngredient->ingredient_id = $ing2->id; + $userIngredient->bar_membership_id = 2; + $userIngredient->save(); + + $response = $this->postJson('/api/shelf/ingredients/batch-delete?bar_id=1', [ + 'ingredient_ids' => [$ing1->id, $ing2->id] + ]); + + $response->assertNoContent(); + + $this->assertDatabaseMissing('user_ingredients', ['ingredient_id' => $ing1->id]); + $this->assertDatabaseHas('user_ingredients', ['ingredient_id' => $ing2->id]); + } + + public function test_show_cocktail_ids_on_shelf_response(): void + { + $response = $this->getJson('/api/shelf/cocktails?bar_id=1'); + + $response->assertStatus(200); + } +} diff --git a/tests/Feature/ShoppingListControllerTest.php b/tests/Feature/Http/ShoppingListControllerTest.php similarity index 52% rename from tests/Feature/ShoppingListControllerTest.php rename to tests/Feature/Http/ShoppingListControllerTest.php index c2fa2780..8384a93f 100644 --- a/tests/Feature/ShoppingListControllerTest.php +++ b/tests/Feature/Http/ShoppingListControllerTest.php @@ -2,11 +2,14 @@ declare(strict_types=1); -namespace Tests\Feature; +namespace Tests\Feature\Http; use Tests\TestCase; +use Kami\Cocktail\Models\Bar; use Kami\Cocktail\Models\User; +use Illuminate\Support\Facades\DB; use Kami\Cocktail\Models\Ingredient; +use Kami\Cocktail\Models\UserRoleEnum; use Kami\Cocktail\Models\UserShoppingList; use Illuminate\Testing\Fluent\AssertableJson; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -18,21 +21,24 @@ class ShoppingListControllerTest extends TestCase public function setUp(): void { parent::setUp(); - } - public function test_list_ingredients_on_shopping_list_response() - { - $ingredients = Ingredient::factory()->count(5)->create(); $user = User::factory()->create(); $this->actingAs($user); + } + + public function test_list_ingredients_on_shopping_list_response(): void + { + $this->setupBar(); + $ingredients = Ingredient::factory()->count(5)->create(['bar_id' => 1]); foreach ($ingredients as $ingredient) { $usl = new UserShoppingList(); $usl->ingredient_id = $ingredient->id; - $user->shoppingList()->save($usl); + $usl->bar_membership_id = 1; + $usl->save(); } - $response = $this->getJson('/api/shopping-list'); + $response = $this->getJson('/api/shopping-list?bar_id=1'); $response->assertOk(); $response->assertJson( @@ -43,12 +49,12 @@ public function test_list_ingredients_on_shopping_list_response() ); } - public function test_add_multiple_ingredients_to_shopping_list_response() + public function test_add_multiple_ingredients_to_shopping_list_response(): void { - $ingredients = Ingredient::factory()->count(3)->create(); - $this->actingAs(User::factory()->create()); + $this->setupBar(); + $ingredients = Ingredient::factory()->count(3)->create(['bar_id' => 1]); - $response = $this->postJson('/api/shopping-list/batch-store', [ + $response = $this->postJson('/api/shopping-list/batch-store?bar_id=1', [ 'ingredient_ids' => $ingredients->pluck('id')->toArray() ]); @@ -61,28 +67,49 @@ public function test_add_multiple_ingredients_to_shopping_list_response() ); } - public function test_delete_multiple_ingredients_from_shopping_list_response() + public function test_add_multiple_ingredients_to_shopping_list_from_another_bar_response(): void { - $ingredients = Ingredient::factory()->count(2)->create(); - $user = User::factory()->create(); - $this->actingAs($user); + $this->setupBar(); - foreach ($ingredients as $ingredient) { - $usl = new UserShoppingList(); - $usl->ingredient_id = $ingredient->id; - $user->shoppingList()->save($usl); - } + Bar::factory()->create(['id' => 2]); + DB::table('bar_memberships')->insert(['id' => 2, 'bar_id' => 2, 'user_id' => 1, 'user_role_id' => UserRoleEnum::Admin->value]); + $ing1 = Ingredient::factory()->create(['bar_id' => 1]); + $ing2 = Ingredient::factory()->create(['bar_id' => 2]); - $response = $this->postJson('/api/shopping-list/batch-delete', [ - 'ingredient_ids' => $ingredients->pluck('id')->toArray() + $response = $this->postJson('/api/shopping-list/batch-store?bar_id=1', [ + 'ingredient_ids' => [$ing1->id, $ing2->id] ]); $response->assertOk(); $response->assertJson( fn (AssertableJson $json) => $json - ->has('data.ingredient_ids', 2) + ->has('data', 1) + ->where('data.0.ingredient_id', $ing1->id) ->etc() ); } + + public function test_delete_multiple_ingredients_from_shopping_list_response(): void + { + $this->setupBar(); + $ingredients = Ingredient::factory()->count(2)->create(['bar_id' => 1]); + + foreach ($ingredients as $ingredient) { + $usl = new UserShoppingList(); + $usl->ingredient_id = $ingredient->id; + $usl->bar_membership_id = 1; + $usl->save(); + } + + $response = $this->postJson('/api/shopping-list/batch-delete?bar_id=1', [ + 'ingredient_ids' => $ingredients->pluck('id')->toArray() + ]); + + $response->assertNoContent(); + + foreach ($ingredients as $ingredient) { + $this->assertDatabaseMissing('user_shopping_lists', ['ingredient_id' => $ingredient->id, 'bar_memebership_id' => 1]); + } + } } diff --git a/tests/Feature/Http/StatsControllerTest.php b/tests/Feature/Http/StatsControllerTest.php new file mode 100644 index 00000000..4dac8003 --- /dev/null +++ b/tests/Feature/Http/StatsControllerTest.php @@ -0,0 +1,61 @@ +actingAs(User::factory()->create()); + $this->setupBar(); + } + + public function test_stats_response(): void + { + $ingredients = Ingredient::factory()->count(5)->create(['bar_id' => 1]); + $cocktails = Cocktail::factory()->count(6)->create(['bar_id' => 1]); + + $userIngredient = new UserIngredient(); + $userIngredient->ingredient_id = $ingredients->first()->id; + $userIngredient->bar_membership_id = 1; + $userIngredient->save(); + + $favorite = new CocktailFavorite(); + $favorite->cocktail_id = $cocktails->first()->id; + $favorite->bar_membership_id = 1; + $favorite->save(); + + $response = $this->getJson('/api/stats?bar_id=1'); + + $response->assertSuccessful(); + $response->assertJson( + fn (AssertableJson $json) => + $json + ->where('data.total_cocktails', 6) + ->where('data.total_ingredients', 5) + ->where('data.total_shelf_cocktails', 0) + ->where('data.total_shelf_ingredients', 1) + ->where('data.total_favorited_cocktails', 1) + ->where('data.most_popular_ingredients', []) + ->where('data.top_rated_cocktails', []) + ->where('data.total_collections', 0) + ->where('data.your_top_ingredients', []) + ->etc() + ); + } +} diff --git a/tests/Feature/TagControllerTest.php b/tests/Feature/Http/TagControllerTest.php similarity index 73% rename from tests/Feature/TagControllerTest.php rename to tests/Feature/Http/TagControllerTest.php index bd4af917..64a4be36 100644 --- a/tests/Feature/TagControllerTest.php +++ b/tests/Feature/Http/TagControllerTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Feature; +namespace Tests\Feature\Http; use Tests\TestCase; use Kami\Cocktail\Models\Tag; @@ -23,11 +23,12 @@ public function setUp(): void ); } - public function test_list_tags_response() + public function test_list_tags_response(): void { - Tag::factory()->count(10)->create(); + $bar = $this->setupBar(); + Tag::factory()->count(10)->create(['bar_id' => $bar->id]); - $response = $this->getJson('/api/tags'); + $response = $this->getJson('/api/tags?bar_id=' . $bar->id); $response->assertStatus(200); $response->assertJson( @@ -38,10 +39,12 @@ public function test_list_tags_response() ); } - public function test_show_tag_response() + public function test_show_tag_response(): void { + $bar = $this->setupBar(); $model = Tag::factory()->create([ - 'name' => 'Test tag' + 'name' => 'Test tag', + 'bar_id' => $bar->id ]); $response = $this->getJson('/api/tags/' . $model->id); @@ -57,9 +60,10 @@ public function test_show_tag_response() ); } - public function test_create_tag_response() + public function test_create_tag_response(): void { - $response = $this->postJson('/api/tags/', [ + $this->setupBar(); + $response = $this->postJson('/api/tags?bar_id=1', [ 'name' => 'Test tag', ]); @@ -75,10 +79,12 @@ public function test_create_tag_response() ); } - public function test_update_tag_response() + public function test_update_tag_response(): void { + $bar = $this->setupBar(); $model = Tag::factory()->create([ 'name' => 'Start tag', + 'bar_id' => $bar->id ]); $response = $this->putJson('/api/tags/' . $model->id, [ @@ -96,10 +102,12 @@ public function test_update_tag_response() ); } - public function test_delete_tag_response() + public function test_delete_tag_response(): void { + $bar = $this->setupBar(); $tag = Tag::factory()->create([ 'name' => 'Start cat', + 'bar_id' => $bar->id, ]); $response = $this->delete('/api/tags/' . $tag->id); diff --git a/tests/Feature/UsersControllerTest.php b/tests/Feature/Http/UsersControllerTest.php similarity index 52% rename from tests/Feature/UsersControllerTest.php rename to tests/Feature/Http/UsersControllerTest.php index dab880a2..1422d8ff 100644 --- a/tests/Feature/UsersControllerTest.php +++ b/tests/Feature/Http/UsersControllerTest.php @@ -2,10 +2,12 @@ declare(strict_types=1); -namespace Tests\Feature; +namespace Tests\Feature\Http; use Tests\TestCase; use Kami\Cocktail\Models\User; +use Illuminate\Support\Facades\DB; +use Kami\Cocktail\Models\UserRoleEnum; use Illuminate\Testing\Fluent\AssertableJson; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -16,17 +18,22 @@ class UsersControllerTest extends TestCase public function setUp(): void { parent::setUp(); - } - public function test_list_users_response() - { $this->actingAs( User::factory()->create() ); - User::factory()->count(10)->create(); + $this->setupBar(); + } + + public function test_list_users_response(): void + { + $users = User::factory()->count(9)->create(); + foreach ($users as $user) { + DB::table('bar_memberships')->insert(['bar_id' => 1, 'user_id' => $user->id, 'user_role_id' => UserRoleEnum::General->value]); + } - $response = $this->getJson('/api/users'); + $response = $this->getJson('/api/users?bar_id=1'); $response->assertStatus(200); $response->assertJson( @@ -37,30 +44,14 @@ public function test_list_users_response() ); } - public function test_list_users_no_access_response() - { - $this->actingAs( - User::factory()->create(['is_admin' => false]) - ); - - User::factory()->count(2)->create(); - - $response = $this->getJson('/api/users'); - - $response->assertForbidden(); - } - - public function test_show_user_response() + public function test_show_user_response(): void { - $this->actingAs( - User::factory()->create() - ); - - $model = User::factory()->create([ + $user = User::factory()->create([ 'name' => 'Test' ]); + DB::table('bar_memberships')->insert(['bar_id' => 1, 'user_id' => $user->id, 'user_role_id' => UserRoleEnum::General->value]); - $response = $this->getJson('/api/users/' . $model->id); + $response = $this->getJson('/api/users/' . $user->id . '?bar_id=1'); $response->assertStatus(200); $response->assertJson( @@ -73,17 +64,13 @@ public function test_show_user_response() ); } - public function test_create_user_response() + public function test_create_user_response(): void { - $this->actingAs( - User::factory()->create() - ); - - $response = $this->postJson('/api/users/', [ + $response = $this->postJson('/api/users?bar_id=1', [ 'name' => 'Test', 'email' => 'test@test.com', 'password' => 'TEST', - 'is_admin' => false, + 'role_id' => UserRoleEnum::Admin->value, ]); $response->assertCreated(); @@ -93,28 +80,23 @@ public function test_create_user_response() $json ->has('data') ->has('data.id') - ->has('data.search_api_key') ->where('data.name', 'Test') ->where('data.email', 'test@test.com') - ->where('data.is_admin', false) ->etc() ); } - public function test_update_user_response() + public function test_update_user_response(): void { - $this->actingAs( - User::factory()->create() - ); - - $model = User::factory()->create([ + $user = User::factory()->create([ 'name' => 'Initial Name', ]); + DB::table('bar_memberships')->insert(['bar_id' => 1, 'user_id' => $user->id, 'user_role_id' => UserRoleEnum::General->value]); - $response = $this->putJson('/api/users/' . $model->id, [ + $response = $this->putJson('/api/users/' . $user->id . '?bar_id=1', [ 'name' => 'Updated Name', 'email' => 'test@test.com', - 'is_admin' => true, + 'role_id' => UserRoleEnum::General->value, ]); $response->assertSuccessful(); @@ -122,28 +104,26 @@ public function test_update_user_response() fn (AssertableJson $json) => $json ->has('data') - ->where('data.id', $model->id) + ->where('data.id', $user->id) ->where('data.name', 'Updated Name') - ->where('data.email', 'test@test.com') - ->where('data.is_admin', true) + ->where('data.email', $user->email) ->etc() ); } - public function test_delete_user_response() + public function test_delete_user_response(): void { - $this->actingAs( - User::factory()->create() - ); - - $model = User::factory()->create([ + $user = User::factory()->create([ 'name' => 'Initial Name', ]); + DB::table('bar_memberships')->insert(['bar_id' => 1, 'user_id' => $user->id, 'user_role_id' => UserRoleEnum::General->value]); + + $this->actingAs($user); - $response = $this->delete('/api/users/' . $model->id); + $response = $this->delete('/api/users/' . $user->id); $response->assertNoContent(); - $this->assertDatabaseMissing('users', ['id' => $model->id]); + $this->assertDatabaseMissing('users', ['id' => $user->id]); } } diff --git a/tests/Feature/UtensilControllerTest.php b/tests/Feature/Http/UtensilControllerTest.php similarity index 75% rename from tests/Feature/UtensilControllerTest.php rename to tests/Feature/Http/UtensilControllerTest.php index 22250379..9b1ab48c 100644 --- a/tests/Feature/UtensilControllerTest.php +++ b/tests/Feature/Http/UtensilControllerTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Feature; +namespace Tests\Feature\Http; use Tests\TestCase; use Kami\Cocktail\Models\User; @@ -21,24 +21,28 @@ public function setUp(): void $this->actingAs(User::factory()->create()); } - public function test_list_all_utensils_response() + public function test_list_all_utensils_response(): void { - $response = $this->getJson('/api/utensils'); + $this->setupBar(); + Utensil::factory()->count(10)->create(['bar_id' => 1]); + $response = $this->getJson('/api/utensils?bar_id=1'); $response->assertOk(); $response->assertJson( fn (AssertableJson $json) => $json - ->has('data', 20) + ->has('data', 10) ->etc() ); } - public function test_show_utensil_response() + public function test_show_utensil_response(): void { + $this->setupBar(); $utensil = Utensil::factory()->create([ 'name' => 'Utensil 1', 'description' => 'Utensil 1 Description', + 'bar_id' => 1, ]); $response = $this->getJson('/api/utensils/' . $utensil->id); @@ -54,9 +58,10 @@ public function test_show_utensil_response() ); } - public function test_save_utensil_response() + public function test_save_utensil_response(): void { - $response = $this->postJson('/api/utensils/', [ + $this->setupBar(); + $response = $this->postJson('/api/utensils?bar_id=1', [ 'name' => 'Utensil 1', 'description' => 'Utensil 1 Description', ]); @@ -72,22 +77,13 @@ public function test_save_utensil_response() ); } - public function test_save_utensil_forbidden_response() - { - $this->actingAs(User::factory()->create(['is_admin' => false])); - - $response = $this->postJson('/api/utensils/', [ - 'name' => 'Utensil 1' - ]); - - $response->assertForbidden(); - } - - public function test_update_utensil_response() + public function test_update_utensil_response(): void { + $this->setupBar(); $utensil = Utensil::factory()->create([ 'name' => 'Utensil 1', 'description' => 'Utensil 1 Description', + 'bar_id' => 1, ]); $response = $this->putJson('/api/utensils/' . $utensil->id, [ @@ -106,15 +102,18 @@ public function test_update_utensil_response() ); } - public function test_delete_utensil_response() + public function test_delete_utensil_response(): void { + $this->setupBar(); $utensil = Utensil::factory()->create([ 'name' => 'Utensil 1', 'description' => 'Utensil 1 Description', + 'bar_id' => 1, ]); $response = $this->deleteJson('/api/utensils/' . $utensil->id); $response->assertNoContent(); + $this->assertDatabaseMissing('utensils', ['id' => $utensil->id]); } } diff --git a/tests/Feature/Services/ImportServiceTest.php b/tests/Feature/Import/FromArrayTest.php similarity index 52% rename from tests/Feature/Services/ImportServiceTest.php rename to tests/Feature/Import/FromArrayTest.php index 78fea333..35339736 100644 --- a/tests/Feature/Services/ImportServiceTest.php +++ b/tests/Feature/Import/FromArrayTest.php @@ -1,17 +1,19 @@ setupBar(); + $importer = $this->getImporter(); + $existingIngredient = Ingredient::factory() ->state([ 'name' => 'Ingredient 1', 'strength' => 45.5, - 'description' => 'Test' + 'description' => 'Test', + 'bar_id' => $bar->id, ]) ->create(); $method = CocktailMethod::factory()->create([ - 'name' => 'Method name' + 'name' => 'Method name', + 'bar_id' => $bar->id, ]); $glass = Glass::factory()->create([ 'name' => 'Glass name', + 'bar_id' => $bar->id, ]); - $service = $this->getService(); - $importArray = [ - 'name' => 'Cocktail name', - 'instructions' => 'Instruction data', - 'description' => 'Description data', - 'source' => 'Laravel', - 'garnish' => 'Garnish data', - 'tags' => ['Tag 1', 'Tag 2'], - 'method' => 'Method name', - 'glass' => 'Glass name', - 'images' => [ - ['url' => UploadedFile::fake()->image('image1.jpg'), 'copyright' => 'Image copyright 1'], - ['url' => null, 'copyright' => 'Null'], - ['url' => UploadedFile::fake()->image('image2.png')], - ], - 'ingredients' => [ - [ - 'name' => 'Ingredient 1', - 'amount' => 30, - 'units' => 'ml', - 'optional' => true, - 'substitutes' => [], - 'strength' => 45.5, - 'description' => 'Existing ingredient', - 'origin' => 'Laravel test suite', - ], - [ - 'name' => 'New ingredient', - 'amount' => 22.5, - 'units' => 'ml', - 'optional' => false, - 'substitutes' => ['Ingredient 1'], - 'strength' => 40, - 'description' => 'New ingredient description', - 'origin' => 'Laravel test suite', - ] - ] + $importData = json_decode(file_get_contents(base_path('tests/fixtures/import.json')), true); + $importData['images'] = [ + ['url' => UploadedFile::fake()->image('image1.jpg'), 'copyright' => 'Image copyright 1'], + ['url' => null, 'copyright' => 'Null'], + ['url' => UploadedFile::fake()->image('image2.png')], ]; - $cocktail = $service->importCocktailFromArray($importArray); + $cocktail = $importer->process( + $importData, + auth()->user()->id, + $bar->id + ); $this->assertSame('Cocktail name', $cocktail->name); $this->assertSame('Instruction data', $cocktail->instructions); @@ -93,22 +73,31 @@ public function test_cocktail_default_import_from_array() $this->assertSame('Tag 1', $cocktail->tags->first()->name); $this->assertSame('Tag 2', $cocktail->tags->last()->name); + // Ingredient 1 + $this->assertSame(1, $cocktail->ingredients->first()->sort); $this->assertSame($existingIngredient->id, $cocktail->ingredients->first()->ingredient_id); $this->assertSame(30, $cocktail->ingredients->first()->amount); + $this->assertNull($cocktail->ingredients->first()->amount_max); + $this->assertNull($cocktail->ingredients->first()->note); $this->assertSame('ml', $cocktail->ingredients->first()->units); - $this->assertTrue((bool) $cocktail->ingredients->first()->optional); + $this->assertFalse((bool) $cocktail->ingredients->first()->optional); $this->assertCount(0, $cocktail->ingredients->first()->substitutes); + // Ingredient 2 + $this->assertSame(2, $cocktail->ingredients->last()->sort); $this->assertSame(2, $cocktail->ingredients->last()->ingredient_id); $this->assertSame('New ingredient', $cocktail->ingredients->last()->ingredient->name); $this->assertSame(40, $cocktail->ingredients->last()->ingredient->strength); $this->assertSame('New ingredient description', $cocktail->ingredients->last()->ingredient->description); $this->assertSame('Laravel test suite', $cocktail->ingredients->last()->ingredient->origin); - $this->assertSame(22.5, $cocktail->ingredients->last()->amount); - $this->assertSame('ml', $cocktail->ingredients->last()->units); - $this->assertFalse((bool) $cocktail->ingredients->last()->optional); - $this->assertCount(1, $cocktail->ingredients->last()->substitutes); + $this->assertSame(2, $cocktail->ingredients->last()->amount); + $this->assertSame(4, $cocktail->ingredients->last()->amount_max); + $this->assertSame('Use best one', $cocktail->ingredients->last()->note); + $this->assertSame('dashes', $cocktail->ingredients->last()->units); + $this->assertTrue((bool) $cocktail->ingredients->last()->optional); + $this->assertCount(2, $cocktail->ingredients->last()->substitutes); + // Images $this->assertCount(2, $cocktail->images); $this->assertSame('jpg', $cocktail->images->first()->file_extension); $this->assertSame('Image copyright 1', $cocktail->images->first()->copyright); @@ -116,8 +105,8 @@ public function test_cocktail_default_import_from_array() $this->assertNull($cocktail->images->last()->copyright); } - private function getService(): ImportService + private function getImporter(): FromArray { - return resolve(ImportService::class); + return resolve(FromArray::class); } } diff --git a/tests/Feature/Import/FromCollectionTest.php b/tests/Feature/Import/FromCollectionTest.php new file mode 100644 index 00000000..dd907001 --- /dev/null +++ b/tests/Feature/Import/FromCollectionTest.php @@ -0,0 +1,97 @@ +actingAs( + User::factory()->create() + ); + } + + public function test_process_none(): void + { + $bar = $this->setupBar(); + $importer = $this->getImporter(); + + $importData = json_decode(file_get_contents(base_path('tests/fixtures/import_collection.json')), true); + + $this->assertDatabaseCount('cocktails', 0); + $collection = $importer->process( + $importData, + auth()->user()->id, + $bar->id, + DuplicateActionsEnum::None + ); + + $this->assertSame('Test collection', $collection->name); + $this->assertNull($collection->description); + $this->assertCount(2, $collection->cocktails); + } + + public function test_process_skip(): void + { + $bar = $this->setupBar(); + $importer = $this->getImporter(); + + Cocktail::factory()->create(['bar_id' => $bar->id, 'name' => 'Test 1']); + Cocktail::factory()->create(['bar_id' => $bar->id, 'name' => 'Test 2']); + + $importData = json_decode(file_get_contents(base_path('tests/fixtures/import_collection.json')), true); + + $this->assertDatabaseCount('cocktails', 2); + $collection = $importer->process( + $importData, + auth()->user()->id, + $bar->id, + DuplicateActionsEnum::Skip + ); + + $this->assertDatabaseCount('cocktails', 2); + $this->assertCount(2, $collection->cocktails); + } + + public function test_process_overwrite(): void + { + $bar = $this->setupBar(); + $importer = $this->getImporter(); + + $cocktail1 = Cocktail::factory()->create(['bar_id' => $bar->id, 'name' => 'Test 1', 'instructions' => 'Original']); + $cocktail2 = Cocktail::factory()->create(['bar_id' => $bar->id, 'name' => 'Test 2', 'instructions' => 'Original']); + + $importData = json_decode(file_get_contents(base_path('tests/fixtures/import_collection.json')), true); + + $this->assertDatabaseCount('cocktails', 2); + $collection = $importer->process( + $importData, + auth()->user()->id, + $bar->id, + DuplicateActionsEnum::Overwrite + ); + + $this->assertDatabaseCount('cocktails', 2); + $this->assertCount(2, $collection->cocktails); + $this->assertSame('Lorem ipsum 1', $cocktail1->fresh()->instructions); + $this->assertSame('Lorem ipsum 2', $cocktail2->fresh()->instructions); + } + + private function getImporter(): FromCollection + { + return resolve(FromCollection::class); + } +} diff --git a/tests/Feature/Search/MeilisearchActionsTest.php b/tests/Feature/Search/MeilisearchActionsTest.php new file mode 100644 index 00000000..3900e7cc --- /dev/null +++ b/tests/Feature/Search/MeilisearchActionsTest.php @@ -0,0 +1,60 @@ +getActions(); + + $jwt = $search->getBarSearchApiKey(72); + $contents = json_decode(base64_decode(str_replace('_', '/', str_replace('-', '+', explode('.', $jwt)[1])))); + + $this->assertNotNull($jwt); + $this->assertSame('bar_id = 72', $contents->searchRules->cocktails->filter); + $this->assertSame('bar_id = 72', $contents->searchRules->ingredients->filter); + } + + public function test_isAvailable(): void + { + $search = $this->getActions(); + + $this->assertTrue($search->isAvailable()); + } + + public function test_getVersion(): void + { + $search = $this->getActions(); + + $this->assertNotNull($search->getVersion()); + } + + public function test_getHost(): void + { + $search = $this->getActions(); + + $this->assertNotNull($search->getHost()); + } + + private function getActions(): MeilisearchActions + { + $engineManager = resolve(EngineManager::class); + + return new MeilisearchActions($engineManager->engine()); + } +} diff --git a/tests/Feature/ShelfControllerTest.php b/tests/Feature/ShelfControllerTest.php deleted file mode 100644 index db007a94..00000000 --- a/tests/Feature/ShelfControllerTest.php +++ /dev/null @@ -1,98 +0,0 @@ -count(5)->create(); - $user = User::factory()->create(); - - foreach ($ingredients as $ingredient) { - $userIngredient = new UserIngredient(); - $userIngredient->ingredient_id = $ingredient->id; - $user->shelfIngredients()->save($userIngredient); - } - - $this->actingAs($user); - } - - public function test_list_all_user_ingredients_response() - { - $response = $this->getJson('/api/shelf/ingredients'); - - $response->assertSuccessful(); - $response->assertJson( - fn (AssertableJson $json) => - $json - ->has('data', 5) - ->etc() - ); - } - - public function test_add_multiple_ingredients_to_shelf_response() - { - $newIngredients = Ingredient::factory()->count(2)->create(); - - $response = $this->postJson('/api/shelf/ingredients', [ - 'ingredient_ids' => $newIngredients->pluck('id')->toArray() - ]); - - $response->assertSuccessful(); - $response->assertJson( - fn (AssertableJson $json) => - $json - ->has('data', 2) - ->etc() - ); - } - - public function test_add_ingredient_to_shelf_response() - { - $newIngredient = Ingredient::factory()->create(); - - $response = $this->postJson('/api/shelf/ingredients/' . $newIngredient->id); - - $response->assertOk(); - $response->assertJson( - fn (AssertableJson $json) => - $json - ->has('data.id') - ->has('data.ingredient_slug') - ->where('data.ingredient_id', $newIngredient->id) - ->etc() - ); - } - - public function test_delete_ingredient_from_shelf_response() - { - $newIngredient = Ingredient::factory()->create(); - - $response = $this->deleteJson('/api/shelf/ingredients/' . $newIngredient->id); - - $response->assertNoContent(); - - $this->assertDatabaseMissing('user_ingredients', ['ingredient_id' => $newIngredient->id]); - } - - public function test_user_shelf_cocktails_response() - { - $response = $this->getJson('/api/shelf/cocktails'); - - $response->assertStatus(200); - } -} diff --git a/tests/Feature/StatsControllerTest.php b/tests/Feature/StatsControllerTest.php deleted file mode 100644 index 425642eb..00000000 --- a/tests/Feature/StatsControllerTest.php +++ /dev/null @@ -1,45 +0,0 @@ -count(5)->create(); - Cocktail::factory()->count(6)->create(); - $user = User::factory()->create(); - - $this->actingAs($user); - } - - public function test_stats_response() - { - $response = $this->getJson('/api/stats'); - - $response->assertSuccessful(); - $response->assertJson( - fn (AssertableJson $json) => - $json - ->where('data.total_ingredients', 5) - ->where('data.total_cocktails', 6) - ->where('data.total_shelf_cocktails', 0) - ->has('data.most_popular_ingredients', 0) - ->has('data.top_rated_cocktails', 0) - ->etc() - ); - } -} diff --git a/tests/Scrapers/CocktailClubScraperTest.php b/tests/Scrapers/CocktailClubScraperTest.php index 3d9ec3c3..d6675877 100644 --- a/tests/Scrapers/CocktailClubScraperTest.php +++ b/tests/Scrapers/CocktailClubScraperTest.php @@ -16,7 +16,7 @@ public function testScrape(): void $instructions = "1. Peel a large zest from a fresh orange and cut ready for garnish\n2. Fill your glass with cubed ice\n3. Pour in the equal measure of each ingredient\n4. Using your bar spoon, stir all the ingredients together well for about 20 sec.\n5. Spray the oils from the zest by twisting it over the drink for that citrus aroma."; - $this->assertSame('Negroni', $result['name']); + // $this->assertSame('Negroni', $result['name']); // Unstable to fetch $this->assertSame('Sophisticated and simple at the same time with a complex flavor that makes the perfect aperitif. Popular all over the world and de rigeur during aperitivo hour in Milan  ', $result['description']); $this->assertSame('https://cocktailclub.com/cocktails/negroni-tanqueray', $result['source']); $this->assertSame(null, $result['glass']); diff --git a/tests/TestCase.php b/tests/TestCase.php index 2932d4a6..7383130d 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,10 +1,23 @@ create(['id' => 1, 'created_user_id' => auth()->user()->id]); + DB::table('bar_memberships')->insert(['id' => 1, 'bar_id' => $bar->id, 'user_id' => auth()->user()->id, 'user_role_id' => UserRoleEnum::Admin->value]); + + return $bar; + } } diff --git a/tests/Unit/UnitConverter/ConverterTest.php b/tests/Unit/UnitConverter/ConverterTest.php index 0963dae0..8d7c4bab 100644 --- a/tests/Unit/UnitConverter/ConverterTest.php +++ b/tests/Unit/UnitConverter/ConverterTest.php @@ -5,7 +5,6 @@ namespace Tests\Unit\UnitConverter; use Tests\TestCase; -use InvalidArgumentException; use Kami\Cocktail\UnitConverter\Cl; use Kami\Cocktail\UnitConverter\Ml; use Kami\Cocktail\UnitConverter\Oz; diff --git a/tests/fixtures/import.json b/tests/fixtures/import.json index 30119f1b..03d9abdb 100644 --- a/tests/fixtures/import.json +++ b/tests/fixtures/import.json @@ -1,28 +1,29 @@ { - "name": "Old Fashioned", - "instructions": "Place sugar cube in old fashioned glass and saturate with bitter, add few dashes of plain water. Muddle until dissolved. Fill the glass with ice cubes and add whiskey. Stir gently.", - "garnish": "Garnish with an orange slice or zest, and a cocktail cherry.", - "description": "Developed during the 19th century and given its name in the 1880s, it is an IBA Official Cocktail. It is also one of six basic drinks listed in David A. Embury's The Fine Art of Mixing Drinks. ", - "source": "https:\/\/iba-world.com\/old-fashioned\/", + "name": "Cocktail name", + "instructions": "Instruction data", + "description": "Description data", + "garnish": "Garnish data", + "source": "Laravel", "tags": [ - "IBA Cocktail", - "The Unforgettables", - "Whiskey" + "Tag 1", + "Tag 2" ], - "glass": "Lowball", - "method": "Build", + "abv": 14.2, + "glass": "Glass name", + "method": "Method name", "images": [ { - "url": "http:\/\/localhost:8000\/uploads\/cocktails\/old-fashioned_p1lxM7.jpg", - "copyright": "Epicurious", - "sort": 0 + "url": null, + "copyright": "Localhost", + "sort": 1 } ], "ingredients": [ { "sort": 1, - "name": "Bourbon Whiskey", - "amount": 45, + "name": "Ingredient 1", + "amount": 30, + "amount_max": null, "units": "ml", "optional": false, "category": "Spirits", @@ -33,39 +34,17 @@ }, { "sort": 2, - "name": "Sugar", - "amount": 1, - "units": "cube", - "optional": false, - "category": "Uncategorized", - "description": "White sugar", - "strength": 0, - "origin": null, - "substitutes": [] - }, - { - "sort": 3, - "name": "Angostura aromatic bitters", + "name": "New ingredient", "amount": 2, + "amount_max": 4, "units": "dashes", - "optional": false, - "category": "Bitters", - "description": "Angostura bitters is a concentrated bitters (herbal alcoholic preparation) based on gentian, herbs, and spices, by House of Angostura in Trinidad and Tobago.", - "strength": 44.7, - "origin": "Trinidad & Tobago", - "substitutes": [] - }, - { - "sort": 4, - "name": "Water", - "amount": 2, - "units": "dashes", - "optional": false, + "optional": true, "category": "Beverages", - "description": "It's water.", - "strength": 0, - "origin": "Worldwide", - "substitutes": [] + "description": "New ingredient description", + "strength": 40, + "origin": "Laravel test suite", + "note": "Use best one", + "substitutes": ["Club soda", "H20"] } ] } diff --git a/tests/fixtures/import_collection.json b/tests/fixtures/import_collection.json new file mode 100644 index 00000000..9686fd89 --- /dev/null +++ b/tests/fixtures/import_collection.json @@ -0,0 +1,32 @@ +{ + "name": "Test collection", + "description": null, + "cocktails": [ + { + "name": "Test 1", + "instructions": "Lorem ipsum 1", + "garnish": "orange twist", + "description": null, + "source": null, + "tags": [], + "glass": "Lowball", + "method": "Stir", + "abv": 29.88, + "images": [], + "ingredients": [] + }, + { + "name": "Test 2", + "instructions": "Lorem ipsum 2", + "garnish": "orange twist", + "description": null, + "source": null, + "tags": [], + "glass": "Lowball", + "method": "Stir", + "abv": 29.88, + "images": [], + "ingredients": [] + } + ] +}