[{"content":" Project Overview # Project: Fog Carport Case\nSemester: DAT 2nd Semester Exam Project\nFocus: Web application architecture, business logic modeling, and workflow design.\nThis project was developed as part of the Datamatiker program’s 2nd semester exam and is based on a case provided by the Danish building materials company Johannes Fog A/S.\nThe goal of the project is to design and implement a system that supports the ordering and sales process for custom-built carports. The application allows customers to configure a carport with their own dimensions and submit a request directly through a web interface.\nAt the same time, the system supports sellers by providing a structured workflow for reviewing requests, validating offers, and managing orders.\nThe project focuses on modeling real-world business requirements and translating them into a maintainable software architecture.\nVision # The purpose of the system is to demonstrate how Fog’s existing manual workflow can be replaced by a structured digital process.\nInstead of handling carport requests through fragmented tools and manual calculations, the application provides a clear workflow where:\ncustomers can configure and request a custom carport sellers review and validate requests pricing and material lists are calculated automatically orders move through a controlled lifecycle from request to payment The solution emphasizes a clear separation between customer-facing functionality and internal business logic used by sellers.\nProblem Context # Fog currently uses an older internal system to handle orders for custom-built carports. Over time this system has become difficult to maintain and no longer fits the way sellers work in practice.\nAs a result, the company faces several challenges:\nthe existing tool cannot easily be maintained or extended sellers rely on manual processes and calculations the customer ordering flow is not integrated with the internal workflow order information and material calculations are not handled within a single system Fog therefore wants a modern solution where customers can configure and request carports directly from the website, while sellers manage the order process through a structured workflow.\nThis project demonstrates how such a system could be designed and implemented.\nBusiness Logic: Carport Order Workflow # The application models the process used when selling custom-built carports.\n1) Customer configuration and request # Customers begin by configuring a desired carport through a web interface.\nThe configuration includes parameters such as:\ncarport width and length optional shed roof configuration When the customer submits the form, the system creates a request that functions as an initial offer draft.\n2) Seller validation # Before an offer becomes available to the customer, it must be reviewed by a seller.\nThe seller can:\nvalidate that the configuration is technically feasible review automatically generated calculations adjust pricing if necessary This step ensures that all offers follow the company’s pricing rules and construction constraints.\n3) Offer presentation # Once the seller approves the offer, the customer can view it through the web application.\nAt this stage the system displays:\nthe selected configuration the calculated price the current order status However, internal construction details such as the full material list remain hidden.\n4) Payment and order completion # If the customer accepts the offer and completes payment, the order becomes finalized.\nAt this stage the system reveals:\nthe complete material list a generated construction drawing (SVG) the full order details This approach ensures that internal construction knowledge is only shared once an order is confirmed.\nArchitecture # The prototype is implemented using a layered architecture inspired by the MVC (Model–View–Controller) pattern combined with a dedicated service layer.\nThis architecture separates responsibilities between presentation, business logic, and data access.\nThe application uses Thymeleaf for server-side rendering. Controllers handle HTTP requests, delegate business logic to the service layer, and return either Thymeleaf templates for rendering or redirects following the Post-Redirect-Get pattern.\nThe system consists of the following main components:\nControllers\nhandle HTTP requests and routing prepare data for presentation return rendered views or redirect responses Service Layer\nimplements domain logic performs price calculations generates material lists coordinates the order workflow DAO / Repository Layer\nhandles database access retrieves and stores domain entities such as customers, offers, orders, and materials Database\nrelational data model storing application data and order state This structure ensures that business rules remain independent from both the user interface and the database implementation.\nDeployment # A deployed version of the application is available online.\nLive Demo\nhttps://fog.corral.dk\nSource Code # The complete implementation is available on GitHub.\nMortenjenne/fog-carport Fog carport calculator null 0 0 Contributors # Daniel Hangaard Morten Jensen\n","date":"28 January 2026","externalUrl":null,"permalink":"/projects/fog-carport/","section":"Projects","summary":"A web application supporting the ordering and offer workflow for custom-built carports at Johannes Fog.","title":"Fog Carport","type":"projects"},{"content":" Project Overview # Project: Portfolio\nSemester: DAT 3rd Semester 2026\nFocus: Production-ready Java backend with workflow and resource optimization for a modern canteen kitchen.\nMiseOS is built around one core idea: the kitchen should have the same digital discipline as physical mise en place.\nThe system supports the full journey from dish suggestion to published menu, including ingredient requests, allergen visibility, and multilingual communication for guests.\nVision # MiseOS is a kitchen operations backend that helps teams move from fragmented, manual planning to a structured, role-based workflow.\nIt supports:\ncreative ownership for line cooks editorial and operational control for head/sous chef reliable publication of guest-facing menu information Problem Statement # In many kitchens, menu planning still depends on handwritten notes, Word files, and verbal coordination.\nThis creates recurring problems:\ndish ideas are scattered across stations ingredient needs are difficult to consolidate translation and allergen communication are error-prone management lacks one coherent overview MiseOS solves this by centralizing planning, approvals, menu publishing, and ingredient workflows in one backend system.\nBusiness Logic: The Creative Canteen # The application models a kitchen where quality comes from station-level ownership and management curation.\n1) Section-based planning (bottom-up) # Instead of top-down menu creation, stations submit proposals independently.\nStations: Hot, Cold/Starter, Salad, Bakery/Dessert Line cooks submit weekly dish suggestions per station Empty slots are allowed intentionally Suggestions can later be translated for guest-facing use 2) Head chef curation and verification # The head/sous chef has full overview and editorial authority.\nReview and approve/reject/edit suggestions Assemble complete weekly menu Balance variety and operational feasibility Ensure Danish/English guest content quality 3) Ingredient and ordering flow # Ingredient needs are linked directly to operational planning.\nCooks submit ingredient requests Management reviews and approves Requests are aggregated into shopping lists Finalized list supports better ordering decisions 4) Nice-to-have extension (waste reduction) # A planned extension is takeaway handling for leftovers:\nEnable surplus portions after lunch Allow guest/customer reservation flow Track offered/sold/remaining portions Architecture # MiseOS is implemented as a layered backend architecture separating HTTP handling, business logic, and persistence.\nThe system consists of:\nJavalin controllers handling REST and WebSocket communication Service layer implementing domain logic and workflow rules DAO layer using JPA/Hibernate for database access PostgreSQL relational data model JWT authentication and role-based authorization External API integrations for AI, translation, and weather data Development Log (Portfolio Posts) # The project development is documented weekly in the blog series.\nWhy I\u0026rsquo;m Building MiseOS Understanding the Kitchen: From Real World to User Stories From ERD to JPA: Design decisions and Hibernate implementation Integrating AI \u0026amp; Translation: External APIs and Service Layer Design Designing and Implementing the Service Layer Wiring the Application: Controllers, Routes, and Server Configuration Testing the API Layer and Real-Time Notifications with WebSockets From Fake IDs to Secure Passports: A Journey into JWT Middleware From Localhost to Live: Deploying MiseOS with CI/CD Writing the map, while building the city: A Week on MiseOS API Documentation API Documentation # All resource endpoints, request/response examples, and authentication details are documented in the API documentation.\nAPI documentation\nProject Video # A short walkthrough of the MiseOS portfolio and backend system.\nThe video covers the project overview, development log, API documentation, and a live backend demo in IntelliJ.\nSource Code # The complete Java backend implementation is available on GitHub.\nMortenjenne/miseOS Kitchen management system Java 0 0 ","date":"28 January 2026","externalUrl":null,"permalink":"/projects/miseos/","section":"Projects","summary":"MiseOS is a production-oriented Java backend focused on canteen workflow, weekly menu publishing, ingredient coordination, and multilingual guest communication.","title":"MiseOS (Exam Portfolio)","type":"projects"},{"content":" Project Overview # Project: Olsker Cupcakes\nSemester: Datamatiker – 2nd Semester Project\nFocus: Web application prototype for online cupcake ordering.\nThe Olsker Cupcakes project is a web-based system developed as part of the second semester of the Datamatiker program.\nThe application demonstrates how a small local bakery could introduce a digital ordering platform where customers can design and order custom cupcakes online. The system allows users to choose a cupcake base and topping combination and place an order for in-store pickup.\nThe project focuses on implementing a clear ordering workflow and demonstrating a maintainable backend structure.\nBackground # Olsker Cupcakes is a small organic bakery located in Olsker on the island of Bornholm. The bakery specializes in handmade cupcakes produced with local and sustainable ingredients.\nTo improve accessibility and customer convenience, the business wanted a digital solution where customers could browse the available cupcake options, customize their order, and place orders online.\nThis project demonstrates how such a system could work as an initial step toward a digital ordering platform.\nSystem Workflow # The application supports a simple ordering process for both customers and administrators.\nCustomer functionality\nCustomers can:\nbrowse available cupcake bases and toppings create custom cupcake combinations place orders for pickup create an account or order as a guest Administrator functionality\nAdministrators can:\nview users and orders manage order status and payments remove invalid or unpaid orders This workflow demonstrates how a small food business could manage online orders in a structured way.\nArchitecture # The application is implemented using a lightweight Java web stack.\nThe system uses Javalin for HTTP routing and server handling together with Thymeleaf for server-side rendering of HTML pages.\nControllers handle incoming HTTP requests, call the service layer for business logic, and render views using Thymeleaf templates.\nThe application consists of the following main layers:\nControllers – handle routing and HTTP requests Service Layer – implements business logic such as order handling Mapper / DAO Layer – manages database access Database – PostgreSQL relational data model This layered structure helps separate presentation logic from business logic and persistence.\nDeployment # A deployed version of the application is available online.\nLive Demo\nhttps://cupcake.corral.dk\nSource Code # The full implementation is available on GitHub.\nMortenjenne/CupCake Olskers Cupcake Bornholm Java 0 0 Contributors # Toby Hartzberg\nJesper Andersen\nMorten Jensen\nDaniel Hangaard\n","date":"28 January 2026","externalUrl":null,"permalink":"/projects/olsker-cupcakes/","section":"Projects","summary":"A web application prototype that allows customers to design and order custom cupcakes.","title":"Olsker Cupcakes","type":"projects"},{"content":"","date":"9 April 2026","externalUrl":null,"permalink":"/docs/","section":"Docs","summary":"","title":"Docs","type":"docs"},{"content":"Welcome to my website! Here i will be sharing my projects, blog posts, and insights from my journey to becoming a developer.\n","date":"9 April 2026","externalUrl":null,"permalink":"/","section":"Morten Jensen","summary":"","title":"Morten Jensen","type":"page"},{"content":"This section documents the takeaway offer endpoints of the MiseOS API.\nAfter lunch service, head chefs and sous chefs can publish leftover portions as takeaway offers. Customers can browse available offers and place orders through the order endpoints.\nEach offer is linked to a dish from the dish bank and tracks the number of offered and remaining portions. When all portions are sold, the offer is automatically marked as sold out and disabled.\nTakeaway offer lifecycle # stateDiagram-v2 [*] --\u003e ENABLED : offer created (after 12:00) ENABLED --\u003e DISABLED : manually disabled DISABLED --\u003e ENABLED : re-enabled ENABLED --\u003e SOLD_OUT : all portions sold SOLD_OUT --\u003e [*] : offer closed One offer per dish per day is allowed. Offers can only be created after 12:00.\nTakeaway Offer Endpoints # Method URL Auth GET /takeaway/offers ANYONE GET /takeaway/offers/{id} ANYONE POST /takeaway/offers HEAD_CHEF, SOUS_CHEF PUT /takeaway/offers/{id} HEAD_CHEF, SOUS_CHEF PATCH /takeaway/offers/{id}/enable HEAD_CHEF, SOUS_CHEF PATCH /takeaway/offers/{id}/disable HEAD_CHEF, SOUS_CHEF DELETE /takeaway/offers/{id} HEAD_CHEF, SOUS_CHEF TakeAwayOffer response object # The take away offer object contains the following fields:\n{ \u0026#34;id\u0026#34;: 1, \u0026#34;enabled\u0026#34;: true, \u0026#34;soldOut\u0026#34;: false, \u0026#34;offeredPortions\u0026#34;: 20, \u0026#34;availablePortions\u0026#34;: 14, \u0026#34;price\u0026#34;: 65.00, \u0026#34;dish\u0026#34;: { \u0026#34;id\u0026#34;: 3, \u0026#34;nameDA\u0026#34;: \u0026#34;Boller i karry\u0026#34; }, \u0026#34;createdBy\u0026#34;: { \u0026#34;id\u0026#34;: 1, \u0026#34;firstName\u0026#34;: \u0026#34;Gordon\u0026#34;, \u0026#34;lastName\u0026#34;: \u0026#34;Ramsay\u0026#34; }, \u0026#34;createdAt\u0026#34;: \u0026#34;2026-04-09\u0026#34;, \u0026#34;updatedAt\u0026#34;: \u0026#34;2026-04-09 13:15\u0026#34; } GET /takeaway/offers # Returns all offers with optional filters. Available to anyone — no token required.\nQuery parameters\nParameter Type Description date LocalDate Filter by offer date (yyyy-MM-dd) enabled Boolean Filter by enabled status soldOut Boolean Filter by sold out status dishId Long Filter by specific dish Example Request # curl \u0026#34;https://miseos.corral.dk/api/v1/takeaway/offers?enabled=true\u0026amp;soldOut=false\u0026#34; Response 200 — array of TakeAwayOffer objects:\n[ { \u0026#34;id\u0026#34;: 1, \u0026#34;enabled\u0026#34;: true, \u0026#34;soldOut\u0026#34;: false, \u0026#34;offeredPortions\u0026#34;: 20, \u0026#34;availablePortions\u0026#34;: 14, \u0026#34;price\u0026#34;: 65.00, \u0026#34;dish\u0026#34;: { \u0026#34;id\u0026#34;: 3, \u0026#34;nameDA\u0026#34;: \u0026#34;Boller i karry\u0026#34; }, \u0026#34;createdBy\u0026#34;: { \u0026#34;id\u0026#34;: 1, \u0026#34;firstName\u0026#34;: \u0026#34;Gordon\u0026#34;, \u0026#34;lastName\u0026#34;: \u0026#34;Ramsay\u0026#34; }, \u0026#34;createdAt\u0026#34;: \u0026#34;2026-04-09\u0026#34;, \u0026#34;updatedAt\u0026#34;: \u0026#34;2026-04-09 13:15\u0026#34; }, { \u0026#34;id\u0026#34;: 2, \u0026#34;enabled\u0026#34;: true, \u0026#34;soldOut\u0026#34;: false, \u0026#34;offeredPortions\u0026#34;: 35, \u0026#34;availablePortions\u0026#34;: 17, \u0026#34;price\u0026#34;: 65.00, \u0026#34;dish\u0026#34;: { \u0026#34;id\u0026#34;: 7, \u0026#34;nameDA\u0026#34;: \u0026#34;Stegt flæsk\u0026#34; }, \u0026#34;createdBy\u0026#34;: { \u0026#34;id\u0026#34;: 2, \u0026#34;firstName\u0026#34;: \u0026#34;Jamie\u0026#34;, \u0026#34;lastName\u0026#34;: \u0026#34;Oliver\u0026#34; }, \u0026#34;createdAt\u0026#34;: \u0026#34;2026-04-09\u0026#34;, \u0026#34;updatedAt\u0026#34;: \u0026#34;2026-04-09 13:20\u0026#34; } ] GET /takeaway/offers/{id} # Returns a single offer by ID. Available to anyone.\nExample Request # curl https://miseos.corral.dk/api/v1/takeaway/offers/1 Response 200 — TakeAwayOffer object.\nErrors\nStatus Cause 400 Invalid ID 404 Offer not found POST /takeaway/offers # Creates a new takeaway offer for today\u0026rsquo;s service.\nDomain rules:\nOffers can only be created after 12:00 Only one offer per dish per day is allowed The dish must be active in the dish bank Request body\n{ \u0026#34;dishId\u0026#34;: 3, \u0026#34;offeredPortions\u0026#34;: 20, \u0026#34;price\u0026#34;: 65.00 } Example Request # curl -X POST \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;dishId\u0026#34;: 3, \u0026#34;offeredPortions\u0026#34;: 20, \u0026#34;price\u0026#34;: 65.00}\u0026#39; \\ https://miseos.corral.dk/api/v1/takeaway/offers Response 201 — created TakeAwayOffer object.\nErrors\nStatus Cause 400 Invalid input — missing or invalid field 404 Dish not found 409 Offer creation before 12:00 409 An offer for this dish already exists today PUT /takeaway/offers/{id} # Updates an existing offer. Useful to correct portions or price before orders are placed.\nDomain rules:\nPortions cannot be set below already sold quantity Request body\n{ \u0026#34;dishId\u0026#34;: 3, \u0026#34;offeredPortions\u0026#34;: 25, \u0026#34;price\u0026#34;: 60.00 } Response 200 — updated TakeAwayOffer object.\nErrors\nStatus Cause 400 Invalid input 404 Offer or dish not found 409 New portions below already sold quantity PATCH /takeaway/offers/{id}/enable # Re-enables a disabled offer, making it visible and orderable again.\nCannot enable a sold out offer.\nExample Request # curl -X PATCH \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/takeaway/offers/1/enable Response 200 — updated TakeAwayOffer object with enabled: true.\nErrors\nStatus Cause 404 Offer not found 409 Offer is sold out and cannot be re-enabled PATCH /takeaway/offers/{id}/disable # Disables an active offer, hiding it from customers without deleting it.\nExample Request # curl -X PATCH \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/takeaway/offers/1/disable Response 200 — updated TakeAwayOffer object with enabled: false.\nDELETE /takeaway/offers/{id} # Permanently deletes an offer.\nOffers that have active or completed orders cannot be deleted.\nResponse 204 — no content.\nErrors\nStatus Cause 404 Offer not found 409 Offer has existing orders and cannot be deleted ","date":"9 April 2026","externalUrl":null,"permalink":"/docs/miseos-api-doc/take-away-offers/","section":"Docs","summary":"","title":"Takeaway Offers","type":"docs"},{"content":"This section documents the takeaway order endpoints of the MiseOS API.\nCustomers place orders against active takeaway offers. An order can contain multiple lines — one per dish. Portions are reserved immediately when an order is placed.\nManagement can mark orders as paid and view a daily sales summary. Customers can cancel their own orders within the 45-minute cancellation window.\nOrder lifecycle # stateDiagram-v2 [*] --\u003e RESERVED : order placed RESERVED --\u003e PAID : marked as paid by management RESERVED --\u003e CANCELLED : cancelled by customer (within 45 min) or management PAID --\u003e [*] CANCELLED --\u003e [*] : portions returned to offer When an order is cancelled, all reserved portions are returned to the offer. Paid orders cannot be cancelled.\nTakeaway Order Endpoints # Method URL Auth GET /takeaway/orders CUSTOMER, KITCHEN_STAFF GET /takeaway/orders/{id} CUSTOMER, KITCHEN_STAFF POST /takeaway/orders CUSTOMER PATCH /takeaway/orders/{id}/pay HEAD_CHEF, SOUS_CHEF PATCH /takeaway/orders/{id}/cancel CUSTOMER, HEAD_CHEF, SOUS_CHEF GET /takeaway/orders/summary HEAD_CHEF, SOUS_CHEF Role-based filtering (GET /takeaway/orders)\nCustomers only see their own orders Head chefs and sous chefs can filter by customerId to see any customer\u0026rsquo;s orders TakeAwayOrder response object # The take away order object contains the following fields:\n{ \u0026#34;id\u0026#34;: 1, \u0026#34;totalOrderLines\u0026#34;: 2, \u0026#34;totalQuantity\u0026#34;: 3, \u0026#34;customer\u0026#34;: { \u0026#34;id\u0026#34;: 5, \u0026#34;firstName\u0026#34;: \u0026#34;Hans\u0026#34;, \u0026#34;lastName\u0026#34;: \u0026#34;Hansen\u0026#34; }, \u0026#34;orderLines\u0026#34;: [ { \u0026#34;id\u0026#34;: 1, \u0026#34;offer\u0026#34;: { \u0026#34;id\u0026#34;: 1, \u0026#34;dishName\u0026#34;: \u0026#34;Boller i karry\u0026#34;, \u0026#34;price\u0026#34;: 65.00 }, \u0026#34;quantity\u0026#34;: 2, \u0026#34;lineTotal\u0026#34;: 130.00 }, { \u0026#34;id\u0026#34;: 2, \u0026#34;offer\u0026#34;: { \u0026#34;id\u0026#34;: 3, \u0026#34;dishName\u0026#34;: \u0026#34;Surdejsbrød\u0026#34;, \u0026#34;price\u0026#34;: 35.00 }, \u0026#34;quantity\u0026#34;: 1, \u0026#34;lineTotal\u0026#34;: 35.00 } ], \u0026#34;totalOrderPrice\u0026#34;: 165.00, \u0026#34;orderStatus\u0026#34;: \u0026#34;RESERVED\u0026#34;, \u0026#34;orderedAt\u0026#34;: \u0026#34;2026-04-09 13:05\u0026#34;, \u0026#34;createdAt\u0026#34;: \u0026#34;2026-04-09\u0026#34; } Status values: RESERVED | PAID | CANCELLED\nTakeAwaySummary response object # Returned by GET /takeaway/orders/summary.\n{ \u0026#34;date\u0026#34;: \u0026#34;2026-04-09\u0026#34;, \u0026#34;totalOfferedPortions\u0026#34;: 55, \u0026#34;totalSoldPortions\u0026#34;: 32, \u0026#34;totalRemainingPortions\u0026#34;: 23, \u0026#34;totalOrders\u0026#34;: 18, \u0026#34;summaryPerOffer\u0026#34;: [ { \u0026#34;offerId\u0026#34;: 1, \u0026#34;dish\u0026#34;: { \u0026#34;id\u0026#34;: 3, \u0026#34;nameDA\u0026#34;: \u0026#34;Boller i karry\u0026#34; }, \u0026#34;offeredPortions\u0026#34;: 20, \u0026#34;soldPortions\u0026#34;: 14, \u0026#34;remainingPortions\u0026#34;: 6, \u0026#34;revenue\u0026#34;: 910.00 }, { \u0026#34;offerId\u0026#34;: 2, \u0026#34;dish\u0026#34;: { \u0026#34;id\u0026#34;: 7, \u0026#34;nameDA\u0026#34;: \u0026#34;Stegt flæsk\u0026#34; }, \u0026#34;offeredPortions\u0026#34;: 35, \u0026#34;soldPortions\u0026#34;: 18, \u0026#34;remainingPortions\u0026#34;: 17, \u0026#34;revenue\u0026#34;: 1170.00 } ] } GET /takeaway/orders # Returns orders with optional filters. Customers only see their own orders. Management can filter by customer.\nQuery parameters\nParameter Type Description customerId Long Filter by customer — management only offerId Long Filter by offer date LocalDate Filter by order date (yyyy-MM-dd) status String Filter by status: RESERVED, PAID, CANCELLED Example Request # curl -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ \u0026#34;https://miseos.corral.dk/api/v1/takeaway/orders?date=2026-04-09\u0026amp;status=RESERVED\u0026#34; Response 200 — array of TakeAwayOrder objects.\nGET /takeaway/orders/{id} # Returns a single order by ID.\nCustomers can only view their own orders. Management can view any order.\nExample Request # curl -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/takeaway/orders/1 Response 200 — TakeAwayOrder object.\nErrors\nStatus Cause 400 Invalid ID 403 Attempting to view another customer\u0026rsquo;s order 404 Order not found POST /takeaway/orders # Places a new order. An order can contain multiple lines — one per offer. Portions are reduced from the offer immediately.\nRequest body\n{ \u0026#34;takeAwayOrderLines\u0026#34;: [ { \u0026#34;offerId\u0026#34;: 1, \u0026#34;quantity\u0026#34;: 2 }, { \u0026#34;offerId\u0026#34;: 3, \u0026#34;quantity\u0026#34;: 1 } ] } Example Request # curl -X POST \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{ \u0026#34;takeAwayOrderLines\u0026#34;: [ { \u0026#34;offerId\u0026#34;: 1, \u0026#34;quantity\u0026#34;: 2 }, { \u0026#34;offerId\u0026#34;: 3, \u0026#34;quantity\u0026#34;: 1 } ] }\u0026#39; \\ https://miseos.corral.dk/api/v1/takeaway/orders Response 201 — created TakeAwayOrder object with status RESERVED.\nErrors\nStatus Cause 400 Empty order lines or invalid quantity 404 Offer not found 409 Offer is disabled or sold out 409 Not enough portions remaining PATCH /takeaway/orders/{id}/pay # Marks an order as paid. Only head chefs and sous chefs can perform this action.\nCannot mark a cancelled order as paid.\nExample Request # curl -X PATCH \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/takeaway/orders/1/pay Response 200 — updated TakeAwayOrder object with status PAID.\nErrors\nStatus Cause 403 Caller is not head chef or sous chef 404 Order not found 409 Order is already cancelled PATCH /takeaway/orders/{id}/cancel # Cancels an order and returns portions to the offer.\nCustomers can only cancel within 45 minutes of placing the order Head chefs and sous chefs can cancel any time before payment Paid orders cannot be cancelled Example Request # curl -X PATCH \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/takeaway/orders/1/cancel Response 200 — updated TakeAwayOrder object with status CANCELLED.\nErrors\nStatus Cause 403 Customer attempting to cancel another customer\u0026rsquo;s order 403 Cancellation window (45 min) has expired 404 Order not found 409 Order is already paid and cannot be cancelled GET /takeaway/orders/summary # Returns a sales summary for a given date. Defaults to today if no date is provided.\nQuery parameters\nParameter Type Description date LocalDate Summary date (yyyy-MM-dd) — defaults to today Example Request # curl -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ \u0026#34;https://miseos.corral.dk/api/v1/takeaway/orders/summary?date=2026-04-09\u0026#34; Response 200 — TakeAwaySummary object.\n","date":"9 April 2026","externalUrl":null,"permalink":"/docs/miseos-api-doc/take-away-orders/","section":"Docs","summary":"","title":"Takeaway Orders","type":"docs"},{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/tags/api/","section":"Tags","summary":"","title":"Api","type":"tags"},{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/tags/documentation/","section":"Tags","summary":"","title":"Documentation","type":"tags"},{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/tags/miseos/","section":"Tags","summary":"","title":"MiseOS","type":"tags"},{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/series/miseos-development/","section":"Series","summary":"","title":"MiseOS Development","type":"series"},{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/posts/","section":"Posts","summary":"","title":"Posts","type":"posts"},{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/categories/project-log/","section":"Categories","summary":"","title":"Project Log","type":"categories"},{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/series/","section":"Series","summary":"","title":"Series","type":"series"},{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"This week I focused on API documentation for the project.\nI did not build a big new feature.\nInstead, I documented what I already built — and that gave me a much better overview of the system.\nWhat I worked on this week # I went through the API resource by resource and updated the documentation so it matches the actual codebase.\nI documented and aligned:\nAuthentication Users Stations Allergens Dish Suggestions Dishes Weekly Menus Ingredient Requests Shopping Lists Notifications Menu Inspirations I used the same structure in each doc:\nshort intro/context endpoint overview table request/response examples auth roles common error cases How I documented it (tools) # I wrote the documentation in Markdown inside my Hugo portfolio, using one index page and one page per API resource.\nTools used this week:\nMarkdown + Hugo for publishing and navigation IntelliJ HTTP Client for endpoint verification while documenting Mermaid diagrams for workflow/state visuals Live code cross-checking directly in the Java codebase (controllers, services, DTOs, mappers) The goal was to produce manually curated docs aligned tightly with the implemented behavior.\nHow I structured the documentation # I created one API index page and separate pages for each resource.\nThe index page works like a table of contents, so it is easy to navigate the docs quickly.\nFrom there, each resource has its own focused page with endpoints and examples.\nThis structure helped me keep things clean:\none place to get overview (index) one place per domain/resource (detail pages) It also makes it easier to maintain later, because changes are isolated to the relevant resource page.\nWhy this was useful (concrete aha) # Writing documentation was not just “text work”. It forced me to walk through the system as if I were a new developer consuming the API.\nIt helped me:\nfind small non-critical issues catch unclear endpoint naming spot inconsistencies in a few response examples rethink parts of the API structure The biggest aha came from the Weekly Menu response structure.\nWhile documenting examples, I noticed menu slots did not always appear in a stable Monday → Friday order.\nThe reason was that slots came from an unordered collection returned by the persistence layer, so response order could vary.\nThat pushed me to make ordering explicit in the mapper:\nsort first by day of week (MONDAY ... FRIDAY) then by station id for deterministic ordering within the same day This made the API output stable and predictable for frontend use and documentation examples.\nExample from the mapper # List\u0026lt;WeeklyMenuSlotDTO\u0026gt; slots = menu.getWeeklyMenuSlots() .stream() .sorted(Comparator .comparingInt((WeeklyMenuSlot slot) -\u0026gt; sortByDayOrder(slot.getDayOfWeek().name())) .thenComparingLong(slot -\u0026gt; slot.getStation().getId())) .map(WeeklyMenuMapper::toSlotDTO) .collect(Collectors.toList()); And the day-order function:\nprivate static int sortByDayOrder(String day) { return switch (day) { case \u0026#34;MONDAY\u0026#34; -\u0026gt; 1; case \u0026#34;TUESDAY\u0026#34; -\u0026gt; 2; case \u0026#34;WEDNESDAY\u0026#34; -\u0026gt; 3; case \u0026#34;THURSDAY\u0026#34; -\u0026gt; 4; case \u0026#34;FRIDAY\u0026#34; -\u0026gt; 5; default -\u0026gt; 99; }; } In other words: writing docs exposed a real API usability issue, and fixing it improved both developer experience and UI reliability.\nDiagrams helped more than expected # I added lifecycle/workflow diagrams in places where state matters (for example shopping lists and menu flow).\nThat helped both:\nreaders, because process and transitions are easier to understand quickly myself, because drawing the flow made it obvious, if the API matched, what I actually wanted If the diagram felt confusing, it usually meant the API behavior needed clarification too.\nHighlights from this week # Weekly Menus # I clarified:\ndraft vs published behavior role-based visibility (ANYONE vs management roles) slot operations publish constraints Notifications # I aligned docs with real-time behavior:\nWebSocket endpoint/auth expectations broadcast vs user-targeted notifications snapshot endpoint for badge/bootstrap use cases no offline queue (fire-and-forget) Menu Inspirations # I documented both modes:\nGET /menu-inspirations/daily SSE /menu-inspirations/stream Including event types:\nstatus dish done error Shopping Lists # I documented end-to-end flow:\ngeneration from approved requests AI normalization + fallback behavior draft/finalized constraints item operations + ordering endpoints finalize precondition (all ordered) What was difficult # The hardest part was choosing the right level of detail.\nIf docs are too short, they are not useful.\nIf they are too long, they are hard to read.\nI tried to keep a practical middle ground for a school project: clear enough for someone to use and test the API, but still concise.\nWhat I learned # Good docs require checking code, not memory. A consistent template saves time and improves quality. Index + resource pages is a good structure for medium-sized APIs. Diagrams are very useful for state-based endpoints. Documentation can reveal real implementation issues early. Final reflection: Week 10 and backend wrap-up # This week became more than documentation work.\nIt also worked as a final backend quality check before the project hand-in.\nBy documenting every endpoint and flow, I could confirm that the core backend is now in a stable delivery state:\nauthentication and role-based authorization menu and suggestion workflows ingredient and shopping list lifecycle real-time notifications deployment and API availability Looking back at the backend as a whole, one of the most important design choices was keeping the system layered and maintaining a clear separation of concerns. Controllers focus on HTTP translation, services contain business logic, and the DAO layer isolates persistence through JPA/Hibernate. This structure made it easier to expand the system while keeping individual components focused.\nAnother lesson was the value of placing business rules inside the domain model. Operations such as approving suggestions or finalizing shopping lists are implemented directly on the entities, which helps keep the rules consistent and easier to reason about.\nOverall, documenting the API forced me to revisit every part of the system from the perspective of someone consuming the backend. That process helped confirm that the architecture and workflows are coherent and ready to support further development.\nFull API documentation # You can browse the full API documentation here:\nhttps://corral.dk/docs/miseos-api-doc/\nNext step # The next phase is frontend work in React.\nI will use the documented API as the contract while building:\nrole-based UI flows menu planning views shopping list workflows live updates through WebSocket/SSE The goal is to keep the same discipline from this week: build, validate, document, and keep everything aligned.\nThis is part 10 of my MiseOS development log. Follow along as I build a tool for professional kitchens, one commit at a time.\n","date":"3 April 2026","externalUrl":null,"permalink":"/posts/api-doc/","section":"Posts","summary":"","title":"Writing the Map While Building the City: A Week on MiseOS API Documentation","type":"posts"},{"content":"","date":"30 March 2026","externalUrl":null,"permalink":"/tags/caddy/","section":"Tags","summary":"","title":"Caddy","type":"tags"},{"content":"","date":"30 March 2026","externalUrl":null,"permalink":"/tags/ci/cd/","section":"Tags","summary":"","title":"Ci/Cd","type":"tags"},{"content":"","date":"30 March 2026","externalUrl":null,"permalink":"/tags/devops/","section":"Tags","summary":"","title":"Devops","type":"tags"},{"content":"","date":"30 March 2026","externalUrl":null,"permalink":"/tags/digitalocean/","section":"Tags","summary":"","title":"Digitalocean","type":"tags"},{"content":"","date":"30 March 2026","externalUrl":null,"permalink":"/tags/docker/","section":"Tags","summary":"","title":"Docker","type":"tags"},{"content":"For most of this semester MiseOS lived on localhost. That was fine for development, but at some point every backend hits the same wall — it needs to run somewhere real. This week was about crossing that wall.\nThe goal was a pipeline where merging to main automatically tests, builds, and deploys the application without touching the server manually. By the end of the week that was working.\nAnd honestly: I didn’t expect the GitHub Actions spinner to become this emotional.\nYellow while it builds and tests. Green when it works (big smile). Red when it fails (“oh no…”).\nIt’s such a small UI detail, but it makes the project feel alive.\nWhat I Wanted to Build # The simplest production setup that still teaches real DevOps concepts:\nGitHub Actions — run tests and build the image on every push Docker Hub — store the built image as an artifact DigitalOcean droplet — the server that runs everything Docker Compose — manage multiple services on the same machine Watchtower — watch for new images and restart the container automatically Caddy — reverse proxy with automatic HTTPS No Kubernetes, no cloud-native overengineering. Just enough to understand the full deploy lifecycle from code push to running container.\nThe CI/CD Pipeline # The workflow splits into two jobs: test and deploy. The deploy job only runs when the test job passes and the push is to main. Pull requests run tests only — they never deploy.\nname: MiseOS CI/CD on: push: branches: [ main ] pull_request: branches: [ main ] jobs: test: name: Build and Test runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Set up JDK 17 uses: actions/setup-java@v4 with: java-version: \u0026#39;17\u0026#39; distribution: \u0026#39;temurin\u0026#39; cache: \u0026#39;maven\u0026#39; - name: Run tests env: DEEPL_APIKEY: ${{ secrets.DEEPL_APIKEY }} GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} SECRET_KEY: ${{ secrets.SECRET_KEY }} DB_NAME: ${{ secrets.DB_NAME }} ISSUER: ${{ secrets.ISSUER }} TOKEN_EXPIRE_TIME: ${{ secrets.TOKEN_EXPIRE_TIME }} run: mvn --batch-mode test deploy: name: Build and Push Docker Image runs-on: ubuntu-latest needs: test if: github.ref == \u0026#39;refs/heads/main\u0026#39; \u0026amp;\u0026amp; github.event_name == \u0026#39;push\u0026#39; steps: - name: Checkout uses: actions/checkout@v4 - name: Set up JDK 17 uses: actions/setup-java@v4 with: java-version: \u0026#39;17\u0026#39; distribution: \u0026#39;temurin\u0026#39; cache: \u0026#39;maven\u0026#39; - name: Build with Maven run: mvn --batch-mode --update-snapshots package -DskipTests - name: Login to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build and push Docker image uses: docker/build-push-action@v5 with: context: . file: ./Dockerfile push: true tags: ${{ secrets.DOCKERHUB_USERNAME }}/mise-os:latest - name: Trigger Watchtower Webhook run: | curl -f -H \u0026#34;Authorization: Bearer ${{ secrets.WATCHTOWER_TOKEN }}\u0026#34; https://deploy.corral.dk/v1/update The test job runs with real secrets because my integration tests hit the actual auth endpoints. Without them the JWT validation fails and half the tests would not run.\nThe deploy job skips tests with -DskipTests — they already ran in the previous job.\nDeployment flow overview # This is the actual release path used by MiseOS.\nA push to main runs CI, builds and publishes a Docker image, then triggers a webhook on the droplet.\nCaddy receives the HTTPS request and reverse-proxies it to the Watchtower container, which exposes the HTTP API endpoint. Watchtower then pulls the newest image from Docker Hub and restarts the miseOS container.\nflowchart TD subgraph GHA[\"GitHub Actions\"] Push([Push to main]) --\u003e CI{CI pipeline} CI --\u003e Tests[Run tests] Tests -- Fail --\u003e Stop[Stop pipeline] Tests -- Pass --\u003e Build[Build Docker image] Build --\u003e Publish[Push image] Publish --\u003e Webhook[POST webhook trigger] end Hub[(Docker HubmiseOS:latest)] subgraph DO[\"DigitalOcean Droplet\"] Caddy[Caddy reverse proxy] WT[Watchtower] API[MiseOS container] DB[(Postgres)] Caddy --\u003e WT WT --\u003e|Restart miseOS| API API --\u003e|SQL| DB end Publish --\u003e Hub Webhook --\u003e Caddy Hub --\u003e|Pull latest image| WT style CI fill:#f4b8ff,stroke:#333,stroke-width:2px,color:#000 style Tests fill:#fff6b3,stroke:#333,color:#000 style Stop fill:#ffb3b3,stroke:#333,color:#000 style Build fill:#b8ffb8,stroke:#333,color:#000 style Publish fill:#b8ffb8,stroke:#333,color:#000 style Caddy fill:#ffffff,stroke:#333,color:#000 style WT fill:#ffffff,stroke:#333,color:#000 style API fill:#eeeeee,stroke:#333,color:#000 style GHA fill:transparent,stroke:#666,stroke-dasharray: 5 5 style DO fill:transparent,stroke:#666,stroke-dasharray: 5 5 The Dockerfile # The image is intentionally minimal:\nFROM amazoncorretto:17-alpine RUN apk update \u0026amp;\u0026amp; apk add --no-cache curl COPY target/app.jar /app.jar EXPOSE 7070 CMD [\u0026#34;java\u0026#34;, \u0026#34;-jar\u0026#34;, \u0026#34;/app.jar\u0026#34;] Alpine base keeps the image small. curl is installed because the health check needs it. Nothing else — no build tools, no source code, just the compiled jar and the JVM.\nAll runtime configuration comes from environment variables. The same image runs locally, in CI, and in production — the environment is what changes, not the image.\nThe Droplet Setup # On the server, Docker Compose manages all services. MiseOS sits alongside Postgres, Caddy, Watchtower, and the portfolio site.\nmiseOS: image: mortenjenne/mise-os:latest container_name: miseOS ports: - \u0026#34;7072:7070\u0026#34; environment: - DEPLOYED=${DEPLOYED} - DB_NAME=${DB_NAME} - DB_USERNAME=${DB_USERNAME} - DB_PASSWORD=${DB_PASSWORD} - CONNECTION_STR=${CONNECTION_STR} - SECRET_KEY=${SECRET_KEY} - ISSUER=${ISSUER} - TOKEN_EXPIRE_TIME=${TOKEN_EXPIRE_TIME} - DEEPL_APIKEY=${DEEPL_APIKEY} - GEMINI_API_KEY=${GEMINI_API_KEY} networks: - backend - frontend volumes: - ./logs:/logs healthcheck: test: [\u0026#34;CMD\u0026#34;, \u0026#34;curl\u0026#34;, \u0026#34;-f\u0026#34;, \u0026#34;http://127.0.0.1:7070/api/v1/auth/health\u0026#34;] interval: 10s timeout: 5s retries: 5 start_period: 15s The health check endpoint is a simple GET /api/v1/auth/health that returns 200. Compose uses it to know when the container is actually ready — Caddy and Watchtower both depend on it. Without this, services would race to start and Caddy might try to route traffic before the API was listening.\nCaddy as Reverse Proxy # Caddy handles TLS and domain routing. The configuration is simple — each subdomain proxies to the relevant container by name:\nmiseos.corral.dk { reverse_proxy miseOS:7070 } deploy.corral.dk { reverse_proxy watchtower:8080 } The deploy.corral.dk subdomain is how GitHub Actions reaches Watchtower — it sends a POST to that address with a bearer token to trigger the image pull. Caddy terminates TLS so the webhook arrives over HTTPS.\nCaddy provisions and renews certificates automatically via Let\u0026rsquo;s Encrypt. That used to require a lot of configuration — here it requires none.\nWatchtower for Automatic Updates # After Docker Hub push, the workflow triggers:\ncurl -f \\ -H \u0026#34;Authorization: Bearer ${{ secrets.WATCHTOWER_TOKEN }}\u0026#34; \\ https://deploy.corral.dk/v1/update Watchtower pulls the latest image for the miseOS container and restarts it. The update typically completes in under 30 seconds. Old images are cleaned up automatically with WATCHTOWER_CLEANUP=true.\nwatchtower: image: containrrr/watchtower:latest container_name: watchtower restart: unless-stopped volumes: - /var/run/docker.sock:/var/run/docker.sock environment: - WATCHTOWER_HTTP_API_UPDATE=true - WATCHTOWER_HTTP_API_TOKEN=${WATCHTOWER_TOKEN} - WATCHTOWER_CLEANUP=true networks: - frontend command: miseOS portfolio-site What Was Difficult # The hardest part this week was configuration, not code.\nIn local development I used a config.properties file (gitignored) with values like:\nDB name JWT issuer token expiration external API URLs That worked locally, but GitHub Actions cannot access gitignored files.\nSo in CI the app started without expected config, and auth-related routes failed in ways that initially looked like logic bugs.\nI tried a few quick fixes first, but the proper solution was to refactor configuration loading to environment variables (System.getenv()), so the same mechanism works in CI, Docker, and production.\nI also simplified config ownership:\nSecrets and deployment-specific values come from environment variables\n(SECRET_KEY, ISSUER, TOKEN_EXPIRE_TIME, API keys, DB credentials) Stable integration base URLs were moved into ApiConfig as private static constants That made startup deterministic and removed dependency on local files during deployment.\nTo avoid silent misconfiguration, I added fail-fast validation in security startup:\nif (secretKey == null || secretKey.isBlank()) throw new IllegalStateException(\u0026#34;JWT secret key must be configured\u0026#34;); if (secretKey.getBytes().length \u0026lt; 32) throw new IllegalStateException(\u0026#34;JWT secret key too short. Use at least 32 bytes\u0026#34;); Failing fast at startup is far better than failing mysteriously during a user login.\nCI success does not mean production success. Tests passing in GitHub Actions confirms the code is correct. It does not confirm the runtime environment on the server is correctly configured. Those are two separate concerns and they fail in different ways.\nWhat Worked Well # Splitting the workflow into test and deploy jobs made the pipeline easy to reason about. If the test job fails, nothing deploys — there is no ambiguity about what ran and what did not.\nIt was easy to debug if something did go wrong. The logs from each job are available in GitHub Actions, and the application logs are persisted to a mounted volume on the server. This way i could quickly identify and fix the missing SECRET_KEY issue after the first deployment.\nHealth checks on the container meant dependent services would not start until the API was actually ready. Before adding them, Caddy occasionally tried to proxy traffic before the JVM had finished starting.\nLogging to a mounted volume means logs persist across container restarts. That turned out to be immediately useful — after the first deployment the application log showed a startup warning about a misconfigured environment variable that would have been invisible without it.\nWhat I’d Explore Next # One thing I still want to understand better is long-term configuration strategy.\nThis week I moved critical runtime values to environment variables so CI/CD and deployment would work reliably. That solved the immediate problem.\nAs the project or in future projects grows, I want to explore where the right boundary should be between:\nenvironment variables for deployment-specific values, config.properties for application configuration, and code-level constants for stable business rules. For now the system works well, but configuration strategy becomes more important as a codebase grows. This is something I want to refine intentionally rather than just letting it evolve accidentally.\nNext Steps # With deployment automated, the next focus shifts from infrastructure back to features. The React frontend is the missing piece — the backend is fully live at miseos.corral.dk but there is no UI yet. The priority flows are the line cook request workflow and the head chef approval dashboard.\nThis is part 9 of my MiseOS development log. Follow along as I build a tool for professional kitchens, one commit at a time.\n","date":"30 March 2026","externalUrl":null,"permalink":"/posts/ci-cd/","section":"Posts","summary":"","title":"From Localhost to Live: Deploying MiseOS with CI/CD","type":"posts"},{"content":"","date":"30 March 2026","externalUrl":null,"permalink":"/tags/github-actions/","section":"Tags","summary":"","title":"Github-Actions","type":"tags"},{"content":"","date":"30 March 2026","externalUrl":null,"permalink":"/tags/project/","section":"Tags","summary":"","title":"Project","type":"tags"},{"content":"","date":"30 March 2026","externalUrl":null,"permalink":"/tags/watchtower/","section":"Tags","summary":"","title":"Watchtower","type":"tags"},{"content":"This section documents the allergen management endpoints of the MiseOS API, which allow for retrieving, creating, updating, and deleting allergens.\nThe MiseOS platform maintains a standardized list of food allergens used when creating dishes and dish suggestions.\nThe system supports the 14 EU allergen categories, which must be declared in professional food service environments. These allergens can be associated with dishes to ensure proper dietary and regulatory information is available.\nAllergens can be added in two ways:\nUsing the /allergens/seed endpoint to populate the database with the 14 predefined EU allergens Creating allergens manually through the API for custom or organization-specific use cases This allows kitchens to either rely on the standard EU allergen list or extend it with additional allergens if required.\nAllergens Endpoints # Method URL Auth GET /allergens KITCHEN_STAFF GET /allergens/{id} KITCHEN_STAFF GET /allergens/search/{query} KITCHEN_STAFF POST /allergens HEAD_CHEF POST /allergens/seed HEAD_CHEF PUT /allergens/{id} HEAD_CHEF DELETE /allergens/{id} HEAD_CHEF Allergen response object # The allergen object has the following structure:\n{ \u0026#34;id\u0026#34;: 1, \u0026#34;nameDA\u0026#34;: \u0026#34;Gluten\u0026#34;, \u0026#34;nameEN\u0026#34;: \u0026#34;Gluten\u0026#34;, \u0026#34;descriptionDA\u0026#34;: \u0026#34;Indeholder hvede, rug eller byg\u0026#34;, \u0026#34;descriptionEN\u0026#34;: \u0026#34;Contains wheat, rye or barley\u0026#34;, \u0026#34;displayNumber\u0026#34;: 1 } GET /allergens # Returns all 14 EU allergens sorted by display number.\nExample Request # curl -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/allergens Response 200 — array of allergen objects.\n[ { \u0026#34;id\u0026#34;: 1, \u0026#34;nameDA\u0026#34;: \u0026#34;Gluten\u0026#34;, \u0026#34;nameEN\u0026#34;: \u0026#34;Gluten\u0026#34;, \u0026#34;descriptionDA\u0026#34;: \u0026#34;Indeholder hvede, rug eller byg\u0026#34;, \u0026#34;descriptionEN\u0026#34;: \u0026#34;Contains wheat, rye or barley\u0026#34;, \u0026#34;displayNumber\u0026#34;: 1 }, { \u0026#34;id\u0026#34;: 2, \u0026#34;nameDA\u0026#34;: \u0026#34;Skaldyr\u0026#34;, \u0026#34;nameEN\u0026#34;: \u0026#34;Crustaceans\u0026#34;, \u0026#34;descriptionDA\u0026#34;: \u0026#34;Indeholder krebsdyr som rejer, krabber eller hummere\u0026#34;, \u0026#34;descriptionEN\u0026#34;: \u0026#34;Contains crustaceans such as shrimp, crab or lobster\u0026#34;, \u0026#34;displayNumber\u0026#34;: 2 }, ... ] GET /allergens/{id} # Returns an allergen object by ID.\nExample Request # curl -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/allergens/1 Response 200 — allergen object.\nErrors\nStatus Cause 404 Allergen not found GET /allergens/search/{query} # Searches allergens by name in both Danish and English. Returns partial matches.\nPath parameters\nParameter Type Description query String Search term Example Request # curl -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/allergens/search/gluten Response 200 — array of matching allergen objects. Empty array if none found.\nPOST /allergens # Creates a new allergen.\nRequest body\n{ \u0026#34;nameDA\u0026#34;: \u0026#34;Sennep\u0026#34;, \u0026#34;nameEN\u0026#34;: \u0026#34;Mustard\u0026#34;, \u0026#34;descriptionDA\u0026#34;: \u0026#34;Indeholder sennepsfrø\u0026#34;, \u0026#34;descriptionEN\u0026#34;: \u0026#34;Contains mustard seeds\u0026#34;, \u0026#34;displayNumber\u0026#34;: 10 } Response 201 — created allergen object.\nErrors\nStatus Cause 409 Name or display number already in use POST /allergens/seed # Seeds the database with the 14 standard EU allergens used in food labeling regulations.\nThis endpoint is intended for initial system setup and should only be called once on an empty database.\nThe allergens are predefined in the application and include both Danish and English names and descriptions.\nResponse 201 — array of all seeded allergen objects.\nErrors\nStatus Cause 400 Allergens already seeded PUT /allergens/{id} # Updates an allergen. Only checks uniqueness if the name or display number has changed.\nRequest body — same shape as create.\nResponse 200 — updated allergen object.\nDELETE /allergens/{id} # Deletes an allergen.\nDeletion is blocked if the allergen is currently referenced by any dish or dish suggestion.\nResponse 204 — no content.\nErrors\nStatus Cause 400 Allergen is in use by one or more dishes 404 Allergen not found ","date":"27 March 2026","externalUrl":null,"permalink":"/docs/miseos-api-doc/allergens/","section":"Docs","summary":"","title":"Allergens","type":"docs"},{"content":"This section documents the authentication endpoints of the MiseOS API, which allow users to register and log in to receive a JWT token for authenticated requests.\nClients must first authenticate using the /auth/login endpoint to receive a token.\nThis token must then be included in the Authorization header for all protected endpoints.\nJWT tokens expire after 30 minutes and must be refreshed by logging in again.\nAuthentication Endpoints # Method URL Auth POST /auth/register ANYONE POST /auth/login ANYONE POST /auth/register # Registers a new user and returns their details. New users registering through /auth/register are assigned the role CUSTOMER by default.\nKitchen roles such as LINE_COOK, SOUS_CHEF, and HEAD_CHEF must be assigned by a head chef.\nRequest body\n{ \u0026#34;firstName\u0026#34;: \u0026#34;Dominique\u0026#34;, \u0026#34;lastName\u0026#34;: \u0026#34;Crenn\u0026#34;, \u0026#34;email\u0026#34;: \u0026#34;Crenn@ateliercrenn.com\u0026#34;, \u0026#34;password\u0026#34;: \u0026#34;Password123\u0026#34; } Response 201\n{ \u0026#34;id\u0026#34;: 2, \u0026#34;firstName\u0026#34;: \u0026#34;Dominique\u0026#34;, \u0026#34;lastName\u0026#34;: \u0026#34;Crenn\u0026#34;, \u0026#34;email\u0026#34;: \u0026#34;Crenn@ateliercrenn.com\u0026#34;, \u0026#34;userRole\u0026#34;: \u0026#34;CUSTOMER\u0026#34; } Errors\nStatus Cause 400 Invalid input — password too short, invalid email format 409 Email already registered POST /auth/login # Returns a JWT token for use in subsequent requests.\nRequest body\n{ \u0026#34;email\u0026#34;: \u0026#34;gordon@kitchen.com\u0026#34;, \u0026#34;password\u0026#34;: \u0026#34;Password123\u0026#34; } Response 200\n{ \u0026#34;token\u0026#34;: \u0026#34;eyJhbGci...\u0026#34;, \u0026#34;email\u0026#34;: \u0026#34;gordon@kitchen.com\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;HEAD_CHEF\u0026#34; } Errors\nStatus Cause 400 Missing email or password 401 Invalid credentials ","date":"27 March 2026","externalUrl":null,"permalink":"/docs/miseos-api-doc/authentication/","section":"Docs","summary":"","title":"Authentication","type":"docs"},{"content":"This section documents the dish suggestion endpoints of the MiseOS API.\nDish suggestions allow kitchen staff to propose new dishes for upcoming menu weeks. Each suggestion is associated with a station, a target week, and a set of allergens.\nSuggestions are reviewed by head chefs or sous chefs who can either approve or reject them. Approved suggestions are automatically converted into dishes in the dish bank, while rejected suggestions must include feedback for the creator.\nDish suggestion lifecycle # Kitchen staff can submit dish suggestions for upcoming weeks.\nstateDiagram-v2 [*] --\u003e PENDING : suggestion created PENDING --\u003e APPROVED : approve PENDING --\u003e REJECTED : reject APPROVED --\u003e DISH : dish created Dish suggestions are created with the POST /dish-suggestions endpoint and initially have the status PENDING.\nEndpoints # Method URL Auth GET /dish-suggestions KITCHEN_STAFF GET /dish-suggestions/current-week HEAD_CHEF, SOUS_CHEF GET /dish-suggestions/{id} KITCHEN_STAFF POST /dish-suggestions KITCHEN_STAFF PUT /dish-suggestions/{id} KITCHEN_STAFF DELETE /dish-suggestions/{id} KITCHEN_STAFF DELETE /dish-suggestions/{id}/allergens/{allergenId} KITCHEN_STAFF PATCH /dish-suggestions/{id}/approve HEAD_CHEF, SOUS_CHEF PATCH /dish-suggestions/{id}/reject HEAD_CHEF, SOUS_CHEF DishSuggestion response object # The dish suggestion object has the following structure with approval status:\n{ \u0026#34;id\u0026#34;: 1, \u0026#34;nameDA\u0026#34;: \u0026#34;Røget Laks\u0026#34;, \u0026#34;descriptionDA\u0026#34;: \u0026#34;Laks med dild creme og rugbrødschips\u0026#34;, \u0026#34;dishStatus\u0026#34;: \u0026#34;APPROVED\u0026#34;, \u0026#34;feedback\u0026#34;: null, \u0026#34;station\u0026#34;: { \u0026#34;id\u0026#34;: 1, \u0026#34;name\u0026#34;: \u0026#34;Cold Kitchen\u0026#34; }, \u0026#34;createdBy\u0026#34;: { \u0026#34;id\u0026#34;: 2, \u0026#34;firstName\u0026#34;: \u0026#34;Claire\u0026#34;, \u0026#34;lastName\u0026#34;: \u0026#34;Smyth\u0026#34; }, \u0026#34;reviewedBy\u0026#34;: { \u0026#34;id\u0026#34;: 1, \u0026#34;firstName\u0026#34;: \u0026#34;Gordon\u0026#34;, \u0026#34;lastName\u0026#34;: \u0026#34;Ramsay\u0026#34; }, \u0026#34;reviewedAt\u0026#34;: \u0026#34;2026-03-27 11:00\u0026#34;, \u0026#34;allergens\u0026#34;: [ { \u0026#34;id\u0026#34;: 1, \u0026#34;nameDA\u0026#34;: \u0026#34;Gluten\u0026#34;, \u0026#34;nameEN\u0026#34;: \u0026#34;Gluten\u0026#34;, \u0026#34;displayNumber\u0026#34;: 1 }, { \u0026#34;id\u0026#34;: 4, \u0026#34;nameDA\u0026#34;: \u0026#34;Fisk\u0026#34;, \u0026#34;nameEN\u0026#34;: \u0026#34;Fish\u0026#34;, \u0026#34;displayNumber\u0026#34;: 4 }, { \u0026#34;id\u0026#34;: 7, \u0026#34;nameDA\u0026#34;: \u0026#34;Mælk\u0026#34;, \u0026#34;nameEN\u0026#34;: \u0026#34;Milk\u0026#34;, \u0026#34;displayNumber\u0026#34;: 7 } ], \u0026#34;targetWeek\u0026#34;: 14, \u0026#34;targetYear\u0026#34;: 2026, \u0026#34;deadline\u0026#34;: \u0026#34;2026-05-07\u0026#34;, \u0026#34;isPastDeadline\u0026#34;: false, \u0026#34;createdAt\u0026#34;: \u0026#34;2026-03-27 10:00\u0026#34;, \u0026#34;updatedAt\u0026#34;: null } deadline represents the submission deadline for the suggestion\u0026rsquo;s target week.\nisPastDeadline indicates whether the deadline has already passed at the time of the request.\nStatus values # Status Description PENDING Awaiting review by head chef or sous chef APPROVED Approved and converted into a dish REJECTED Rejected with feedback from management GET /dish-suggestions # Returns dish suggestions filtered by query parameters.\nAccess rules:\nKitchen staff only see suggestions they created. Head chefs and sous chefs can see suggestions from all stations. Query parameters\nParameter Type Description status String Filter by status: PENDING, APPROVED, REJECTED week Integer Target week — must be provided with year year Integer Target year — must be provided with week stationId Long Filter by station orderBy String Field used for sorting (status, station, createdAt) Example Request # curl -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/dish-suggestions?status=PENDING\u0026amp;week=14\u0026amp;year=2026\u0026amp;stationId=1\u0026amp;orderBy=createdAt Response 200 — array of dish suggestion objects.\n[ { \u0026#34;id\u0026#34;: 1, \u0026#34;nameDA\u0026#34;: \u0026#34;Røget Laks\u0026#34;, \u0026#34;descriptionDA\u0026#34;: \u0026#34;Laks med dild creme og rugbrødschips\u0026#34;, \u0026#34;dishStatus\u0026#34;: \u0026#34;PENDING\u0026#34;, \u0026#34;feedback\u0026#34;: null, \u0026#34;station\u0026#34;: { \u0026#34;id\u0026#34;: 1, \u0026#34;name\u0026#34;: \u0026#34;Cold Kitchen\u0026#34; }, \u0026#34;createdBy\u0026#34;: { \u0026#34;id\u0026#34;: 2, \u0026#34;firstName\u0026#34;: \u0026#34;Claire\u0026#34;, \u0026#34;lastName\u0026#34;: \u0026#34;Smyth\u0026#34; }, \u0026#34;reviewedBy\u0026#34;: null, \u0026#34;reviewedAt\u0026#34;: null, \u0026#34;allergens\u0026#34;: [ { \u0026#34;id\u0026#34;: 1, \u0026#34;nameDA\u0026#34;: \u0026#34;Gluten\u0026#34;, \u0026#34;nameEN\u0026#34;: \u0026#34;Gluten\u0026#34;, \u0026#34;displayNumber\u0026#34;: 1 }, { \u0026#34;id\u0026#34;: 4, \u0026#34;nameDA\u0026#34;: \u0026#34;Fisk\u0026#34;, \u0026#34;nameEN\u0026#34;: \u0026#34;Fish\u0026#34;, \u0026#34;displayNumber\u0026#34;: 4 }, { \u0026#34;id\u0026#34;: 7, \u0026#34;nameDA\u0026#34;: \u0026#34;Mælk\u0026#34;, \u0026#34;nameEN\u0026#34;: \u0026#34;Milk\u0026#34;, \u0026#34;displayNumber\u0026#34;: 7 } ], \u0026#34;targetWeek\u0026#34;: 14, \u0026#34;targetYear\u0026#34;: 2026, \u0026#34;deadline\u0026#34;: \u0026#34;2026-05-07\u0026#34;, \u0026#34;isPastDeadline\u0026#34;: false, \u0026#34;createdAt\u0026#34;: \u0026#34;2026-03-27 10:00\u0026#34;, \u0026#34;updatedAt\u0026#34;: null }, { \u0026#34;id\u0026#34;: 2, \u0026#34;nameDA\u0026#34;: \u0026#34;Grillet Kylling\u0026#34;, \u0026#34;descriptionDA\u0026#34;: \u0026#34;Kylling med BBQ sauce og coleslaw\u0026#34;, \u0026#34;dishStatus\u0026#34;: \u0026#34;PENDING\u0026#34;, \u0026#34;feedback\u0026#34;: null, \u0026#34;station\u0026#34;: { \u0026#34;id\u0026#34;: 1, \u0026#34;name\u0026#34;: \u0026#34;Hot Kitchen\u0026#34; }, \u0026#34;createdBy\u0026#34;: { \u0026#34;id\u0026#34;: 3, \u0026#34;firstName\u0026#34;: \u0026#34;Gino\u0026#34;, \u0026#34;lastName\u0026#34;: \u0026#34;D\u0026#39;Acampo\u0026#34; }, \u0026#34;reviewedBy\u0026#34;: null, \u0026#34;reviewedAt\u0026#34;: null, \u0026#34;allergens\u0026#34;: [ { \u0026#34;id\u0026#34;: 7, \u0026#34;nameDA\u0026#34;: \u0026#34;Mælk\u0026#34;, \u0026#34;nameEN\u0026#34;: \u0026#34;Milk\u0026#34;, \u0026#34;displayNumber\u0026#34;: 7 } ], \u0026#34;targetWeek\u0026#34;: 14, \u0026#34;targetYear\u0026#34;: 2026, \u0026#34;deadline\u0026#34;: \u0026#34;2026-05-07\u0026#34;, \u0026#34;isPastDeadline\u0026#34;: false, \u0026#34;createdAt\u0026#34;: \u0026#34;2026-03-28 14:30\u0026#34;, \u0026#34;updatedAt\u0026#34;: null }, ... ] Errors\nStatus Cause 400 week provided without year or vice versa GET /dish-suggestions/current-week # Returns all suggestions for the current ISO week.\nQuery parameters\nParameter Type Description status String Optional status filter Example Request # curl -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/dish-suggestions/current-week?status=PENDING Response 200 — array of dish suggestion objects.\nGET /dish-suggestions/{id} # Returns a single suggestion. Line cooks can only view their own suggestions.\nExample Request # curl -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/dish-suggestions/1 Response 200 — dish suggestion object.\nErrors\nStatus Cause 403 Line cook attempting to view another cook\u0026rsquo;s suggestion 404 Suggestion not found POST /dish-suggestions # Dish suggestions must be submitted before the weekly planning deadline.\nThe submission deadline is Wednesday of the week preceding the target week.\nAfter this deadline, new suggestions for that week cannot be created or modified.\nRequest body\n{ \u0026#34;nameDA\u0026#34;: \u0026#34;Grillet Torsk\u0026#34;, \u0026#34;descriptionDA\u0026#34;: \u0026#34;Saftig torsk med urter og citron\u0026#34;, \u0026#34;stationId\u0026#34;: 1, \u0026#34;allergenIds\u0026#34;: [1, 4], \u0026#34;targetWeek\u0026#34;: 20, \u0026#34;targetYear\u0026#34;: 2026 } Response 201 — created dish suggestion object.\nErrors\nStatus Cause 400 Missing required fields or invalid data 409 Deadline for the target week has passed PUT /dish-suggestions/{id} # Updates a suggestion. Only the creator or management can update. Only PENDING suggestions can be updated.\nRequest body\n{ \u0026#34;nameDA\u0026#34;: \u0026#34;Grillet Torsk med Citron\u0026#34;, \u0026#34;descriptionDA\u0026#34;: \u0026#34;Saftig torsk med friske urter, citron og kapers\u0026#34;, \u0026#34;allergenIds\u0026#34;: [1, 4] } Response 200 — updated dish suggestion object.\nErrors\nStatus Cause 400 Missing required fields or invalid data 403 Not the creator and not management 409 Suggestion is not PENDING DELETE /dish-suggestions/{id}/allergens/{allergenId} # Removes a single allergen from a suggestion. Only the creator or management can remove. Only PENDING suggestions can be modified.\nExample Request # curl -X DELETE \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/dish-suggestions/5/allergens/1 Response 200 — updated dish suggestion object.\nErrors\nStatus Cause 400 Invalid dish suggestion or allergen ID 403 Not the creator and not management 404 Suggestion or allergen not found DELETE /dish-suggestions/{id} # Deletes a suggestion. Only the creator, head chef, or sous chef can delete.\nExample Request # curl -X DELETE \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/dish-suggestions/1 Response 204 — no content.\nErrors\nStatus Cause 400 Invalid dish suggestion ID 403 Not the creator and not management 404 Suggestion not found PATCH /dish-suggestions/{id}/approve # Approves a suggestion. Automatically creates a dish in the dish bank. Triggers a WebSocket notification to the creator.\nExample Request # curl -X PATCH \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/dish-suggestions/1/approve Response 200 — updated dish suggestion object with status APPROVED.\nErrors\nStatus Cause 400 Invalid suggestion ID 404 Suggestion not found 409 Suggestion is not PENDING PATCH /dish-suggestions/{id}/reject # Rejects a suggestion. A feedback message explaining the rejection is required and will be sent to the creator of the suggestion. Triggers a WebSocket notification to the creator.\nExample Request # curl -X PATCH \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/dish-suggestions/1/reject Request body\n{ \u0026#34;feedback\u0026#34;: \u0026#34;Retten passer ikke til sommermenuen\u0026#34; } Response 200 — updated dish suggestion object with status REJECTED.\nErrors\nStatus Cause 400 Feedback is blank or too short 404 Suggestion not found 409 Suggestion is not PENDING ","date":"27 March 2026","externalUrl":null,"permalink":"/docs/miseos-api-doc/dish-suggestions/","section":"Docs","summary":"","title":"Dish Suggestions","type":"docs"},{"content":"This section documents the dish bank endpoints of the MiseOS API.\nThe dish bank contains all approved dishes that can be reused when planning weekly menus.\nDishes typically originate from approved dish suggestions, but head chefs and sous chefs can also create dishes manually when needed.\nEach dish stores:\nDanish and optional English names and descriptions associated allergens the originating week and year the kitchen station responsible for the dish Only active dishes can be used when building weekly menus or ingredient requests.\nDish lifecycle # flowchart LR Suggestion[\"Dish Suggestion\"] --\u003e|Approved| Dish[\"Dish created in dish bank\"] Dish --\u003e|Used in| Menu[\"Weekly Menu\"] Dish --\u003e|Deactivate| Inactive[\"Inactive Dish\"] Endpoints # Method URL Auth GET /dishes HEAD_CHEF, SOUS_CHEF GET /dishes/search KITCHEN_STAFF GET /dishes/available HEAD_CHEF, SOUS_CHEF GET /dishes/grouped HEAD_CHEF, SOUS_CHEF GET /dishes/{id} KITCHEN_STAFF POST /dishes HEAD_CHEF, SOUS_CHEF PUT /dishes/{id} HEAD_CHEF, SOUS_CHEF PATCH /dishes/{id}/activate HEAD_CHEF, SOUS_CHEF PATCH /dishes/{id}/deactivate HEAD_CHEF, SOUS_CHEF DELETE /dishes/{id} HEAD_CHEF, SOUS_CHEF Response objects # The dish API returns several response structures depending on the endpoint.\nDish response object # Represents a complete dish from the dish bank.\nUsed by: GET /dishes, GET /dishes/{id}, POST /dishes, PUT /dishes/{id}, PATCH /dishes/{id}/activate, PATCH /dishes/{id}/deactivate\n{ \u0026#34;id\u0026#34;: 1, \u0026#34;nameDA\u0026#34;: \u0026#34;Røget Laks\u0026#34;, \u0026#34;nameEN\u0026#34;: \u0026#34;Smoked Salmon\u0026#34;, \u0026#34;descriptionDA\u0026#34;: \u0026#34;Laks med dildcreme og rugbrødschips\u0026#34;, \u0026#34;descriptionEN\u0026#34;: \u0026#34;Salmon with dill cream and rye bread chips\u0026#34;, \u0026#34;station\u0026#34;: { \u0026#34;id\u0026#34;: 1, \u0026#34;name\u0026#34;: \u0026#34;Cold Kitchen\u0026#34; }, \u0026#34;createdBy\u0026#34;: { \u0026#34;id\u0026#34;: 1, \u0026#34;firstName\u0026#34;: \u0026#34;Gordon\u0026#34;, \u0026#34;lastName\u0026#34;: \u0026#34;Ramsay\u0026#34; }, \u0026#34;allergens\u0026#34;: [ { \u0026#34;id\u0026#34;: 1, \u0026#34;nameDA\u0026#34;: \u0026#34;Gluten\u0026#34;, \u0026#34;nameEN\u0026#34;: \u0026#34;Gluten\u0026#34;, \u0026#34;displayNumber\u0026#34;: 1 }, { \u0026#34;id\u0026#34;: 4, \u0026#34;nameDA\u0026#34;: \u0026#34;Fisk\u0026#34;, \u0026#34;nameEN\u0026#34;: \u0026#34;Fish\u0026#34;, \u0026#34;displayNumber\u0026#34;: 4 } ], \u0026#34;active\u0026#34;: true, \u0026#34;originWeek\u0026#34;: 7, \u0026#34;originYear\u0026#34;: 2026, \u0026#34;hasTranslations\u0026#34;: true, \u0026#34;createdAt\u0026#34;: \u0026#34;2026-02-15 12:00\u0026#34;, \u0026#34;updatedAt\u0026#34;: \u0026#34;2026-02-20 15:30\u0026#34; } DishOption response object # A lightweight representation used when selecting dishes for menus or browsing grouped lists.\nUsed by: GET /dishes/grouped, GET /dishes/available\n{ \u0026#34;dishId\u0026#34;: 1, \u0026#34;dishName\u0026#34;: \u0026#34;Røget Laks\u0026#34;, \u0026#34;dishDescription\u0026#34;: \u0026#34;Laks med dildcreme og rugbrødschips\u0026#34;, \u0026#34;stationName\u0026#34;: \u0026#34;Cold Kitchen\u0026#34;, \u0026#34;isActive\u0026#34;: true } AvailableDishes response object # Represents dishes available for building a specific weekly menu. Separates dishes created in the requested week (thisWeekDishes) from dishes already in the bank from previous weeks (fromDishBank).\nUsed by: GET /dishes/available\n{ \u0026#34;week\u0026#34;: 20, \u0026#34;year\u0026#34;: 2026, \u0026#34;thisWeekDishes\u0026#34;: { \u0026#34;Cold Kitchen\u0026#34;: [ { \u0026#34;dishId\u0026#34;: 1, \u0026#34;dishName\u0026#34;: \u0026#34;Røget Laks\u0026#34;, \u0026#34;dishDescription\u0026#34;: \u0026#34;Laks med dildcreme og rugbrødschips\u0026#34;, \u0026#34;stationName\u0026#34;: \u0026#34;Cold Kitchen\u0026#34;, \u0026#34;isActive\u0026#34;: true } ] }, \u0026#34;fromDishBank\u0026#34;: { \u0026#34;Hot Kitchen\u0026#34;: [ { \u0026#34;dishId\u0026#34;: 2, \u0026#34;dishName\u0026#34;: \u0026#34;Bøf Bearnaise\u0026#34;, \u0026#34;dishDescription\u0026#34;: \u0026#34;Oksemørbrad med hjemmelavet bearnaise\u0026#34;, \u0026#34;stationName\u0026#34;: \u0026#34;Hot Kitchen\u0026#34;, \u0026#34;isActive\u0026#34;: true }, { \u0026#34;dishId\u0026#34;: 5, \u0026#34;dishName\u0026#34;: \u0026#34;Stegt Flæsk\u0026#34;, \u0026#34;dishDescription\u0026#34;: \u0026#34;Med persillesovs og kartofler\u0026#34;, \u0026#34;stationName\u0026#34;: \u0026#34;Hot Kitchen\u0026#34;, \u0026#34;isActive\u0026#34;: true } ], \u0026#34;Pastry\u0026#34;: [ { \u0026#34;dishId\u0026#34;: 9, \u0026#34;dishName\u0026#34;: \u0026#34;Chokolademousse\u0026#34;, \u0026#34;dishDescription\u0026#34;: \u0026#34;Mørk chokolade med flødeskum\u0026#34;, \u0026#34;stationName\u0026#34;: \u0026#34;Pastry\u0026#34;, \u0026#34;isActive\u0026#34;: true } ] } } GET /dishes # Returns all dishes with optional filters. Only accessible to head chefs and sous chefs — for management and menu planning. Line cooks use GET /dishes/search.\nQuery parameters\nParameter Type Description stationId Long Filter by station active Boolean Filter by active status Example request\ncurl -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ \u0026#34;https://miseos.corral.dk/api/v1/dishes?stationId=1\u0026amp;active=true\u0026#34; Response 200 — array of dish objects.\n[ { \u0026#34;id\u0026#34;: 1, \u0026#34;nameDA\u0026#34;: \u0026#34;Røget Laks\u0026#34;, \u0026#34;nameEN\u0026#34;: \u0026#34;Smoked Salmon\u0026#34;, \u0026#34;descriptionDA\u0026#34;: \u0026#34;Laks med dildcreme og rugbrødschips\u0026#34;, \u0026#34;descriptionEN\u0026#34;: \u0026#34;Salmon with dill cream and rye bread chips\u0026#34;, \u0026#34;station\u0026#34;: { \u0026#34;id\u0026#34;: 1, \u0026#34;name\u0026#34;: \u0026#34;Cold Kitchen\u0026#34; }, \u0026#34;createdBy\u0026#34;: { \u0026#34;id\u0026#34;: 1, \u0026#34;firstName\u0026#34;: \u0026#34;Gordon\u0026#34;, \u0026#34;lastName\u0026#34;: \u0026#34;Ramsay\u0026#34; }, \u0026#34;allergens\u0026#34;: [ { \u0026#34;id\u0026#34;: 1, \u0026#34;nameDA\u0026#34;: \u0026#34;Gluten\u0026#34;, \u0026#34;nameEN\u0026#34;: \u0026#34;Gluten\u0026#34;, \u0026#34;displayNumber\u0026#34;: 1 }, { \u0026#34;id\u0026#34;: 4, \u0026#34;nameDA\u0026#34;: \u0026#34;Fisk\u0026#34;, \u0026#34;nameEN\u0026#34;: \u0026#34;Fish\u0026#34;, \u0026#34;displayNumber\u0026#34;: 4 } ], \u0026#34;active\u0026#34;: true, \u0026#34;originWeek\u0026#34;: 7, \u0026#34;originYear\u0026#34;: 2026, \u0026#34;hasTranslations\u0026#34;: true, \u0026#34;createdAt\u0026#34;: \u0026#34;2026-02-15 12:00\u0026#34;, \u0026#34;updatedAt\u0026#34;: \u0026#34;2026-02-20 15:30\u0026#34; }, { \u0026#34;id\u0026#34;: 2, \u0026#34;nameDA\u0026#34;: \u0026#34;Bøf Bearnaise\u0026#34;, \u0026#34;nameEN\u0026#34;: \u0026#34;Beef Bearnaise\u0026#34;, \u0026#34;descriptionDA\u0026#34;: \u0026#34;Oksemørbrad med hjemmelavet bearnaise\u0026#34;, \u0026#34;descriptionEN\u0026#34;: \u0026#34;Beef tenderloin with homemade bearnaise\u0026#34;, \u0026#34;station\u0026#34;: { \u0026#34;id\u0026#34;: 2, \u0026#34;name\u0026#34;: \u0026#34;Hot Kitchen\u0026#34; }, \u0026#34;createdBy\u0026#34;: { \u0026#34;id\u0026#34;: 1, \u0026#34;firstName\u0026#34;: \u0026#34;Gordon\u0026#34;, \u0026#34;lastName\u0026#34;: \u0026#34;Ramsay\u0026#34; }, \u0026#34;allergens\u0026#34;: [ { \u0026#34;id\u0026#34;: 2, \u0026#34;nameDA\u0026#34;: \u0026#34;Æg\u0026#34;, \u0026#34;nameEN\u0026#34;: \u0026#34;Eggs\u0026#34;, \u0026#34;displayNumber\u0026#34;: 2 }, { \u0026#34;id\u0026#34;: 7, \u0026#34;nameDA\u0026#34;: \u0026#34;Mælk\u0026#34;, \u0026#34;nameEN\u0026#34;: \u0026#34;Milk\u0026#34;, \u0026#34;displayNumber\u0026#34;: 7 } ], \u0026#34;active\u0026#34;: true, \u0026#34;originWeek\u0026#34;: 6, \u0026#34;originYear\u0026#34;: 2026, \u0026#34;hasTranslations\u0026#34;: true, \u0026#34;createdAt\u0026#34;: \u0026#34;2026-02-10 12:00\u0026#34;, \u0026#34;updatedAt\u0026#34;: \u0026#34;2026-02-18 15:30\u0026#34; } ] GET /dishes/search # Searches dishes by name in both Danish and English. Returns partial matches. Available to all kitchen staff.\nQuery parameters\nParameter Type Description query String Search term — minimum 2 characters Example request\ncurl -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ \u0026#34;https://miseos.corral.dk/api/v1/dishes/search?query=laks\u0026#34; Response 200 — array of dish objects.\nErrors\nStatus Cause 400 Query is blank or fewer than 2 characters GET /dishes/available # Returns dishes available for a specific week\u0026rsquo;s menu, grouped by station. Separates new dishes from that week from the existing dish bank.\nQuery parameters\nParameter Type Description week Integer ISO week number (required) year Integer Year (required) Example request\ncurl -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ \u0026#34;https://miseos.corral.dk/api/v1/dishes/available?week=20\u0026amp;year=2026\u0026#34; Response 200 — AvailableDishes object.\nGET /dishes/grouped # Returns all active dishes grouped by station name. Intended for menu planning — a complete overview of what is available per station.\nExample request\ncurl -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ \u0026#34;https://miseos.corral.dk/api/v1/dishes/grouped\u0026#34; Response 200\n{ \u0026#34;Cold Kitchen\u0026#34;: [ { \u0026#34;dishId\u0026#34;: 1, \u0026#34;dishName\u0026#34;: \u0026#34;Røget Laks\u0026#34;, \u0026#34;dishDescription\u0026#34;: \u0026#34;Laks med dildcreme og rugbrødschips\u0026#34;, \u0026#34;stationName\u0026#34;: \u0026#34;Cold Kitchen\u0026#34;, \u0026#34;isActive\u0026#34;: true }, { \u0026#34;dishId\u0026#34;: 4, \u0026#34;dishName\u0026#34;: \u0026#34;Roastbeef\u0026#34;, \u0026#34;dishDescription\u0026#34;: \u0026#34;Roastbeef med remoulade og sprøde løg\u0026#34;, \u0026#34;stationName\u0026#34;: \u0026#34;Cold Kitchen\u0026#34;, \u0026#34;isActive\u0026#34;: true } ], \u0026#34;Hot Kitchen\u0026#34;: [ { \u0026#34;dishId\u0026#34;: 2, \u0026#34;dishName\u0026#34;: \u0026#34;Bøf Bearnaise\u0026#34;, \u0026#34;dishDescription\u0026#34;: \u0026#34;Oksemørbrad med hjemmelavet bearnaise\u0026#34;, \u0026#34;stationName\u0026#34;: \u0026#34;Hot Kitchen\u0026#34;, \u0026#34;isActive\u0026#34;: true }, { \u0026#34;dishId\u0026#34;: 3, \u0026#34;dishName\u0026#34;: \u0026#34;Tarteletter\u0026#34;, \u0026#34;dishDescription\u0026#34;: \u0026#34;Høns i asparges\u0026#34;, \u0026#34;stationName\u0026#34;: \u0026#34;Hot Kitchen\u0026#34;, \u0026#34;isActive\u0026#34;: true }, { \u0026#34;dishId\u0026#34;: 5, \u0026#34;dishName\u0026#34;: \u0026#34;Stegt Flæsk\u0026#34;, \u0026#34;dishDescription\u0026#34;: \u0026#34;Med persillesovs og kartofler\u0026#34;, \u0026#34;stationName\u0026#34;: \u0026#34;Hot Kitchen\u0026#34;, \u0026#34;isActive\u0026#34;: true } ], \u0026#34;Pastry\u0026#34;: [ { \u0026#34;dishId\u0026#34;: 9, \u0026#34;dishName\u0026#34;: \u0026#34;Chokolademousse\u0026#34;, \u0026#34;dishDescription\u0026#34;: \u0026#34;Mørk chokolade med flødeskum\u0026#34;, \u0026#34;stationName\u0026#34;: \u0026#34;Pastry\u0026#34;, \u0026#34;isActive\u0026#34;: true } ] } GET /dishes/{id} # Returns a single dish by ID. Available to all kitchen staff.\nExample request\ncurl -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ \u0026#34;https://miseos.corral.dk/api/v1/dishes/1\u0026#34; Response 200 — dish object.\nErrors\nStatus Cause 400 ID is not a positive number 404 Dish not found POST /dishes # Creates a new dish manually. The dish is assigned the current week and year as its origin and is active by default.\nExample request\ncurl -X POST \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{ \u0026#34;nameDA\u0026#34;: \u0026#34;Grillet Kylling\u0026#34;, \u0026#34;descriptionDA\u0026#34;: \u0026#34;Serveret med citron og timian\u0026#34;, \u0026#34;stationId\u0026#34;: 2, \u0026#34;allergenIds\u0026#34;: [1, 3] }\u0026#39; \\ \u0026#34;https://miseos.corral.dk/api/v1/dishes\u0026#34; Request body\n{ \u0026#34;nameDA\u0026#34;: \u0026#34;Grillet Kylling\u0026#34;, \u0026#34;descriptionDA\u0026#34;: \u0026#34;Serveret med citron og timian\u0026#34;, \u0026#34;stationId\u0026#34;: 2, \u0026#34;allergenIds\u0026#34;: [1, 3] } allergenIds is optional — omit or send an empty array for a dish with no allergens.\nResponse 201 — created dish object.\nErrors\nStatus Cause 404 Station not found PUT /dishes/{id} # Updates a dish. English fields are optional — set to null if no translation exists yet.\nExample request\ncurl -X PUT \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{ \u0026#34;nameDA\u0026#34;: \u0026#34;Grillet Kylling\u0026#34;, \u0026#34;descriptionDA\u0026#34;: \u0026#34;Serveret med citron og timian\u0026#34;, \u0026#34;nameEN\u0026#34;: \u0026#34;Grilled Chicken\u0026#34;, \u0026#34;descriptionEN\u0026#34;: \u0026#34;Served with lemon and thyme\u0026#34;, \u0026#34;allergenIds\u0026#34;: [1] }\u0026#39; \\ \u0026#34;https://miseos.corral.dk/api/v1/dishes/7\u0026#34; Request body\n{ \u0026#34;nameDA\u0026#34;: \u0026#34;Grillet Kylling\u0026#34;, \u0026#34;descriptionDA\u0026#34;: \u0026#34;Serveret med citron og timian\u0026#34;, \u0026#34;nameEN\u0026#34;: \u0026#34;Grilled Chicken\u0026#34;, \u0026#34;descriptionEN\u0026#34;: \u0026#34;Served with lemon and thyme\u0026#34;, \u0026#34;allergenIds\u0026#34;: [1] } Response 200 — updated dish object.\nErrors\nStatus Cause 404 Dish not found PATCH /dishes/{id}/activate # Reactivates a previously deactivated dish. The dish becomes available again for menus and ingredient requests.\nExample request\ncurl -X PATCH \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ \u0026#34;https://miseos.corral.dk/api/v1/dishes/6/activate\u0026#34; Response 200 — updated dish object with active: true.\nPATCH /dishes/{id}/deactivate # Deactivates a dish. Deactivated dishes cannot be added to menus or ingredient requests but remain in the system for historical reference. Prefer deactivation over deletion when the dish has been used in menus.\nExample request\ncurl -X PATCH \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ \u0026#34;https://miseos.corral.dk/api/v1/dishes/6/deactivate\u0026#34; Response 200 — updated dish object with active: false.\nDELETE /dishes/{id} # Permanently deletes a dish. Fails if the dish is used in any weekly menu — use deactivate instead.\nExample request\ncurl -X DELETE \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ \u0026#34;https://miseos.corral.dk/api/v1/dishes/10\u0026#34; Response 204 — no content.\nErrors\nStatus Cause 404 Dish not found 409 Dish is used in one or more weekly menus ","date":"27 March 2026","externalUrl":null,"permalink":"/docs/miseos-api-doc/dishes/","section":"Docs","summary":"","title":"Dishes","type":"docs"},{"content":"This section documents the ingredient request endpoints of the MiseOS API.\nKitchen staff can submit ingredient requests for upcoming delivery dates. Head chefs and sous chefs can review requests and approve or reject them. Approved requests are later aggregated into shopping lists.\nIngredient request workflow # stateDiagram-v2 [*] --\u003e PENDING : Staff submits request PENDING --\u003e APPROVED : Head/Sous chef approves PENDING --\u003e REJECTED : Head/Sous chef rejects PENDING --\u003e DELETED : Owner or Head/Sous deletes APPROVED --\u003e [*] REJECTED --\u003e [*] DELETED --\u003e [*] The diagram illustrates the lifecycle of an ingredient request, starting from submission (PENDING) to either approval, rejection, or deletion. Only pending requests can be approved or rejected, and only pending requests can be deleted by the owner or Head/Sous chefs.\nEndpoints # Method URL Auth GET /ingredient-requests KITCHEN_STAFF GET /ingredient-requests/{id} KITCHEN_STAFF POST /ingredient-requests KITCHEN_STAFF PUT /ingredient-requests/{id} KITCHEN_STAFF DELETE /ingredient-requests/{id} KITCHEN_STAFF PATCH /ingredient-requests/{id}/approve HEAD_CHEF, SOUS_CHEF PATCH /ingredient-requests/{id}/reject HEAD_CHEF, SOUS_CHEF Visibility rule:\nHead chefs and sous chefs can see all requests Other kitchen staff only see their own requests Ingredient Request response object # The ingredient request object has the following structure:\n{ \u0026#34;id\u0026#34;: 1, \u0026#34;name\u0026#34;: \u0026#34;Frisk Dild\u0026#34;, \u0026#34;quantity\u0026#34;: 10.0, \u0026#34;unit\u0026#34;: \u0026#34;BUNCH\u0026#34;, \u0026#34;preferredSupplier\u0026#34;: \u0026#34;Grønttorvet\u0026#34;, \u0026#34;note\u0026#34;: \u0026#34;Til laksen\u0026#34;, \u0026#34;status\u0026#34;: \u0026#34;PENDING\u0026#34;, \u0026#34;requestType\u0026#34;: \u0026#34;DISH_SPECIFIC\u0026#34;, \u0026#34;deliveryDate\u0026#34;: \u0026#34;2026-04-01\u0026#34;, \u0026#34;requestedBy\u0026#34;: { \u0026#34;id\u0026#34;: 2, \u0026#34;firstName\u0026#34;: \u0026#34;Claire\u0026#34;, \u0026#34;lastName\u0026#34;: \u0026#34;Smyth\u0026#34; }, \u0026#34;dish\u0026#34;: { \u0026#34;id\u0026#34;: 1, \u0026#34;dishNameDA\u0026#34;: \u0026#34;Røget Laks\u0026#34;, \u0026#34;dishNameEN\u0026#34;: \u0026#34;Smoked Salmon\u0026#34; }, \u0026#34;reviewedAt\u0026#34;: null, \u0026#34;createdAt\u0026#34;: \u0026#34;2026-03-27 10:00\u0026#34;, \u0026#34;updatedAt\u0026#34;: null } Status values: PENDING | APPROVED | REJECTED\nRequest type values: DISH_SPECIFIC | GENERAL_STOCK\nUnit values: KG | G | L | ML | PCS | BUNCH | SIDES | BOX | BOTTLE | CAN\nGET /ingredient-requests # Returns ingredient requests with optional filters.\nQuery parameters\nParameter Type Description status String Filter by request status deliveryDate LocalDate Filter by delivery date (yyyy-MM-dd) requestType String Filter by request type stationId Long Filter by station Response 200 — array of ingredient request objects.\nErrors\nStatus Cause 400 Invalid query value (status, requestType, date format, stationId) GET /ingredient-requests/{id} # Returns a single ingredient request.\nResponse 200 — ingredient request object.\nErrors\nStatus Cause 403 User is not owner and not management 404 Request not found POST /ingredient-requests # Creates a new ingredient request.\nValidation rules:\ndeliveryDate cannot be in the past deliveryDate cannot be more than 30 days ahead dishId is required for DISH_SPECIFIC For non-management users, dish must belong to user\u0026rsquo;s station Dish must be active Request body\n{ \u0026#34;name\u0026#34;: \u0026#34;Frisk Dild\u0026#34;, \u0026#34;quantity\u0026#34;: 10.0, \u0026#34;unit\u0026#34;: \u0026#34;BUNCH\u0026#34;, \u0026#34;preferredSupplier\u0026#34;: \u0026#34;Grønttorvet\u0026#34;, \u0026#34;note\u0026#34;: \u0026#34;Til laksen\u0026#34;, \u0026#34;requestType\u0026#34;: \u0026#34;DISH_SPECIFIC\u0026#34;, \u0026#34;deliveryDate\u0026#34;: \u0026#34;2026-04-01\u0026#34;, \u0026#34;dishId\u0026#34;: 1 } Response 201 — created ingredient request object.\nErrors\nStatus Cause 400 Invalid payload, invalid date window, or missing required fields 403 Dish belongs to different station 404 Dish/user not found PUT /ingredient-requests/{id} # Updates an existing request.\nRules:\nOwner or management can update Business rule validation is applied in domain/service layer Payload updates request content fields (not review action) Request body\n{ \u0026#34;name\u0026#34;: \u0026#34;Frisk Dild\u0026#34;, \u0026#34;quantity\u0026#34;: 8.0, \u0026#34;unit\u0026#34;: \u0026#34;BUNCH\u0026#34;, \u0026#34;preferredSupplier\u0026#34;: \u0026#34;Grønttorvet\u0026#34;, \u0026#34;note\u0026#34;: \u0026#34;Opdateret mængde\u0026#34;, \u0026#34;requestType\u0026#34;: \u0026#34;DISH_SPECIFIC\u0026#34;, \u0026#34;deliveryDate\u0026#34;: \u0026#34;2026-04-01\u0026#34;, \u0026#34;dishId\u0026#34;: 1 } Response 200 — updated ingredient request object.\nErrors\nStatus Cause 400 Invalid payload or date validation failure 403 User is not owner and not management 404 Request or dish not found 409 Request cannot be modified in current state DELETE /ingredient-requests/{id} # Deletes an ingredient request.\nRules:\nOwner or management can delete Domain rules for deletable state are enforced Response 204 — no content.\nErrors\nStatus Cause 403 User is not owner and not management 404 Request not found 409 Request cannot be deleted in current state PATCH /ingredient-requests/{id}/approve # Approves a request.\nOptional payload can adjust approved quantity/note before approval.\nRequest body (optional)\n{ \u0026#34;quantity\u0026#34;: 8.0, \u0026#34;note\u0026#34;: \u0026#34;Reduceret mængde godkendt\u0026#34; } If body is omitted, current values are kept.\nResponse 200 — updated ingredient request object (status: APPROVED).\nErrors\nStatus Cause 404 Request not found 409 Request is not in approvable state PATCH /ingredient-requests/{id}/reject # Rejects a request.\nResponse 200 — updated ingredient request object (status: REJECTED).\nErrors\nStatus Cause 404 Request not found 409 Request is not in rejectable state Notifications # These endpoints trigger WebSocket notifications:\nPOST /ingredient-requests → admin pending count broadcast PATCH /ingredient-requests/{id}/approve → direct staff notification + pending count update PATCH /ingredient-requests/{id}/reject → direct staff notification + pending count update DELETE /ingredient-requests/{id} (if deleted) → pending count update ","date":"27 March 2026","externalUrl":null,"permalink":"/docs/miseos-api-doc/ingredient-requests/","section":"Docs","summary":"","title":"Ingredient Requests","type":"docs"},{"content":"This section documents the AI-powered menu inspiration endpoints of the MiseOS API.\nMenu inspiration suggestions are generated using the following inputs:\nCurrent week\u0026rsquo;s weather forecast in Copenhagen The requesting user\u0026rsquo;s station The company\u0026rsquo;s ISO standards Previously published dishes from recent weeks Results are non-deterministic and vary between requests.\nInspiration generation flow # flowchart LR Staff --\u003e|Request inspiration| API API --\u003e|User + station + dish history lookup| Database Database --\u003e API API --\u003e|Fetch forecast| WeatherAPI WeatherAPI --\u003e API API --\u003e|Prompt generation| AIModel AIModel --\u003e|Dish suggestions| API API --\u003e|Return suggestions| Staff Menu Inspirations Endpoints # Method URL Auth GET /menu-inspirations/daily KITCHEN_STAFF SSE (Server-Sent Events) /menu-inspirations/stream KITCHEN_STAFF AiDishSuggestion object # Suggestions are currently generated in Danish (DA). Translation can be applied later if suggestion is approved to a Dish object.\n{ \u0026#34;nameDA\u0026#34;: \u0026#34;Grillet Laks med Dild\u0026#34;, \u0026#34;descriptionDA\u0026#34;: \u0026#34;Frisk atlanterhavslaks grillet med citron og frisk dild\u0026#34; } GET /menu-inspirations/daily # Returns 10 AI-generated dish suggestions tailored to the authenticated user\u0026rsquo;s kitchen station.\nExample Request # curl -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/menu-inspirations/daily Response 200 — array of 10 AiDishSuggestion objects.\nExample response for a chef working at the cold station during cold weather:\n[ { \u0026#34;nameDA\u0026#34;: \u0026#34;Rimmet kuller med dildolie\u0026#34;, \u0026#34;descriptionDA\u0026#34;: \u0026#34;Saltet og rimmet dansk kuller serveret med en emulsion på dild og syrnede agurker for at minimere madspild fra fraskær.\u0026#34; }, { \u0026#34;nameDA\u0026#34;: \u0026#34;Rugkerne-knækbrød med malt\u0026#34;, \u0026#34;descriptionDA\u0026#34;: \u0026#34;Artisan knækbrød bagt med mask fra lokal ølproduktion og toppet med fermenterede frø for optimal udnyttelse af råvarer.\u0026#34; }, { \u0026#34;nameDA\u0026#34;: \u0026#34;Braiseret knoldselleri med svampeglace\u0026#34;, \u0026#34;descriptionDA\u0026#34;: \u0026#34;Hele selleri braiseret i grøntsagsfond, glaseret med en kraftig reduktion af tørrede danske svampe og serveret med hasselnødde-crumble.\u0026#34; }, { \u0026#34;nameDA\u0026#34;: \u0026#34;Grønærte-hummus med dukkah\u0026#34;, \u0026#34;descriptionDA\u0026#34;: \u0026#34;En proteinrig hummus baseret på danske ærter, toppet med en krydret dukkah lavet af afskårne nødder og kerner.\u0026#34; }, { \u0026#34;nameDA\u0026#34;: \u0026#34;Ovnbagt kyllingebryst med urtesauce\u0026#34;, \u0026#34;descriptionDA\u0026#34;: \u0026#34;Bryst af frilandsfjerkræ serveret med en sauce monté på grønne urter og rodfrugter fra regionalt landbrug.\u0026#34; }, { \u0026#34;nameDA\u0026#34;: \u0026#34;Perlebygsalat med bagte rødbeder\u0026#34;, \u0026#34;descriptionDA\u0026#34;: \u0026#34;Lun salat af perlebyg vendt med ovnbagte rødbeder og en vinaigrette på sennep og æblecidereddike.\u0026#34; }, { \u0026#34;nameDA\u0026#34;: \u0026#34;Confiteret svamperilette\u0026#34;, \u0026#34;descriptionDA\u0026#34;: \u0026#34;En grov rillette på danskdyrkede østershatte og løg, smagt til med timian og serveret med syltede sennepsfrø.\u0026#34; }, { \u0026#34;nameDA\u0026#34;: \u0026#34;Røget makrelmousse med peberrod\u0026#34;, \u0026#34;descriptionDA\u0026#34;: \u0026#34;Mousse på røget makrel fra Nordsøen monteret med yoghurt og revet peberrod for en klassisk smagsprofil.\u0026#34; }, { \u0026#34;nameDA\u0026#34;: \u0026#34;Kål- og æblesalat med ristede græskarkerner\u0026#34;, \u0026#34;descriptionDA\u0026#34;: \u0026#34;Fintsnittet hvidkål blandet med danske æbler og en emulsion af rapsolie for en sprød, sæsonbetonet struktur.\u0026#34; }, { \u0026#34;nameDA\u0026#34;: \u0026#34;Linsesalat med bagt græskar\u0026#34;, \u0026#34;descriptionDA\u0026#34;: \u0026#34;Belugalinser vendt med ovnbagte græskartern og en urteolie baseret på persillestilke for at sikre nul-spild.\u0026#34; } ] Errors\nStatus Cause 404 User not found or user has no station assigned 502 Weather provider unavailable 502 AI provider unavailable SSE /menu-inspirations/stream # Streams dish suggestions progressively using Server-Sent Events (text/event-stream).\nThe stream emits:\nstatus events (progress messages during generation) dish events (one suggestion at a time) done when completed error if generation fails Example Request # curl -N \\ -H \u0026#34;Accept: text/event-stream\u0026#34; \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/menu-inspirations/stream Example Event Stream # event: status data: Analyserer køkkenstationens udstyr... event: status data: Henter vejrdata for din lokation... event: status data: Genererer menuforslag baseret på bæredygtighed... event: dish data: {\u0026#34;nameDA\u0026#34;:\u0026#34;Rimmet kuller med dild-emulsion\u0026#34;,\u0026#34;descriptionDA\u0026#34;:\u0026#34;Saltet kuller serveres med en kold emulsion på dildolie og dildstængler for minimalt madspild.\u0026#34;} event: dish data: {\u0026#34;nameDA\u0026#34;:\u0026#34;Grillet knoldselleri-tatar\u0026#34;,\u0026#34;descriptionDA\u0026#34;:\u0026#34;Hakket bagt knoldselleri vendt med fermenteret sennep og purløgsolie toppet med sprøde selleriskræller.\u0026#34;} event: dish data: {\u0026#34;nameDA\u0026#34;:\u0026#34;Rugkernebrød med surdej\u0026#34;,\u0026#34;descriptionDA\u0026#34;:\u0026#34;Langtidshævet rugbrød bagt med knækkede rugkerner og brug af overskydende surdej for maksimal udnyttelse.\u0026#34;} event: dish data: {\u0026#34;nameDA\u0026#34;:\u0026#34;Kardemommeknuder med fuldkorn\u0026#34;,\u0026#34;descriptionDA\u0026#34;:\u0026#34;Klassisk bagværk med 30% erstatning af hvedemel med lokalt formalet fuldkornshvedemel for bedre næringsprofil.\u0026#34;} event: dish data: {\u0026#34;nameDA\u0026#34;:\u0026#34;Braiserede linser med ovnbagte beder\u0026#34;,\u0026#34;descriptionDA\u0026#34;:\u0026#34;Belugalinser braiseret i grøntsagsbouillon toppet med ovnbagte danske beder og dild-vinaigrette.\u0026#34;} event: dish data: {\u0026#34;nameDA\u0026#34;:\u0026#34;Langtidsstegt svinenakke med kålrabi\u0026#34;,\u0026#34;descriptionDA\u0026#34;:\u0026#34;Skiver af svinenakke glaceret i æblemost serveret med puré af kålrabi og bagt grønkål.\u0026#34;} event: dish data: {\u0026#34;nameDA\u0026#34;:\u0026#34;Perlebyg med bagte jordskokker\u0026#34;,\u0026#34;descriptionDA\u0026#34;:\u0026#34;Perlebyg vendt med urtepesto af persillestilke og toppet med sprødstegte jordskokker.\u0026#34;} event: dish data: {\u0026#34;nameDA\u0026#34;:\u0026#34;Salat på vinterkål og æble\u0026#34;,\u0026#34;descriptionDA\u0026#34;:\u0026#34;Snittet grønkål og rød kål masseret med æbleeddike, toppet med syrnede æbler og ristede kerner.\u0026#34;} event: dish data: {\u0026#34;nameDA\u0026#34;:\u0026#34;Røget fiskesalat med radiser\u0026#34;,\u0026#34;descriptionDA\u0026#34;:\u0026#34;Røget hvidfisk rørt med rygeost og dild, toppet med tynde skiver af danske radiser og dild-olie.\u0026#34;} event: dish data: {\u0026#34;nameDA\u0026#34;:\u0026#34;Hummus af gule ærter\u0026#34;,\u0026#34;descriptionDA\u0026#34;:\u0026#34;Dansk-dyrkede gule ærter blendet med koldpresset rapsolie og fermenteret hvidløg for en lokal variant af klassisk hummus.\u0026#34;} event: done data: complete Errors\nErrors are sent as SSE events: event: error data: \u0026lt;message\u0026gt; Notes # Suggestions are not automatically saved as dishes or suggestions. Output depends on real-time weather and model behavior. SSE stream closes automatically after done or error. ","date":"27 March 2026","externalUrl":null,"permalink":"/docs/miseos-api-doc/menu-inspirations/","section":"Docs","summary":"","title":"Menu Inspirations","type":"docs"},{"content":"The MiseOS API provides programmatic access to the MiseOS kitchen management platform.\nIt allows applications to manage users, kitchen stations, menus, ingredients, and internal kitchen workflows used in professional kitchens.\nMost endpoints follow REST principles and return JSON responses.\nReal-time features are delivered through WebSocket and Server-Sent Events (SSE).\nBase URL # Current version of the API is available at:\nhttps://miseos.corral.dk/api/v1\nAll endpoints listed in this documentation are relative to this base URL.\nVersioning # The MiseOS API uses URL versioning.\nCurrent version:\nhttps://miseos.corral.dk/api/v1\nFuture breaking changes will be released under /api/v2.\nResources # The following sections describe the available API resources and their endpoints.\nAuthentication Users Allergens Stations Dish Suggestions Dishes Weekly Menus Ingredient Requests Shopping Lists Notifications Menu Inspirations Takeaway Offers Takeaway Orders Authentication # Most endpoints require authentication via a JWT token in the Authorization header. Tokens are obtained through the login endpoint.\nExamples of public endpoints include:\nPOST /auth/register POST /auth/login GET /weekly-menus/current GET /weekly-menus/by-week Authorization: Bearer \u0026lt;token\u0026gt; JWT tokens expire after 30 minutes.\nYou can obtain a token by calling the login endpoint.\nQuick Start # Register a user POST /auth/register\nLogin POST /auth/login\nUse the returned token Authorization: Bearer Example request to get user profile:\ncurl -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/users/me Roles # Role Description HEAD_CHEF Full access — management, approvals, publishing SOUS_CHEF Management access — most operations except role changes LINE_COOK Own resources — requests, suggestions, read access KITCHEN_STAFF Any kitchen role (HEAD_CHEF, SOUS_CHEF, LINE_COOK) ANYONE No authentication required Error Format # All errors follow this format:\n{ \u0026#34;statusCode\u0026#34;: 404, \u0026#34;message\u0026#34;: \u0026#34;User with ID 99 was not found.\u0026#34;, \u0026#34;timestamp\u0026#34;: \u0026#34;2026-03-27 12:00\u0026#34;, \u0026#34;path\u0026#34;: \u0026#34;/api/v1/users/99\u0026#34;, \u0026#34;referenceId\u0026#34;: \u0026#34;abc123xyz\u0026#34; } Error Fields # Field Description statusCode HTTP status code of the error message Human-readable error message timestamp Time the error occurred (server time) path API endpoint that was called referenceId Unique ID for this error instance (use when contacting support) Common Error Status Codes # Status Meaning 400 Invalid input or validation failure 401 Missing or invalid token 403 Authenticated but insufficient role or ownership 404 Resource not found 409 Conflict — resource already exists or wrong state 500 Internal server error 502 External service unavailable ","date":"27 March 2026","externalUrl":null,"permalink":"/docs/miseos-api-doc/","section":"Docs","summary":"","title":"MiseOS API Documentation","type":"docs"},{"content":"This section documents the notification endpoints of the MiseOS API.\nMiseOS uses WebSockets to push real-time updates to connected clients.\nThe notification system supports two delivery flows:\nAdmin broadcast for HEAD_CHEF and SOUS_CHEF Direct staff notifications for individual kitchen users Notification flow # flowchart LR Client --\u003e|WebSocket connect| NotificationServer NotificationServer --\u003e|Admin broadcast| AdminClients NotificationServer --\u003e|Direct notification| StaffClient NotificationServer --\u003e|REST snapshot| Dashboard Notification Endpoints # Method URL Auth WS /notifications KITCHEN_STAFF GET /notifications/snapshot HEAD_CHEF, SOUS_CHEF WebSocket connection # Clients must connect with a valid JWT in the Authorization header:\nwss://miseos.corral.dk/api/v1/notifications Authorization: Bearer \u0026lt;token\u0026gt; Session registration behavior:\nHEAD_CHEF, SOUS_CHEF → registered as admin sessions (receive broadcast updates) Other kitchen staff (e.g. LINE_COOK) → registered as staff sessions (receive direct user notifications) If the token is missing or invalid, the server closes the connection with close code 1008 (policy violation).\nAdmin broadcast message # Sent to all connected admin sessions when pending counts change.\n{ \u0026#34;notificationType\u0026#34;: \u0026#34;PENDING_COUNT_UPDATED\u0026#34;, \u0026#34;category\u0026#34;: \u0026#34;INGREDIENT_REQUEST\u0026#34;, \u0026#34;count\u0026#34;: 3, \u0026#34;timestamp\u0026#34;: \u0026#34;2026-03-27 10:15\u0026#34; } Notification types (admin):\nNEW_INGREDIENT_REQUEST NEW_DISH_SUGGESTION PENDING_COUNT_UPDATED Category values:\nINGREDIENT_REQUEST DISH_SUGGESTION Staff direct message # Sent to a specific user when their request/suggestion is reviewed.\n{ \u0026#34;notificationType\u0026#34;: \u0026#34;REQUEST_APPROVED\u0026#34;, \u0026#34;category\u0026#34;: \u0026#34;INGREDIENT_REQUEST\u0026#34;, \u0026#34;requestId\u0026#34;: 5, \u0026#34;itemName\u0026#34;: \u0026#34;Frisk Dild\u0026#34;, \u0026#34;reviewedBy\u0026#34;: { \u0026#34;id\u0026#34;: 1, \u0026#34;firstName\u0026#34;: \u0026#34;Gordon\u0026#34;, \u0026#34;lastName\u0026#34;: \u0026#34;Ramsay\u0026#34; }, \u0026#34;timestamp\u0026#34;: \u0026#34;2026-03-27 10:20\u0026#34; } Notification types (staff):\nREQUEST_APPROVED REQUEST_REJECTED SUGGESTION_APPROVED SUGGESTION_REJECTED Category values:\nINGREDIENT_REQUEST DISH_SUGGESTION GET /notifications/snapshot # Returns current pending counters for dashboard badges.\nRecommended flow: call this once on page load, then rely on WebSocket updates.\nExample Request # curl -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/notifications/snapshot Response 200 # { \u0026#34;pendingDishSuggestions\u0026#34;: 3, \u0026#34;pendingIngredientRequests\u0026#34;: 7, \u0026#34;totalPending\u0026#34;: 10 } Errors\nStatus Cause 401 Missing or invalid token 403 Insufficient role (not HEAD_CHEF/SOUS_CHEF) Delivery notes # Notifications are fire-and-forget. If a user is offline when a message is sent, it is not queued. Source-of-truth state is always available through REST endpoints. The current implementation allows one active staff WebSocket session per user. If a new connection is established, the previous session is replaced. ","date":"27 March 2026","externalUrl":null,"permalink":"/docs/miseos-api-doc/notifications/","section":"Docs","summary":"","title":"Notifications","type":"docs"},{"content":"This section documents the shopping list endpoints of the MiseOS API.\nShopping lists are generated from approved ingredient requests for a delivery date.\nAI normalization merges ingredient name variants and synonyms across languages into a single normalized name. If AI normalization fails, the shopping list is still generated using the original ingredient names. Manual items can also be added to shopping lists after generation.\nThe diagrams below give a quick overview of how shopping lists are created and managed.\nGeneration workflow shows how approved ingredient requests are transformed into a shopping list, including AI normalization and fallback behavior. Lifecycle shows the allowed shopping list states and when transitions happen. Together, they help clarify both the process flow and state rules before diving into endpoint details.\nShopping list generation workflow # Shopping lists are generated from approved ingredient requests for a specific delivery date.\nThe workflow below illustrates how ingredient requests are aggregated, normalized using AI, and transformed into a shopping list.\nflowchart TD A[Ingredient Requests Submitted] B[Requests Approved] C[Shopping List Generated] D[AI Ingredient Normalization] E[Shopping List Created - DRAFT] F[Items Marked as Ordered] G[Shopping List Finalized] A --\u003e B B --\u003e C C --\u003e D D --\u003e E E --\u003e F F --\u003e G Shopping list lifecycle # stateDiagram-v2 [*] --\u003e DRAFT : list generated DRAFT --\u003e FINALIZED : finalize DRAFT --\u003e [*] : deleted FINALIZED --\u003e [*] Shopping lists are initially created in the DRAFT state.\nWhile in draft, items can be added, removed, or updated.\nOnce all items are marked as ordered, the list can be finalized.\nFinalized lists are immutable and represent completed purchase orders.\nShopping Lists Endpoints # Method URL Auth GET /shopping-lists HEAD_CHEF, SOUS_CHEF GET /shopping-lists/{id} HEAD_CHEF, SOUS_CHEF POST /shopping-lists HEAD_CHEF, SOUS_CHEF POST /shopping-lists/{id}/finalize HEAD_CHEF, SOUS_CHEF PATCH /shopping-lists/{id}/delivery-date HEAD_CHEF, SOUS_CHEF DELETE /shopping-lists/{id} HEAD_CHEF, SOUS_CHEF POST /shopping-lists/{id}/items HEAD_CHEF, SOUS_CHEF PUT /shopping-lists/{id}/items/{itemId} HEAD_CHEF, SOUS_CHEF DELETE /shopping-lists/{id}/items/{itemId} HEAD_CHEF, SOUS_CHEF PATCH /shopping-lists/{id}/items/{itemId}/ordered HEAD_CHEF, SOUS_CHEF PATCH /shopping-lists/{id}/items/ordered HEAD_CHEF, SOUS_CHEF ShoppingList response object # The shopping list object has the following structure:\n{ \u0026#34;id\u0026#34;: 1, \u0026#34;deliveryDate\u0026#34;: \u0026#34;2026-04-01\u0026#34;, \u0026#34;status\u0026#34;: \u0026#34;DRAFT\u0026#34;, \u0026#34;createdBy\u0026#34;: { \u0026#34;id\u0026#34;: 1, \u0026#34;firstName\u0026#34;: \u0026#34;Gordon\u0026#34;, \u0026#34;lastName\u0026#34;: \u0026#34;Ramsay\u0026#34; }, \u0026#34;itemCount\u0026#34;: 3, \u0026#34;items\u0026#34;: [ { \u0026#34;id\u0026#34;: 1, \u0026#34;ingredientName\u0026#34;: \u0026#34;Løg\u0026#34;, \u0026#34;quantity\u0026#34;: 14.0, \u0026#34;unit\u0026#34;: \u0026#34;KG\u0026#34;, \u0026#34;supplier\u0026#34;: \u0026#34;Inco\u0026#34;, \u0026#34;notes\u0026#34;: \u0026#34;Claire (løg: 7.0 KG) | Marco (onions: 7.0 KG)\u0026#34;, \u0026#34;ordered\u0026#34;: false, \u0026#34;createdAt\u0026#34;: \u0026#34;2026-03-27 10:00\u0026#34;, \u0026#34;updatedAt\u0026#34;: null }, { \u0026#34;id\u0026#34;: 2, \u0026#34;ingredientName\u0026#34;: \u0026#34;Smør\u0026#34;, \u0026#34;quantity\u0026#34;: 5.0, \u0026#34;unit\u0026#34;: \u0026#34;KG\u0026#34;, \u0026#34;supplier\u0026#34;: \u0026#34;Arla\u0026#34;, \u0026#34;notes\u0026#34;: \u0026#34;Manual entry by: Gordon Ramsay\u0026#34;, \u0026#34;ordered\u0026#34;: false, \u0026#34;createdAt\u0026#34;: \u0026#34;2026-03-27 10:05\u0026#34;, \u0026#34;updatedAt\u0026#34;: null }, { \u0026#34;id\u0026#34;: 3, \u0026#34;ingredientName\u0026#34;: \u0026#34;Frisk Dild\u0026#34;, \u0026#34;quantity\u0026#34;: 10.0, \u0026#34;unit\u0026#34;: \u0026#34;BUNCH\u0026#34;, \u0026#34;supplier\u0026#34;: \u0026#34;Grønttorvet\u0026#34;, \u0026#34;notes\u0026#34;: \u0026#34;Claire (Frisk Dild: 10.0 BUNCH)\u0026#34;, \u0026#34;ordered\u0026#34;: false, \u0026#34;createdAt\u0026#34;: \u0026#34;2026-03-27 10:00\u0026#34;, \u0026#34;updatedAt\u0026#34;: null } ], \u0026#34;allOrdered\u0026#34;: false, \u0026#34;normalized\u0026#34;: true, \u0026#34;createdAt\u0026#34;: \u0026#34;2026-03-27 10:00\u0026#34;, \u0026#34;finalizedAt\u0026#34;: null } Status values: DRAFT | FINALIZED\nallOrdered indicates whether every item in the shopping list has been marked as ordered.\nnormalized = true: AI successfully normalized ingredient names across languages.\nnormalized = false: AI normalization failed and the system fell back to original ingredient names.\nnotes describes the origin of the ingredient entry.\nExamples:\nAggregated ingredient requests\nClaire (løg: 7.0 KG) | Marco (onions: 7.0 KG)\nManual entries\nManual entry by: Gordon Ramsay\nGET /shopping-lists # Returns shopping lists with optional filters.\nQuery parameters\nParameter Type Description status String Filter by DRAFT or FINALIZED deliveryDate LocalDate Filter by yyyy-MM-dd Example Request # curl -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ \u0026#34;https://miseos.corral.dk/api/v1/shopping-lists?status=DRAFT\u0026amp;deliveryDate=2026-04-01\u0026#34; Response 200 — array of shopping list objects.\nErrors\nStatus Cause 400 Invalid status or date format GET /shopping-lists/{id} # Returns one shopping list by ID.\nExample Request # curl -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/shopping-lists/1 Response 200 — shopping list object.\nErrors\nStatus Cause 404 Shopping list not found POST /shopping-lists # Generates a shopping list from approved ingredient requests for a delivery date.\nIngredient names from the requests are aggregated and sent to the AI normalization service.\nThe targetLanguage determines the language used for the normalized ingredient names in the final shopping list.\nFor example:\nløg onions cebolla With targetLanguage = EN these may all be normalized to:\nOnions\nSupported languages # targetLanguage must be one of the supported values:\nCode Language DA Danish EN English ES Spanish IT Italian PT Portuguese FR French DE German PL Polish NL Dutch If an unsupported value is provided, the system defaults to EN (English).\nRequest body\nField Type Description deliveryDate LocalDate Delivery date for the shopping list targetLanguage String Language used for AI ingredient normalization { \u0026#34;deliveryDate\u0026#34;: \u0026#34;2026-04-01\u0026#34;, \u0026#34;targetLanguage\u0026#34;: \u0026#34;DA\u0026#34; } Example Request # curl -X POST \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{ \u0026#34;deliveryDate\u0026#34;: \u0026#34;2026-04-01\u0026#34;, \u0026#34;targetLanguage\u0026#34;: \u0026#34;DA\u0026#34; }\u0026#39; \\ https://miseos.corral.dk/api/v1/shopping-lists Response 201 — generated shopping list object.\nErrors\nStatus Cause 400 Invalid payload (missing deliveryDate or targetLanguage) 409 Shopping list already exists for that date 409 No approved ingredient requests for that date POST /shopping-lists/{id}/finalize # Finalizes a shopping list. All items must be marked as ordered.\nExample Request # curl -X POST \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/shopping-lists/1/finalize Response 200 — updated shopping list object with status: FINALIZED.\nErrors\nStatus Cause 404 Shopping list not found 409 Not all items are ordered 409 List is already finalized PATCH /shopping-lists/{id}/delivery-date # Updates delivery date for a draft list.\nRequest body\n{ \u0026#34;deliveryDate\u0026#34;: \u0026#34;2026-04-03\u0026#34; } Example Request # curl -X PATCH \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{ \u0026#34;deliveryDate\u0026#34;: \u0026#34;2026-04-03\u0026#34; }\u0026#39; \\ https://miseos.corral.dk/api/v1/shopping-lists/1/delivery-date Response 200 — updated shopping list object.\nErrors\nStatus Cause 400 Date is missing, invalid, or in the past 404 Shopping list not found 409 Another shopping list already exists for that date 409 List is finalized DELETE /shopping-lists/{id} # Deletes a draft shopping list.\nExample Request # curl -X DELETE \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/shopping-lists/1 Response 204 — no content.\nErrors\nStatus Cause 404 Shopping list not found 409 List is finalized POST /shopping-lists/{id}/items # Adds a manual item to a draft shopping list.\nRequest body\n{ \u0026#34;ingredientName\u0026#34;: \u0026#34;Smør\u0026#34;, \u0026#34;quantity\u0026#34;: 5.0, \u0026#34;unit\u0026#34;: \u0026#34;KG\u0026#34;, \u0026#34;supplier\u0026#34;: \u0026#34;Arla\u0026#34; } The system automatically adds a note like: Manual entry by: \u0026lt;FirstName LastName\u0026gt;.\nExample Request # curl -X POST \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{ \u0026#34;ingredientName\u0026#34;: \u0026#34;Smør\u0026#34;, \u0026#34;quantity\u0026#34;: 5.0, \u0026#34;unit\u0026#34;: \u0026#34;KG\u0026#34;, \u0026#34;supplier\u0026#34;: \u0026#34;Arla\u0026#34; }\u0026#39; \\ https://miseos.corral.dk/api/v1/shopping-lists/1/items Response 201 — updated shopping list object.\nErrors\nStatus Cause 400 Invalid payload 404 Shopping list not found 409 List is finalized PUT /shopping-lists/{id}/items/{itemId} # Updates a shopping list item (draft lists only).\nRequest body\n{ \u0026#34;quantity\u0026#34;: 8.0, \u0026#34;unit\u0026#34;: \u0026#34;KG\u0026#34;, \u0026#34;supplier\u0026#34;: \u0026#34;Ny Leverandør\u0026#34; } quantity and unit are required. supplier is optional.\nExample Request # curl -X PUT \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{ \u0026#34;quantity\u0026#34;: 8.0, \u0026#34;unit\u0026#34;: \u0026#34;KG\u0026#34;, \u0026#34;supplier\u0026#34;: \u0026#34;Ny Leverandør\u0026#34; }\u0026#39; \\ https://miseos.corral.dk/api/v1/shopping-lists/1/items/2 Response 200 — updated shopping list object.\nErrors\nStatus Cause 400 Invalid payload 404 Shopping list or item not found 409 List is finalized DELETE /shopping-lists/{id}/items/{itemId} # Removes an item from a draft list.\nExample Request # curl -X DELETE \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/shopping-lists/1/items/2 Response 200 — updated shopping list object.\nErrors\nStatus Cause 404 Shopping list or item not found 409 List is finalized PATCH /shopping-lists/{id}/items/{itemId}/ordered # Marks one item as ordered.\nExample Request # curl -X PATCH \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/shopping-lists/1/items/2/ordered Response 200 — updated shopping list object.\nErrors\nStatus Cause 404 Shopping list or item not found 409 Item is already marked as ordered PATCH /shopping-lists/{id}/items/ordered # Marks all items in the shopping list as ordered.\nThis is a convenience endpoint used when the entire list has been ordered from suppliers. It performs the same function as marking each item individually but reduces the number of API calls needed.\nExample Request # curl -X PATCH \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/shopping-lists/1/items/ordered Response 200 — updated shopping list object with allOrdered: true.\nErrors\nStatus Cause 404 Shopping list not found 409 One or more items are already marked as ordered ","date":"27 March 2026","externalUrl":null,"permalink":"/docs/miseos-api-doc/shopping-lists/","section":"Docs","summary":"","title":"Shopping Lists","type":"docs"},{"content":"This section documents the station management endpoints of the MiseOS API, which allow for retrieving, creating, updating, and deleting kitchen stations.\nStations represent kitchen sections (Hot Kitchen, Cold Kitchen, Pastry, etc.). Users and dishes can be assigned to stations to organize kitchen responsibilities and workflow.\nStations Endpoints # Method URL Auth GET /stations HEAD_CHEF, SOUS_CHEF GET /stations/{id} HEAD_CHEF, SOUS_CHEF GET /stations/name/{name} HEAD_CHEF, SOUS_CHEF POST /stations HEAD_CHEF PUT /stations/{id} HEAD_CHEF DELETE /stations/{id} HEAD_CHEF Station response object # The station object has the following structure:\n{ \u0026#34;id\u0026#34;: 1, \u0026#34;stationName\u0026#34;: \u0026#34;Cold Kitchen\u0026#34;, \u0026#34;description\u0026#34;: \u0026#34;Salads and starters\u0026#34; } GET /stations # Returns all stations.\nExample Request # curl -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/stations Response 200 — array of station objects.\n[ { \u0026#34;id\u0026#34;: 1, \u0026#34;stationName\u0026#34;: \u0026#34;Hot Kitchen\u0026#34;, \u0026#34;description\u0026#34;: \u0026#34;Main courses and hot dishes\u0026#34; }, { \u0026#34;id\u0026#34;: 2, \u0026#34;stationName\u0026#34;: \u0026#34;Cold Kitchen\u0026#34;, \u0026#34;description\u0026#34;: \u0026#34;Salads and starters\u0026#34; } ] GET /stations/{id} # Returns a station by ID.\nExample Request # curl -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/stations/1 Response 200 — station object.\nErrors\nStatus Cause 404 Station not found GET /stations/name/{name} # Returns a station by exact name match.\nThe name comparison is case-insensitive.\nExample Request # curl -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/stations/name/Cold%20Kitchen Response 200 — station object.\nErrors\nStatus Cause 404 Station not found POST /stations # Creates a new station. Station names must be unique.\nRequest body\n{ \u0026#34;stationName\u0026#34;: \u0026#34;Grill\u0026#34;, \u0026#34;description\u0026#34;: \u0026#34;Steaks and BBQ\u0026#34; } Response 201 — created station object.\nErrors\nStatus Cause 400 Invalid input — missing or empty stationName 409 Station name already exists PUT /stations/{id} # Updates a station\u0026rsquo;s name and description. Station names must be unique.\nRequest body — same shape as create.\nResponse 200 — updated station object.\nErrors\nStatus Cause 400 Invalid input — missing or empty stationName 404 Station not found 409 Station name already exists DELETE /stations/{id} # Deletes a station. Deletion is blocked if the station has assigned users or dishes.\nResponse 204 — no content.\nErrors\nStatus Cause 409 Station has assigned users or dishes ","date":"27 March 2026","externalUrl":null,"permalink":"/docs/miseos-api-doc/stations/","section":"Docs","summary":"","title":"Stations","type":"docs"},{"content":"This section documents the user management endpoints of the MiseOS API, which allow for retrieving, updating, and deleting user accounts.\nKitchen-related endpoints often use the role KITCHEN_STAFF. This is a role group representing any kitchen role:\nHEAD_CHEF SOUS_CHEF LINE_COOK Users with any of these roles are considered kitchen staff and can access endpoints marked with KITCHEN_STAFF. Each role may still have specific permissions depending on the endpoint.\nEndpoints # Method URL Auth GET /users HEAD_CHEF GET /users/me KITCHEN_STAFF GET /users/{id} HEAD_CHEF, SOUS_CHEF PUT /users/{id} KITCHEN_STAFF PATCH /users/{id}/role HEAD_CHEF PATCH /users/{id}/email KITCHEN_STAFF PATCH /users/{id}/password KITCHEN_STAFF PATCH /users/{id}/station/{stationId} HEAD_CHEF, SOUS_CHEF DELETE /users/{id} HEAD_CHEF Headers # Header Description Authorization Required for all endpoints in this section. Format: Bearer {token}. User response object # { \u0026#34;id\u0026#34;: 1, \u0026#34;firstName\u0026#34;: \u0026#34;Gordon\u0026#34;, \u0026#34;lastName\u0026#34;: \u0026#34;Ramsay\u0026#34;, \u0026#34;email\u0026#34;: \u0026#34;gordon.ramsay@kitchen.com\u0026#34;, \u0026#34;userRole\u0026#34;: \u0026#34;HEAD_CHEF\u0026#34;, \u0026#34;station\u0026#34;: { \u0026#34;id\u0026#34;: 1, \u0026#34;name\u0026#34;: \u0026#34;Hot Kitchen\u0026#34; }, \u0026#34;createdAt\u0026#34;: \u0026#34;2026-01-01T12:00:00Z\u0026#34; } GET /users # Returns all users. Sorted alphabetically by first name.\nExample Request # curl -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/users Response 200 — array of user objects.\n[ { \u0026#34;id\u0026#34;: 1, \u0026#34;firstName\u0026#34;: \u0026#34;Gordon\u0026#34;, \u0026#34;lastName\u0026#34;: \u0026#34;Ramsay\u0026#34;, \u0026#34;email\u0026#34;: \u0026#34;gordon.ramsay@kitchen.com\u0026#34;, \u0026#34;userRole\u0026#34;: \u0026#34;HEAD_CHEF\u0026#34;, \u0026#34;station\u0026#34;: { \u0026#34;id\u0026#34;: 1, \u0026#34;name\u0026#34;: \u0026#34;Hot Kitchen\u0026#34; }, \u0026#34;createdAt\u0026#34;: \u0026#34;2026-01-01T12:00:00Z\u0026#34; }, { \u0026#34;id\u0026#34;: 2, \u0026#34;firstName\u0026#34;: \u0026#34;Marco\u0026#34;, \u0026#34;lastName\u0026#34;: \u0026#34;Pierre\u0026#34;, \u0026#34;email\u0026#34;: \u0026#34;marco@kitchen.com\u0026#34;, \u0026#34;userRole\u0026#34;: \u0026#34;LINE_COOK\u0026#34;, \u0026#34;station\u0026#34;: { \u0026#34;id\u0026#34;: 2, \u0026#34;name\u0026#34;: \u0026#34;Cold Kitchen\u0026#34; }, \u0026#34;createdAt\u0026#34;: \u0026#34;2026-01-01T12:00:00Z\u0026#34; }, ... ] GET /users/me # Returns the profile of the currently authenticated user.\nExample Request # curl -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/users/me Response 200 — user object.\nGET /users/{id} # Returns a user by ID. A head chef can access any profile.\nPath parameters\nParameter Type Description id Long User ID Example Request # curl -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/users/12 Response 200 — user object.\nErrors\nStatus Cause 404 User not found PUT /users/{id} # Updates a user\u0026rsquo;s name and station. A line cook can only update their own profile. A head chef can update any profile.\nRequest body\n{ \u0026#34;firstName\u0026#34;: \u0026#34;Gordon\u0026#34;, \u0026#34;lastName\u0026#34;: \u0026#34;Ramsay\u0026#34;, \u0026#34;stationId\u0026#34;: 2 } Response 200 — updated user object.\nErrors\nStatus Cause 403 Attempting to update another user\u0026rsquo;s profile without head chef role 404 User or station not found PATCH /users/{id}/role # Changes a user\u0026rsquo;s role. Only head chef can perform this action.\nRequest body\n{ \u0026#34;userRole\u0026#34;: \u0026#34;SOUS_CHEF\u0026#34; } Response 200 — updated user object.\nPATCH /users/{id}/email # Changes a user\u0026rsquo;s email. Users can only change their own email.\nRequest body\n{ \u0026#34;email\u0026#34;: \u0026#34;new@kitchen.com\u0026#34; } Response 200 — updated user object.\nErrors\nStatus Cause 403 Attempting to change another user\u0026rsquo;s email 409 Email already in use PATCH /users/{id}/password # Changes a user\u0026rsquo;s password. Requires the current password. Users can only change their own password.\nRequest body\n{ \u0026#34;currentPassword\u0026#34;: \u0026#34;OldPassword123\u0026#34;, \u0026#34;newPassword\u0026#34;: \u0026#34;NewPassword123\u0026#34; } Response 200 — updated user object.\nErrors\nStatus Cause 400 Current password incorrect or new password does not meet requirements 403 Attempting to change another user\u0026rsquo;s password PATCH /users/{id}/station/{stationId} # Assigns a user to a station.\nPath parameters\nParameter Type Description id Long User ID stationId Long Station ID Example Request # curl -X PATCH \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/users/5/station/2 Response 200 — updated user object.\n{ \u0026#34;id\u0026#34;: 5, \u0026#34;firstName\u0026#34;: \u0026#34;Marco\u0026#34;, \u0026#34;lastName\u0026#34;: \u0026#34;Pierre\u0026#34;, \u0026#34;email\u0026#34;: \u0026#34;marco@kitchen.com\u0026#34;, \u0026#34;userRole\u0026#34;: \u0026#34;LINE_COOK\u0026#34;, \u0026#34;station\u0026#34;: { \u0026#34;id\u0026#34;: 2, \u0026#34;name\u0026#34;: \u0026#34;Cold Kitchen\u0026#34; } } DELETE /users/{id} # Permanently deletes a user. A head chef cannot delete their own account.\nExample Request # curl -X DELETE \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/users/5 Response 204 — no content.\nErrors\nStatus Cause 400 Attempting to delete own account 404 User not found ","date":"27 March 2026","externalUrl":null,"permalink":"/docs/miseos-api-doc/users/","section":"Docs","summary":"","title":"Users","type":"docs"},{"content":"This section documents the weekly menu endpoints of the MiseOS API.\nWeekly menus are planned by head chefs and sous chefs. A menu is created for a specific ISO week and year, then filled with slots (day + station + optional dish).\nA menu slot represents a station assignment for a specific day and can optionally reference a dish from the dish bank. Menus can be translated to multiple languages and later published for public access. Only published menus are visible to guests.\nGuests can only view published menus. Management can also view drafts.\nWeekly menu lifecycle # stateDiagram-v2 [*] --\u003e DRAFT : menu created DRAFT --\u003e PUBLISHED : publish PUBLISHED --\u003e [*] : menu visible to guests A menu is initially created in the DRAFT state and can be modified by management until it is published. Once published, the menu becomes visible to guests and kitchen staff without draft access.\nWeekly Menus Endpoints # Method URL Auth GET /weekly-menus HEAD_CHEF, SOUS_CHEF GET /weekly-menus/current ANYONE GET /weekly-menus/by-week KITCHEN_STAFF, ANYONE GET /weekly-menus/{id} HEAD_CHEF, SOUS_CHEF POST /weekly-menus HEAD_CHEF, SOUS_CHEF DELETE /weekly-menus/{id} HEAD_CHEF, SOUS_CHEF POST /weekly-menus/{id}/slots HEAD_CHEF, SOUS_CHEF PUT /weekly-menus/{id}/slots/{slotId} HEAD_CHEF, SOUS_CHEF DELETE /weekly-menus/{id}/slots/{slotId} HEAD_CHEF, SOUS_CHEF POST /weekly-menus/{id}/slots/{slotId}/translate HEAD_CHEF, SOUS_CHEF POST /weekly-menus/{id}/translate HEAD_CHEF, SOUS_CHEF POST /weekly-menus/{id}/publish HEAD_CHEF, SOUS_CHEF Role-based visibility (GET /weekly-menus/by-week)\nGuests and line cooks only see PUBLISHED menus Head chefs and sous chefs can see both DRAFT and PUBLISHED WeeklyMenu response object # { \u0026#34;menuId\u0026#34;: 1, \u0026#34;weekNumber\u0026#34;: 20, \u0026#34;year\u0026#34;: 2026, \u0026#34;menuStatus\u0026#34;: \u0026#34;DRAFT\u0026#34;, \u0026#34;publishedAt\u0026#34;: null, \u0026#34;menuCreatedBy\u0026#34;: { \u0026#34;id\u0026#34;: 1, \u0026#34;firstName\u0026#34;: \u0026#34;Gordon\u0026#34;, \u0026#34;lastName\u0026#34;: \u0026#34;Ramsay\u0026#34; }, \u0026#34;menuSlots\u0026#34;: [ { \u0026#34;menuSlotId\u0026#34;: 1, \u0026#34;dayOfWeek\u0026#34;: \u0026#34;MONDAY\u0026#34;, \u0026#34;station\u0026#34;: { \u0026#34;id\u0026#34;: 1, \u0026#34;name\u0026#34;: \u0026#34;Cold Kitchen\u0026#34; }, \u0026#34;menuDish\u0026#34;: { \u0026#34;id\u0026#34;: 1, \u0026#34;nameDA\u0026#34;: \u0026#34;Røget Laks\u0026#34;, \u0026#34;nameEN\u0026#34;: \u0026#34;Smoked Salmon\u0026#34;, \u0026#34;descriptionDA\u0026#34;: \u0026#34;Laks med dildcreme\u0026#34;, \u0026#34;descriptionEN\u0026#34;: \u0026#34;Salmon with dill cream\u0026#34;, \u0026#34;hasTranslation\u0026#34;: true, \u0026#34;allergens\u0026#34;: [] } } ], \u0026#34;numberOfSlots\u0026#34;: 1 } Status values: DRAFT | PUBLISHED\nDay values: MONDAY | TUESDAY | WEDNESDAY | THURSDAY | FRIDAY\nWeeklyMenuOverview response object # Returned by GET /weekly-menus (lightweight list).\n{ \u0026#34;id\u0026#34;: 1, \u0026#34;weekNumber\u0026#34;: 20, \u0026#34;year\u0026#34;: 2026, \u0026#34;menuStatus\u0026#34;: \u0026#34;DRAFT\u0026#34;, \u0026#34;slotCount\u0026#34;: 8, \u0026#34;publishedAt\u0026#34;: null } GET /weekly-menus # Returns weekly menu overviews. Supports optional filtering.\nQuery parameters\nParameter Type Description status String Filter by DRAFT or PUBLISHED year Integer Filter by year week Integer Filter by ISO week number Example Request # curl -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ \u0026#34;https://miseos.corral.dk/api/v1/weekly-menus?status=PUBLISHED\u0026amp;year=2026\u0026amp;week=20\u0026#34; Response 200 — array of WeeklyMenuOverview objects.\nGET /weekly-menus/current # Returns the published menu for the current ISO week.\nExample Request # curl https://miseos.corral.dk/api/v1/weekly-menus/current Response 200 — WeeklyMenu object.\nErrors\nStatus Cause 404 No published menu found for current week GET /weekly-menus/by-week # Returns menu by week and year.\nQuery parameters\nParameter Type Description week Integer ISO week number (required) year Integer Year (required) Example Request # curl \u0026#34;https://miseos.corral.dk/api/v1/weekly-menus/by-week?week=20\u0026amp;year=2026\u0026#34; Response 200 — WeeklyMenu object.\nErrors\nStatus Cause 400 Missing/invalid week or year 404 No menu found for requested week/year GET /weekly-menus/{id} # Returns a specific weekly menu by ID.\nExample Request # curl -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ https://miseos.corral.dk/api/v1/weekly-menus/5 Response 200 — WeeklyMenu object.\nErrors\nStatus Cause 400 Invalid ID 404 Menu not found POST /weekly-menus # Creates a new draft menu for a specific week and year.\nRequest body\n{ \u0026#34;week\u0026#34;: 20, \u0026#34;year\u0026#34;: 2026 } Response 201 — created WeeklyMenu object.\nErrors\nStatus Cause 400 Invalid week/year 409 Menu for that week/year already exists DELETE /weekly-menus/{id} # Deletes a weekly menu.\nDomain rule validation is applied by the menu entity/service (for example who can delete and menu state).\nResponse 204 — no content.\nErrors\nStatus Cause 400 Invalid ID or delete not allowed by domain rules 404 Menu not found 409 Conflict with current menu state/business rules POST /weekly-menus/{id}/slots # Adds a slot to a menu (dayOfWeek + stationId + optional dishId).\nEach menu can contain multiple slots across days and stations.\nRequest body\n{ \u0026#34;dayOfWeek\u0026#34;: \u0026#34;MONDAY\u0026#34;, \u0026#34;stationId\u0026#34;: 1, \u0026#34;dishId\u0026#34;: 1 } dishId is optional. If omitted/null, an empty slot is created.\nResponse 201 — updated WeeklyMenu object.\nErrors\nStatus Cause 400 Invalid input or dish/station mismatch 404 Menu, station, or dish not found 409 Dish is inactive PUT /weekly-menus/{id}/slots/{slotId} # Updates a slot dish assignment.\nRequest body\n{ \u0026#34;dishId\u0026#34;: 2 } Set dishId to null to clear the slot.\nResponse 200 — updated WeeklyMenu object.\nErrors\nStatus Cause 400 Invalid input 404 Menu, slot, or dish not found 409 Dish is inactive DELETE /weekly-menus/{id}/slots/{slotId} # Removes a slot from the menu.\nResponse 200 — updated WeeklyMenu object.\nErrors\nStatus Cause 400 Invalid ID 404 Menu or slot not found POST /weekly-menus/{id}/slots/{slotId}/translate # Translates the dish in a single slot.\nIf an unsupported or missing language code is provided, the API defaults to English (EN).\nQuery parameters\nParameter Type Description lang String Target language code. Supported values: DA, EN, ES, IT, PT, FR, DE, PL, NL Example Request # curl -X POST \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ \u0026#34;https://miseos.corral.dk/api/v1/weekly-menus/5/slots/20/translate?lang=EN\u0026#34; Response 200 — updated WeeklyMenu object.\nErrors\nStatus Cause 400 Missing/invalid lang 404 Menu or slot not found 409 Slot has no dish to translate POST /weekly-menus/{id}/translate # Translates all dishes in the menu in one batch operation.\nIf an unsupported or missing language code is provided, the API defaults to English (EN).\nQuery parameters\nParameter Type Description lang String Target language code. Supported values: DA, EN, ES, IT, PT, FR, DE, PL, NL Example Request # curl -X POST \\ -H \u0026#34;Authorization: Bearer \u0026lt;token\u0026gt;\u0026#34; \\ \u0026#34;https://miseos.corral.dk/api/v1/weekly-menus/5/translate?lang=EN\u0026#34; Response 200 — updated WeeklyMenu object.\nErrors\nStatus Cause 400 Missing/invalid lang 404 Menu not found 502 Translation provider error POST /weekly-menus/{id}/publish # Publishes a weekly menu.\nPublish validation includes:\nMenu must not be empty All assigned dishes must have translations Response 200 — updated WeeklyMenu object with menuStatus: PUBLISHED.\nErrors\nStatus Cause 400 Invalid ID 404 Menu not found 409 Menu is empty or has untranslated dishes ","date":"27 March 2026","externalUrl":null,"permalink":"/docs/miseos-api-doc/weekly-menus/","section":"Docs","summary":"","title":"Weekly Menus","type":"docs"},{"content":"","date":"20 March 2026","externalUrl":null,"permalink":"/tags/authentication/","section":"Tags","summary":"","title":"Authentication","type":"tags"},{"content":"","date":"20 March 2026","externalUrl":null,"permalink":"/tags/authorization/","section":"Tags","summary":"","title":"Authorization","type":"tags"},{"content":"","date":"20 March 2026","externalUrl":null,"permalink":"/tags/bcrypt/","section":"Tags","summary":"","title":"Bcrypt","type":"tags"},{"content":"For the past several weeks, services identified the caller in a simple way: controllers passed a userId into service methods. The service then loaded the User entity and checked role rules (head chef, sous chef, line cook).\nIt worked, but it was not secure. Any client could fake that id, and it added unnecessary database reads.\nJWT was this week’s theory in class, so this was the right moment to implement authentication properly.\nWhy I Built My Own Implementation # In class, we used an external library — dk.bugelhartmann.TokenSecurity — that wraps Nimbus JOSE+JWT. It works well for the general case, but it stores roles as a comma-joined string and returns a UserDTO from its own package.\nMy domain has a single UserRole enum per user — a cook holds one position in the kitchen hierarchy, not a collection of permissions. Storing that as a comma-joined string felt like fitting my model into the library\u0026rsquo;s assumptions rather than the other way around. Building directly on Nimbus meant the token structure could reflect the my actual domain.\nThe tradeoff is real though. A set of roles is the more flexible design — if a future requirement added a PURCHASING_MANAGER who needs head chef permissions on some endpoints but not others, a single role would not cover it cleanly. For MiseOS, the kitchen hierarchy is fixed and mutually exclusive by definition, so a single role is the right fit. But it is worth knowing what was traded away.\nThe other reason for building from scratch was understanding. JWT can be treated as a black box — call a library method, get a token back. I wanted to know what is actually in the token, how the signature works, and what happens when verification fails. Sixty lines in SecurityService and I understand every one of them. The exceptions it throws are mine, not generic library errors that map poorly to the rest of the exception hierarchy.\nPassword Hashing # Before authentication can work, passwords need to be stored safely. PasswordUtil wraps BCrypt implementations for hashing and verification:\npublic class PasswordUtil { public static String hashPassword(String plainPassword, int cost) { return BCrypt.hashpw(plainPassword, BCrypt.gensalt(cost)); } public static boolean verifyPassword(String plainText, String hashed) { return BCrypt.checkpw(plainText, hashed); } } BCrypt is intentionally slow — roughly 100ms per hash at the default cost factor. That is the feature, not a limitation. It is negligible for a legitimate login but makes brute-force attacks expensive. The salt is embedded in the hash output so there is nothing extra to store.\nThe hashedPassword field on the User entity has no @Getter. That is intentional — the hash can never accidentally appear in a serialized API response.\nPassword verification sits on the User entity as a domain method:\npublic boolean verifyPassword(String plainTextPassword) { return PasswordUtil.verifyPassword(plainTextPassword, this.hashedPassword); } Password hashing flow # The registration process ensures that a plain-text password never leaves the service layer. The sequence below shows how the password is transformed before it reaches the database.\nsequenceDiagram participant Client participant UserController participant UserService participant PasswordUtil participant UserDAO Client-\u003e\u003eUserController: POST /register (email, password) activate UserController UserController-\u003e\u003eUserService: registerUser(dto) activate UserService UserService-\u003e\u003ePasswordUtil: hashPassword(plainText) PasswordUtil--\u003e\u003eUserService: hashedPassword UserService-\u003e\u003eUserDAO: save(User with hashedPassword) UserDAO--\u003e\u003eUserService: persisted UserService--\u003e\u003eUserController: UserDTO deactivate UserService UserController--\u003e\u003eClient: 201 Created deactivate UserController SecurityService — Login, Token Creation, Verification # Everything authentication-related lives in SecurityService. That was a deliberate SRP decision: the controller handles HTTP translation, the service handles identity logic. The controller has no knowledge of how tokens are built or verified.\nLogin # public LoginResponseDTO login(LoginRequestDTO dto) { User user = userReader.findByEmail(dto.email()) .orElseThrow(() -\u0026gt; new AuthenticationException(\u0026#34;Invalid email or password\u0026#34;)); if (!user.verifyPassword(dto.password())) { logger.warn(\u0026#34;Login failed — wrong password for: {}\u0026#34;, dto.email()); throw new AuthenticationException(\u0026#34;Invalid email or password\u0026#34;); } String token = createToken(user.getId(), user.getEmail(), user.getUserRole().name()); return new LoginResponseDTO(token, user.getEmail(), user.getUserRole().name()); } Both \u0026ldquo;email not found\u0026rdquo; and \u0026ldquo;wrong password\u0026rdquo; return the same message. That is intentional — different messages leak information about which emails exist in the system.\nToken Creation # public String createToken(Long userId, String email, String role) { JWTClaimsSet claims = new JWTClaimsSet.Builder() .subject(email) .issuer(issuer) .claim(\u0026#34;userId\u0026#34;, userId) .claim(\u0026#34;email\u0026#34;, email) .claim(\u0026#34;role\u0026#34;, role) .expirationTime(new Date(System.currentTimeMillis() + expirationMs)) .issueTime(new Date()) .build(); JWSObject jwsObject = new JWSObject( new JWSHeader(JWSAlgorithm.HS256), new Payload(claims.toJSONObject()) ); jwsObject.sign(new MACSigner(secretKey)); return jwsObject.serialize(); } The token is signed with HS256. The secret key never leaves the server. It is loaded from an environment variable and never committed to the repository. If someone modifies the userId claim to impersonate another user, signature verification fails immediately.\nThree claims are included deliberately: userId because some service operation needs it and a database round trip to fetch it on every request would be wasteful, email for logging, and role because the authorization filter needs it on every request.\nVerification # public AuthenticatedUser verifyAndExtract(String token) { SignedJWT jwt = SignedJWT.parse(token); if (!jwt.verify(new MACVerifier(secretKey))) throw new AuthenticationException(\u0026#34;Token signature invalid\u0026#34;); JWTClaimsSet claims = getJwtClaimsSet(jwt); // expiry, notBefore, issuer UserRole userRole = parseUserRoleClaim(claims.getStringClaim(\u0026#34;role\u0026#34;)); return new AuthenticatedUser( claims.getLongClaim(\u0026#34;userId\u0026#34;), claims.getStringClaim(\u0026#34;email\u0026#34;), userRole ); } getJwtClaimsSet checks three things: expiry, notBefore, and issuer. Here they are part of one operation, and if anything fails, the error message is specific.\nSecurity config is also validated at startup. If secret/issuer is invalid (or secret is too short), the app fails fast.\nif (secretKey.getBytes().length \u0026lt; 32) throw new IllegalStateException(\u0026#34;JWT secret key too short. Use at least 32 bytes\u0026#34;); SecurityController — Two Responsibilities # The controller runs as middleware before every matched route, and also handles the login request.\npublic void authenticate(Context ctx) { Set\u0026lt;String\u0026gt; allowedRoles = getAllowedRoles(ctx); if (allowedRoles.isEmpty() || allowedRoles.contains(\u0026#34;ANYONE\u0026#34;)) return; String header = ctx.header(\u0026#34;Authorization\u0026#34;); validateHeader(header); String token = header.substring(7); AuthenticatedUser authUser = securityService.verifyAndExtract(token); ctx.attribute(\u0026#34;authUser\u0026#34;, authUser); } public void authorize(Context ctx) { Set\u0026lt;String\u0026gt; allowedRoles = getAllowedRoles(ctx); if (allowedRoles.isEmpty() || allowedRoles.contains(\u0026#34;ANYONE\u0026#34;)) return; AuthenticatedUser authUser = ctx.attribute(\u0026#34;authUser\u0026#34;); requireUserNotNull(authUser); if (allowedRoles.contains(\u0026#34;KITCHEN_STAFF\u0026#34;)) { if (!authUser.isKitchenStaff()) throw new UnauthorizedActionException(\u0026#34;Kitchen staff only\u0026#34;); return; } if (!allowedRoles.contains(authUser.userRole().name())) { logger.warn(\u0026#34;[{}] Authorization failed: {} has role {} but needs {}\u0026#34;, ctx.attribute(\u0026#34;request-id\u0026#34;), authUser.email(), authUser.userRole(), allowedRoles); throw new UnauthorizedActionException(\u0026#34;Insufficient role. Required: \u0026#34; + allowedRoles); } } Both filters are registered in ServerConfig:\nconfig.routes.beforeMatched(securityController::authenticate); config.routes.beforeMatched(securityController::authorize); beforeMatched only fires when a route matched. Using before instead would run the auth filter on 404 responses too — unnecessary noise and complexity.\nRequest lifecycle with authentication \u0026amp; authorization # flowchart TD User([Client / Browser]) --\u003e Req[HTTP Request] Req --\u003e Log[before: Request Logging] Log --\u003e Auth{Authenticate} Auth -- \"No Token / Invalid\" --\u003e R401[401 Unauthorized] Auth -- \"Valid Token\" --\u003e Role{Authorize} Role -- \"Insufficient Role\" --\u003e R403[403 Forbidden] Role -- \"Role Allowed\" --\u003e Logic[Controller \u0026 Service] Logic --\u003e DB[(Database)] DB --\u003e Logic Logic --\u003e R200[200/201 Success] R401 --\u003e Resp[HTTP Response] R403 --\u003e Resp R200 --\u003e Resp Resp --\u003e User If the \u0026lsquo;Authenticate\u0026rsquo; gate fails, the request dies immediately with a 401. If it passes, the token is \u0026lsquo;unpacked\u0026rsquo; into an AuthenticatedUser object and stored in the Javalin context attributes. This object acts as a verified passport—it follows the request into the Controller and Service layers, allowing them to make logic decisions (like \u0026lsquo;Can this user see draft menus?\u0026rsquo;) without ever having to re-verify who the user is.\u0026quot;\nAuthenticatedUser — Replacing the userId Header # The old header was a Long that anyone could set to any value. The replacement is a record built from a verified, signed token:\npublic record AuthenticatedUser(Long userId, String email, UserRole userRole) { public boolean isHeadChef() { return userRole == UserRole.HEAD_CHEF; } public boolean isSousChef() { return userRole == UserRole.SOUS_CHEF; } public boolean isLineCook() { return userRole == UserRole.LINE_COOK; } public boolean isKitchenStaff() { return isHeadChef() || isSousChef() || isLineCook(); } } authenticate() After token verification, authenticate() puts an AuthenticatedUser on the Javalin context. Controllers read it directly:\nAuthenticatedUser authUser = SecurityUtil.getAuthenticatedUser(ctx); And services that need a user, for business logic or user relation in other domains, gets its as a parameter:\npublic interface IDishSuggestionService { DishSuggestionDTO createSuggestion(AuthenticatedUser authUser, DishSuggestionCreateDTO dto); DishSuggestionDTO approveSuggestion(AuthenticatedUser authUser, Long dishId); DishSuggestionDTO rejectSuggestion(AuthenticatedUser authUser, Long dishId, String feedback); } Refactoring the Service Layer # In the previous implementation, services often fetched User from DB just to check role. That was useful early on, but inefficient: one extra query purely for authorization.\nWith middleware-based authorization, route annotations now enforce coarse access before a request reaches the service. That removed many redundant role-check reads.\nExample of efficient filtering using token identity:\nLong creatorId = authUser.isHeadChef() || authUser.isSousChef() ? null : authUser.userId(); Ownership checks are now simple ID comparisons against already loaded resources:\nboolean isCreator = suggestion.getCreatedBy().getId().equals(authUser.userId()); boolean isHeadChefOrSousChef = authUser.isHeadChef() || authUser.isSousChef(); if (!isCreator \u0026amp;\u0026amp; !isHeadChefOrSousChef) throw new UnauthorizedActionException(\u0026#34;You can only update your own suggestions\u0026#34;); When a full User entity is genuinely needed (e.g., createdBy, reviewer reference), DB lookup still happens — but now only where it adds domain value.\nThe Tricky Part — Same Endpoint, Different Responses # The most interesting design challenge was endpoints that multiple roles can reach but where the response differs by role. The weekly menu by-week endpoint is the clearest example:\nget(\u0026#34;/by-week\u0026#34;, weeklyMenuController::getByWeekAndYear, Role.KITCHEN_STAFF, Role.ANYONE); A guest (ANYONE) can only see published menus. A head chef can see drafts too. The route annotation cannot express that — it only controls access. The service handles the distinction:\nprivate MenuStatus getMenuStatusPermission(AuthenticatedUser authUser) { if (authUser == null) return MenuStatus.PUBLISHED; // guest — no token if (authUser.isHeadChef() || authUser.isSousChef()) return null; // management — all statuses return MenuStatus.PUBLISHED; // line cook, customer } authUser is null when the endpoint is hit without a token — ANYONE routes skip the authenticate filter entirely. For these cases, there are two methods in SecurityUtil:\n// Protected endpoints — throws if null AuthenticatedUser authUser = SecurityUtil.getAuthenticatedUser(ctx); // Public endpoints — returns null, let the service decide AuthenticatedUser authUser = SecurityUtil.getOptionalAuthenticatedUser(ctx); Updating the Tests # Every REST-assured test that previously used X-Dev-User-Id now goes through real login. A test utility class hits the actual login endpoint and returns a ready-to-use Bearer token:\nheadChefToken = TestAuthenticationUtil.bearerToken(\u0026#34;gordon@kitchen.com\u0026#34;, \u0026#34;Hash1\u0026#34;); lineCookToken = TestAuthenticationUtil.bearerToken(\u0026#34;claire@pastry.com\u0026#34;, \u0026#34;Hash2\u0026#34;); Tokens can now be used in tests like this:\n@Test @DisplayName(\u0026#34;Head chef generates shopping list from approved requests\u0026#34;) void generatesShoppingList() { LocalDate date = LocalDate.now().plusDays(7); String payload = getCreateListPayload(date); ShoppingListDTO response = given() .header(\u0026#34;Authorization\u0026#34;, headChefToken) .contentType(ContentType.JSON) .body(payload) Global tokens gave an easy way to test protected endpoints. The tokens are generated on every test run, so they are always valid and reflect the current user data in the test database.\nOne adjustment in TestPopulator: BCrypt\u0026rsquo;s default cost factor makes test setup noticeably slow. With four users hashed before every test, the overhead is noticeable. Cost factor 4 is the minimum BCrypt allows and hashes in under 10ms — fine for test data that will never face a real attack:\nUser gordon = new User(\u0026#34;Gordon\u0026#34;, \u0026#34;Ramsay\u0026#34;, \u0026#34;gordon@kitchen.com\u0026#34;, PasswordUtil.hashPassword(\u0026#34;Hash1\u0026#34;, 4), UserRole.HEAD_CHEF); Every protected endpoint now also has a @Nested Security class that includes:\nmissing token =\u0026gt; 401 malformed/invalid token =\u0026gt; 401 Token expired =\u0026gt; 401. What Worked Well # Separating SecurityService and SecurityController cleanly kept both focused. The controller reads headers and sets context attributes. The service builds and verifies tokens. Neither leaks into the other\u0026rsquo;s responsibility.\nKITCHEN_STAFF as a convenience role in the Role enum was a good decision. Without it, every endpoint accessible to all three kitchen roles would need three annotations. With it, the common case is one word.\nWhat Was Difficult # The hardest part was deciding where role enforcement belongs when a route is open to multiple roles but the response should differ by role. The route annotation cannot carry that information — it only knows whether to allow or deny. So AuthenticatedUser has to be passed into service methods that would otherwise not need it, purely to inform the filtering logic.\nThe ANYONE case added another layer: a null authUser is valid for public endpoints and must be handled explicitly in the service rather than treated as an error.\nWhat I Would Do Differently # One thing worth understanding but that I did not have time to implement is refresh tokens. The current setup issues a single token with a fixed expiry — when it expires, the user must log in again. For a kitchen tool used during active service that could be a real usability problem.\nIt is on the list to implement — both because it brings real value to the UI and because it is something worth understanding properly.\nNext Steps # With HTTP authentication and role-based authorization now in place, the next step is operational hardening:\nCI/CD with GitHub Actions deployment environment setup This is part 8 of my MiseOS development log. Follow along as I build a tool for professional kitchens, one commit at a time.\n","date":"20 March 2026","externalUrl":null,"permalink":"/posts/security-jwt/","section":"Posts","summary":"","title":"From Fake IDs to Secure Passports: A Journey into JWT Middleware","type":"posts"},{"content":"","date":"20 March 2026","externalUrl":null,"permalink":"/tags/java/","section":"Tags","summary":"","title":"Java","type":"tags"},{"content":"","date":"20 March 2026","externalUrl":null,"permalink":"/tags/jwt/","section":"Tags","summary":"","title":"Jwt","type":"tags"},{"content":"","date":"20 March 2026","externalUrl":null,"permalink":"/tags/security/","section":"Tags","summary":"","title":"Security","type":"tags"},{"content":"","date":"13 March 2026","externalUrl":null,"permalink":"/tags/rest-assured/","section":"Tags","summary":"","title":"Rest-Assured","type":"tags"},{"content":"","date":"13 March 2026","externalUrl":null,"permalink":"/tags/testcontainers/","section":"Tags","summary":"","title":"Testcontainers","type":"tags"},{"content":"","date":"13 March 2026","externalUrl":null,"permalink":"/tags/testing/","section":"Tags","summary":"","title":"Testing","type":"tags"},{"content":"With the controller layer wired up and endpoints live, the next challenge was trust. You can write a service method, call it from a controller, and assume it works — but without tests that exercise the full stack from HTTP request to database response, that assumption is fragile.\nThis week had two goals: replace that assumption with evidence, and add something the system genuinely needed — real-time notifications between the server and the admin dashboard.\nWhy Integration Tests Over Unit Tests Here # For the controller layer specifically, unit tests with mocked services would miss the most common failure modes:\nRoute parameters parsed in the wrong order Query param parsing throwing the wrong exception JSON serialization producing a shape the tests do not expect Database behaviour that differs from what the service assumes REST-assured tests spin up the actual Javalin server and send real HTTP requests to it. When a test passes, it means the full path from URL to database and back works correctly — not just that individual methods return the right values in isolation.\nTestcontainers: A Real Database Without Touching Production # Integration tests only matter if the database behaves like production.\nInstead of mocking the database or relying on an in-memory substitute, every test in this project runs against a real PostgreSQL instance — started automatically inside Docker using Testcontainers.\nTest Isolation with Testcontainers # The diagram shows the full execution path of an integration test.\nA REST-assured test acts as a real client, sending HTTP requests into the Javalin server. From there, the request flows through the application and into a PostgreSQL database running inside a Docker container.\nThe important detail is isolation:\nThe database is created fresh for each test run It is identical to production (PostgreSQL 16) It is destroyed automatically when tests finish It is never connected to real data This means every test runs in a clean, predictable environment while still using the real database engine.\nWith a single JDBC URL change, Testcontainers handles everything — pulling the image, starting the container, and wiring the connection.\nThe TestPopulator: An Investment That Keeps Paying Off # Early in the project I built a TestPopulator class to seed a realistic dataset for DAO tests — stations, users, allergens, dishes, menus, ingredient requests, shopping lists, all with the relationships between them intact.\nAt the time it felt like setup overhead. By the time the controller tests arrived, it was one of the most valuable things in the codebase.\nEvery new controller test class reuses the same populator. One call to populator.populate() gives the test a fully wired kitchen — a head chef, line cooks assigned to stations, approved ingredient requests linked to real dishes, a finalized shopping list ready for conflict tests. The same data that covered edge cases in the DAO tests now covers role enforcement and state conflict tests at the HTTP layer without writing any new setup code.\n@BeforeEach void resetDatabase() { TestCleanDB.truncateTables(emf); TestPopulator populator = new TestPopulator(emf); populator.populate(); seeded = populator.getSeededData(); } The seeded map gives each test direct access to the entities by name rather than querying for them:\n// No database query needed — entity is already in hand ShoppingList finalized = (ShoppingList) seeded.get(\u0026#34;shopping_list_finalized\u0026#34;); Long itemId = finalized.getShoppingListItems().iterator().next().getId(); This kept the tests themselves clean and focused on assertions rather than setup. The upfront investment in a good populator compounds across every test class that follows.\nThe Test Setup Pattern # Every controller test follows the same structure. The server starts once per test class. Before each test, the database is wiped and re-seeded with known data so every test starts from a predictable state:\n@TestInstance(TestInstance.Lifecycle.PER_CLASS) class IngredientRequestControllerTest { @BeforeAll static void startServer() { emf = HibernateTestConfig.getEntityManagerFactory(); app = ApplicationConfig.startServer(TEST_PORT, emf); RestAssured.baseURI = \u0026#34;http://localhost\u0026#34;; RestAssured.port = TEST_PORT; RestAssured.basePath = \u0026#34;/api/v1\u0026#34;; } @BeforeEach void resetDatabase() { TestCleanDB.truncateTables(emf); TestPopulator populator = new TestPopulator(emf); populator.populate(); seeded = populator.getSeededData(); } @AfterAll static void stopServer() { ApplicationConfig.stopServer(app); } } The TestPopulator seeds a realistic dataset covering all scenarios — head chefs, line cooks, dishes, menus, ingredient requests, shopping lists. The same populator runs before every test, so tests can rely on specific entities and relationships existing in the database.\nWhat Gets Tested # Each endpoint group is covered across three categories.\nHappy path — the operation succeeds with valid input and the right role:\n@Test @DisplayName(\u0026#34;Head chef generates shopping list from approved requests\u0026#34;) void generatesShoppingList() { ShoppingListDTO response = given() .header(USER_HEADER, headChefId) .contentType(ContentType.JSON) .body(payload) .when() .post(\u0026#34;/shopping-lists\u0026#34;) .then() .statusCode(201) .extract() .as(ShoppingListDTO.class); assertThat(response.status(), is(ShoppingListStatus.DRAFT)); assertThat(response.items(), is(not(empty()))); } Role enforcement — the operation is blocked for users without the required role:\n@Test @DisplayName(\u0026#34;Line cook cannot generate shopping list — returns 403\u0026#34;) void lineCookCannotGenerate() { given() .header(USER_HEADER, lineCookId) .contentType(ContentType.JSON) .body(payload) .when() .post(\u0026#34;/shopping-lists\u0026#34;) .then() .statusCode(403); } State conflict — the operation is blocked because the resource is in the wrong state:\n@Test @DisplayName(\u0026#34;Cannot delete a finalized shopping list — returns 409\u0026#34;) void cannotDeleteFinalizedList() { given() .header(USER_HEADER, headChefId) .when() .delete(\u0026#34;/shopping-lists/\u0026#34; + finalizedListId) .then() .statusCode(409); } The 409 tests were particularly important. Early on several were returning 400 because IllegalStateException was mapped to the wrong status code. The tests caught it immediately.\nSome tests also assert on error message content — for example, a request with a non-existent user id should return a specific message:\n@Test @DisplayName(\u0026#34;Should fail with 404 when user does not exist\u0026#34;) void getDailyInspirationShouldFailWithNonExistentUser() { given() .header(USER_HEADER, 999) .when() .get(\u0026#34;/daily\u0026#34;) .then() .statusCode(404) .body(\u0026#34;message\u0026#34;, equalToIgnoringCase(\u0026#34;User with ID 999 was not found.\u0026#34;)); } This verifies not just the status code but that the error message contract is stable — useful when building a frontend that displays error feedback to the user.\nA Gotcha: Comparing Dates in Assertions # One issue that came up was asserting on LocalDate fields. When RestAssured deserializes a response, dates without an explicit format annotation come back as arrays:\n\u0026#34;deliveryDate\u0026#34;: [2026, 3, 19] Comparing that to \u0026quot;2026-03-19\u0026quot; as a string always fails. The fix was to extract the full response as a typed DTO and assert on the LocalDate directly:\nList\u0026lt;ShoppingListDTO\u0026gt; response = given() .header(USER_HEADER, headChefId) .queryParam(\u0026#34;deliveryDate\u0026#34;, list.getDeliveryDate().toString()) .get(\u0026#34;/shopping-lists\u0026#34;) .then() .statusCode(200) .extract() .jsonPath() .getList(\u0026#34;.\u0026#34;, ShoppingListDTO.class); assertThat(response.get(0).deliveryDate(), is(list.getDeliveryDate())); Jackson deserializes the array back into a LocalDate correctly when extracting into a typed object. Type-safe extraction sidesteps the problem entirely.\nA Limitation: Testing AI integration endpoints # The AI normalization endpoint is a special case. The output is non-deterministic — Gemini may choose different Danish names for the same ingredients on each run. This makes it impossible to assert on specific values.\nThis was the only endpoint where I accepted that automated tests could only cover the deterministic parts — status codes, response shape, presence of certain fields — while the actual AI output needs manual verification in logs.\nThe menu suggestions are tested on the /menu-inspirations/daily endpoint. The test verifies that the response contains 10 suggestions, each with a non-null name and description, but it does not assert on the specific content of those fields:\n@Test @DisplayName(\u0026#34;GET /menu-inspirations/daily - Should give 10 dish suggestion from ai client\u0026#34;) void getDailyInspiration() { User claire = (User) seeded.get(\u0026#34;user_claire\u0026#34;); given() .header(USER_HEADER, claire.getId()) .when() .get(\u0026#34;/daily\u0026#34;) .then() .statusCode(200) .body(\u0026#34;.\u0026#34;, hasSize(10)) .body(\u0026#34;nameDA\u0026#34;, everyItem(notNullValue())) .body(\u0026#34;descriptionDA\u0026#34;, everyItem(notNullValue())); } Probably not ideal, but it’s a pragmatic choice given the nature of the endpoint. The deterministic parts are still covered by tests, and the AI output can be verified manually during development and code reviews.\nReal-Time Notifications with WebSockets # With the REST layer tested, the next piece was something the system genuinely needed but REST cannot solve — pushing state changes to clients that did not ask for them.\nThe Problem # When a line cook submits an ingredient request, the head chef has no idea until they manually refresh the page. Staff also get no feedback on whether their request was seen or acted on. In a busy kitchen during service, both of those gaps matter.\nFrom Problem to Solution # At this point, the limitation was clear: REST could not solve this on its own.\nAfter going through the WebSocket documentation in Javalin and seeing a live demonstration of how persistent connections work, the solution naturally split into two distinct patterns:\nA broadcast channel for admins (shared state updates) A direct channel for staff (user-specific feedback) Instead of trying to force everything through a single mechanism, the system uses both — depending on who needs the information and when.\nThe Architecture # The system uses two different communication patterns depending on the situation:\nREST for actions initiated by users WebSocket for pushing updates the client did not ask for The diagrams below show both flows in action.\nBroadcasting updates to admins # When a line cook submits an ingredient request, the flow is straightforward:\nA REST request (POST /ingredient-requests) is sent to the server The server persists the request in the database The server broadcasts a WebSocket message to all connected admin clients The key idea is that the admins did not request this information — the server pushes it to them as soon as the state changes.\nThis keeps the dashboard in sync in real time without polling or manual refresh.\nDirect notifications to staff # The second flow handles targeted notifications.\nWhen a head chef approves a request:\nA REST request (PATCH /ingredient-requests/approve) updates the database The server sends a direct WebSocket message to the specific user who created the request Unlike the admin case, this is not a broadcast — it is a one-to-one message tied to a specific user session.\nTogether, these two flows show the real value of WebSockets: the server can either broadcast updates to many clients or send precise messages to one — instantly and without a new request.\nKeeping Concerns Separated # One design decision worth explaining: the notification system is split into two interfaces.\nINotificationRegistry is used by the controller — it manages who is connected. INotificationSender is used by the service layer — it sends messages without knowing anything about WebSocket internals. One NotificationService class implements both, and the DIContainer wires it to both consumers:\nNotificationService notificationService = new NotificationService(); // Services only see the sender interface ingredientRequestService = new IngredientRequestService(..., notificationService); // Controller only sees the registry interface notificationController = new NotificationController(notificationService, snapshotService); IngredientRequestService just calls notificationSender.broadcastPendingUpdate(...) — it has no knowledge of WebSocket sessions, connection state, or Javalin internals.\nThe Snapshot Endpoint # When an admin first loads the dashboard their WebSocket connection is brand new — they have no idea how many items accumulated while they were away. A REST snapshot endpoint solves the initial load:\nGET /notifications/snapshot → {\u0026#34;pendingDishSuggestions\u0026#34;: 3, \u0026#34;pendingIngredientRequests\u0026#34;: 7, \u0026#34;totalPending\u0026#34;: 10} The dashboard calls this once on load to populate the badges, then WebSocket takes over and keeps them current from that point forward.\nIf the socket reconnects, the client can call the snapshot endpoint again to resync state safely.\nTesting It # WebSocket endpoints cannot be tested with .http files. During development, wscat from the terminal worked well:\nwscat -c \u0026#34;ws://localhost:7070/api/v1/notifications?role=HEAD_CHEF\u0026amp;userId=1\u0026#34; Send a REST request, and the admin terminal receives the broadcast within milliseconds:\n{\u0026#34;notificationType\u0026#34;:\u0026#34;PENDING_COUNT_UPDATED\u0026#34;,\u0026#34;category\u0026#34;:\u0026#34;INGREDIENT_REQUEST\u0026#34;,\u0026#34;count\u0026#34;:2,\u0026#34;timestamp\u0026#34;:\u0026#34;2026-03-18 20:51\u0026#34;} {\u0026#34;notificationType\u0026#34;:\u0026#34;REQUEST_APPROVED\u0026#34;,\u0026#34;itemName\u0026#34;:\u0026#34;Hvedemel Type 00\u0026#34;,\u0026#34;reviewedBy\u0026#34;:{\u0026#34;id\u0026#34;:1,\u0026#34;firstName\u0026#34;:\u0026#34;Gordon\u0026#34;,\u0026#34;lastName\u0026#34;:\u0026#34;Ramsay\u0026#34;},\u0026#34;timestamp\u0026#34;:\u0026#34;2026-03-18 20:51\u0026#34;} A Note on Message Persistence # One limitation of the current implementation is worth being honest about: if a staff member is not connected when their request gets approved, they never see the notification. WebSocket messages are fire-and-forget — there is no inbox, no history, no replay.\nFor MiseOS in its current scope this is acceptable. A cook who is not logged in will see the updated status when they next load the page. The notification is a convenience, not the source of truth.\nIf I wanted to change that — storing messages so they appear next time a user connects — the architecture is already in the right shape for it. A notifications table, a NotificationDAO, and a query on connect to replay unread messages would be the natural extension. Whether that brings enough business value to justify the effort is a different question, and for a kitchen management tool used during active service hours, I would argue probably not.\nWhat it did give me, regardless: a real understanding of stateful server-side communication, session lifecycle management, and the difference between pushing state versus serving it on request. That carries forward into any future project that needs it.\nWhat I Learned This Week # Integration tests catch bugs unit tests cannot — routing mistakes, serialization mismatches, wrong status codes. Testcontainers is the right answer for database-backed tests — real engine, zero production risk, no leftover state. Type-safe extraction in RestAssured avoids fragile string comparisons on dates and enums. Some things cannot be asserted automatically — for AI output, test the deterministic parts and verify the rest manually. WebSockets and REST solve different problems — REST is request/response, WebSockets are for pushing state changes the client did not ask for. Interface segregation is not just theory — splitting INotificationRegistry and INotificationSender kept the service layer clean and the controller focused. Next Step # Next week security is the focus: implementing authentication and authorization with JWT across the API, replacing the temporary X-Dev-User-Id header and securing both REST and WebSocket entry points.\nThis is part 7 of my MiseOS development log. Follow along as I build a tool for professional kitchens, one commit at a time.\n","date":"13 March 2026","externalUrl":null,"permalink":"/posts/restassured-websockets/","section":"Posts","summary":"","title":"Testing the API Layer and Real-Time Notifications with WebSockets","type":"posts"},{"content":"","date":"13 March 2026","externalUrl":null,"permalink":"/tags/websockets/","section":"Tags","summary":"","title":"Websockets","type":"tags"},{"content":"","date":"6 March 2026","externalUrl":null,"permalink":"/tags/api-design/","section":"Tags","summary":"","title":"Api-Design","type":"tags"},{"content":"","date":"6 March 2026","externalUrl":null,"permalink":"/tags/architecture/","section":"Tags","summary":"","title":"Architecture","type":"tags"},{"content":"","date":"6 March 2026","externalUrl":null,"permalink":"/tags/javalin/","section":"Tags","summary":"","title":"Javalin","type":"tags"},{"content":"Week six of building MiseOS focused on something less flashy than AI suggestions or menu publishing, but just as important: wiring the application together.\nUntil now, most value lived in the domain and service layers. Business rules worked, entities guarded invariants, and services orchestrated workflows. But if HTTP requests cannot reliably reach those services, the architecture is incomplete.\nThis week was about turning the codebase into a real running server:\nControllers translating HTTP into service calls Route modules exposing domain endpoints A DI container wiring dependencies Server configuration and application startup Exception handling with structured error responses Integration tests with REST-assured against real PostgreSQL Avoiding Spaghetti Wiring # One thing I wanted to avoid early was \u0026ldquo;spaghetti wiring\u0026rdquo; — where object creation is scattered everywhere and dependencies become hard to reason about.\nA typical bad path looks like this:\nController → new Service → new DAO → new HTTP Client ↘ another Service It works at first, but over time it becomes fragile, hard to test, and painful to refactor because construction logic leaks into runtime logic.\nIn MiseOS, I avoid this by centralizing all object creation in a DIContainer and keeping setup concerns in dedicated config classes. That way classes focus on behavior, not assembly.\nThe Eye-Opener: Query Params vs Path Params for Filtering # Before this week, many reads were basically getAll().\nWhen I started exposing filters in endpoints, I initially leaned toward path-style variants:\nGET /ingredient-requests/delivery-date/2026-03-06 GET /ingredient-requests/status/PENDING Technically valid, but it felt wrong. Then I remembered the API style from a recent TMDB mini-project: filtering through query params.\nThat pushed me toward:\nGET /ingredient-requests?deliveryDate=2026-03-06 GET /ingredient-requests?status=PENDING GET /ingredient-requests?status=PENDING\u0026amp;deliveryDate=2026-03-06\u0026amp;stationId=2 This was a major design improvement.\nRule I\u0026rsquo;m adopting going forward # Path params identify a concrete resource /ingredient-requests/{id} Query params shape, filter, or sort a collection /ingredient-requests?status=PENDING\u0026amp;stationId=2 Filtering is not a different resource. It is a different projection of the same collection.\nThis also meant refactoring several DAOs away from multiple hardcoded query methods like findByStatus, findByStatusAndDate, getAll — replacing them all with a single findByFilter that accepts nullable parameters. Much cleaner.\nControllers: HTTP Translation Layer (Not Business Logic) # Controllers now do exactly four things:\nRead request data (path, query, body, auth context) Convert to typed inputs/DTOs Delegate to service Return JSON response No business decisions in controller methods.\npublic void getAll(Context ctx) { Long userId = SecurityUtil.requireUserId(ctx); Status status = RequestUtil.getQueryStatus(ctx, \u0026#34;status\u0026#34;); LocalDate deliveryDate = RequestUtil.getQueryDate(ctx, \u0026#34;deliveryDate\u0026#34;); RequestType requestType = RequestUtil.getQueryRequestType(ctx, \u0026#34;requestType\u0026#34;); Long stationId = RequestUtil.getQueryLong(ctx, \u0026#34;stationId\u0026#34;); List\u0026lt;IngredientRequestDTO\u0026gt; requests = ingredientRequestService.getRequests(userId, status, deliveryDate, requestType, stationId); ctx.status(200).json(requests); } RequestUtil: Typed Parameter Parsing # All query and path parameter parsing goes through a shared RequestUtil. The naming convention is intentional:\nrequire* — throws if missing or invalid (mandatory params) get* — returns null if absent (optional filters) // Mandatory — throws 400 if missing or invalid Long id = RequestUtil.requirePathId(ctx, \u0026#34;id\u0026#34;); // Optional — returns null if not provided Status status = RequestUtil.getQueryStatus(ctx, \u0026#34;status\u0026#34;); LocalDate date = RequestUtil.getQueryDate(ctx, \u0026#34;deliveryDate\u0026#34;); No raw ctx.queryParam calls anywhere in controllers. Consistent 400 responses for malformed input across the entire API.\nException Handling: Mapping Domain Exceptions to HTTP # One of the most important decisions this week was establishing a consistent mapping between what goes wrong in the application and what the client receives. Every exception type is handled in one place — the ExceptionController — which keeps error behavior predictable and easy to change.\nThe full mapping:\nException HTTP Status Meaning IllegalArgumentException 400 Bad input — wrong format, out of range ValidationException 400 Business validation failure EntityNotFoundException 404 Resource does not exist UnauthorizedActionException 403 User does not have the required role ConflictException 409 Valid request but conflicts with current state DatabaseException 500 Persistence failure — hides internal detail from client AIIntegrationException 502 External AI service unavailable WeatherIntegrationException 502 External weather service unavailable TranslationException 502 External translation service unavailable A key design point: ConflictException was introduced specifically to distinguish state conflicts from bad input. \u0026ldquo;Cannot delete a published menu\u0026rdquo; or \u0026ldquo;cannot update an approved request\u0026rdquo; are not bad requests — the input is valid, but the current state of the resource makes the operation impossible. That is 409, not 400.\nEvery error response also carries a unique request reference ID, making it possible to match a client error to the exact server log entry:\n{ \u0026#34;status\u0026#34;: 409, \u0026#34;message\u0026#34;: \u0026#34;Cannot delete a finalized shopping list\u0026#34;, \u0026#34;path\u0026#34;: \u0026#34;/api/v1/shopping-lists/3\u0026#34;, \u0026#34;referenceId\u0026#34;: \u0026#34;a6aeef18\u0026#34; } Routes: Modular API Surface per Domain # Instead of one giant route file, endpoints are grouped by domain — UserRoute, StationRoute, DishRoute, WeeklyMenuRoute, IngredientRequestRoute, ShoppingListRoute. Each receives the exact controller it needs and exposes an EndpointGroup.\nOne rule that proved important: static routes must be declared before dynamic routes. In Javalin, if /{id} appears before /current, the string \u0026quot;current\u0026quot; gets matched as an id:\n// Wrong — /{id} catches \u0026#34;current\u0026#34; as an id get(\u0026#34;/{id}\u0026#34;, controller::getById); get(\u0026#34;/current\u0026#34;, controller::getCurrent); // Correct — static routes first get(\u0026#34;/current\u0026#34;, controller::getCurrent); get(\u0026#34;/{id}\u0026#34;, controller::getById); SSE: Streaming AI Suggestions # One endpoint used a different pattern entirely — the MenuInspirationController streams AI dish suggestions using Server-Sent Events instead of a single response:\npublic void getStreamingSuggestions(SseClient client) { client.keepAlive(); Long userId = SecurityUtil.requireUserId(client.ctx()); menuInspirationService.streamDailyInspiration( userId, status -\u0026gt; client.sendEvent(\u0026#34;status\u0026#34;, status), dish -\u0026gt; client.sendEvent(\u0026#34;dish\u0026#34;, dish), () -\u0026gt; { client.sendEvent(\u0026#34;done\u0026#34;, \u0026#34;complete\u0026#34;); client.close(); }, error -\u0026gt; { client.sendEvent(\u0026#34;error\u0026#34;, error.getMessage()); client.close(); } ); } The client receives status messages first (\u0026ldquo;Analyserer vejrdata\u0026hellip;\u0026rdquo;), then individual dish objects as they arrive, then a done event. No polling, no waiting for the full response — the UI feels alive while the AI is thinking.\nHow the Application Starts Up # Starting the application involves a chain of responsibilities. Rather than putting everything in one class, each concern has its own home:\nHibernateConfig sets up the database connection and registers all entities ApiConfig loads external API keys and URLs (Gemini, DeepL, OpenMeteo) ObjectMapperConfig controls how Java objects are converted to and from JSON DIContainer creates all DAOs, services, controllers, and route modules in the right order ServerConfig creates the Javalin server, attaches middleware, routes, and exception handlers ApplicationConfig ties everything together with two simple methods: startServer(port, emf) and stopServer(app) The diagram below shows the full picture — what gets created first, and what depends on what.\nFigure: The full dependency graph of the application. Objects are created from the bottom up — database and external clients first, then DAOs, then services, then controllers, then routes attached to the running server.\nHow a Request Travels Through the System # Once the server is running, every incoming HTTP request follows the same path through the layers. The diagram below traces that journey from the moment a client sends something to the moment a response comes back.\nThe request first hits ServerConfig, where a unique ID is assigned and all incoming details are logged. It is then routed to the appropriate Controller, which reads path params, query params, and the request body and turns them into typed inputs. The Service layer applies the business logic — it may read from or write to the database, or reach out to an external API such as Gemini for AI, OpenMeteo for weather, or DeepL for translation. The result travels back as a structured JSON response, with the request ID appearing in both the response and the server log so the full lifecycle is traceable.\nWhat Was Hard # Optional filter parameters behaved differently depending on which database was running. A common shorthand for optional JPQL filters looked clean on paper:\nWHERE (:status IS NULL OR entity.status = :status) AND (:date IS NULL OR entity.date = :date) It worked fine during development but failed with a cryptic error when the tests ran against the real database. The database could not determine the type of a null date parameter when it had no surrounding context to infer from.\nThe fix was to build the query dynamically — only adding a filter clause when a value is actually provided, and only binding the parameter when the clause exists in the query. More verbose, but it works reliably everywhere:\nif (deliveryDate != null) jpql.append(\u0026#34;AND entity.date = :date \u0026#34;); // ... if (deliveryDate != null) query.setParameter(\u0026#34;date\u0026#34;, deliveryDate); The lesson: test against the same database you run in production. A pattern that works in the development environment can silently fail in the real one.\nWhat I Learned This Week # Wiring is architecture work — it shapes how the whole system evolves, not just how it starts. Query params for filtering made the API more natural and the DAO interface cleaner. Strict layer responsibilities meant less refactoring pain when requirements changed. 400 and 409 are not interchangeable. Bad input is different from state conflict. Test against the same database you deploy to. Development shortcuts hide real problems. Next Step # With routing and controller wiring stable, next focus is expanding API integration tests with REST assured and maybe adding some Websocket endpoints for real-time updates in the UI. The goal is to have a fully tested API layer that can evolve confidently as new features are added.\nMiseOS now feels less like “a set of classes” and more like an actual backend system.\nThis is part 6 of my MiseOS development log. Follow along as I build a tool for professional kitchens, one commit at a time.\n","date":"6 March 2026","externalUrl":null,"permalink":"/posts/controllers-config/","section":"Posts","summary":"","title":"Wiring the Application: Controllers, Routes, and Server Configuration","type":"posts"},{"content":"Week five of building MiseOS. The service layer is where everything connects — HTTP requests come in from controllers, business logic runs, and data goes out through the DAOs. Getting this layer right determines how readable and maintainable the rest of the codebase will be.\nWhat I Built This Week # Ten service classes covering the full domain and external APIs:\nService Responsibility AllergenService CRUD + EU allergen seeder (14 official allergens, bilingual DA/EN, double-seed protection) DishService CRUD + activate/deactivate + available dishes grouped by station DishSuggestionService Submit, approve, reject, update — full suggestion lifecycle AiService Normalize data, generate AI-powered dish suggestions based on station weather and ISO certification DishTranslationService Translate dish names and descriptions to English or a target language WeeklyMenuService Create menu, add/remove/update slots, translate, publish StationService CRUD + name uniqueness UserService Register, login, update profile, change role/email/password, delete IngredientRequestService Create, approve, reject, filter by status and delivery date ShoppingListService Create, approve shopping list, item management, mark ordered, finalize Each service depends only on interfaces — never on concrete classes. Each one validates, authorizes, fetches, executes, and returns in the same order every time.\nThe Service Layer is the Heart of the Application # To understand the flow and responsibilities of the service layer, I diagrammed two of the program\u0026rsquo;s core features: dish suggestion and menu publication.\nThese diagrams were invaluable for catching edge cases and ensuring a smooth user experience. They helped reveal the full lifecycle — how a line cook\u0026rsquo;s suggestion travels through the system, and how a head chef builds and publishes a weekly menu. From this, it became clear where validation should happen, where state transitions belong, and how the system should respond at each step.\nThe following diagram guided the implementation of DishSuggestionService:\nDish Suggestion Lifecycle # sequenceDiagram actor LineCook actor HeadChef participant DishSuggestionService participant DishSuggestionDAO participant DishSuggestionEntity as DishSuggestion (Entity) participant DishDAO %% ── SUBMIT SUGGESTION ───────────────────────────── LineCook-\u003e\u003eDishSuggestionService: submitSuggestion(creatorId, dto) DishSuggestionService-\u003e\u003eDishSuggestionService: validateCreateInput(dto) Note right of DishSuggestionService: validateNamevalidateDescriptionvalidateRange(week/year) DishSuggestionService-\u003e\u003eDishSuggestionService: ensureIsKitchenStaff(user) Note right of DishSuggestionService: HEAD_CHEFSOUS_CHEFLINE_COOK allowed DishSuggestionService-\u003e\u003eDishSuggestionEntity: checkCreationAllowed(LocalDate.now()) Note right of DishSuggestionEntity: Entity checkssubmission deadline DishSuggestionService-\u003e\u003eDishSuggestionDAO: create(suggestion) DishSuggestionDAO--\u003e\u003eDishSuggestionService: saved (PENDING) DishSuggestionService--\u003e\u003eLineCook: DishSuggestionDTO %% ── APPROVE SUGGESTION ──────────────────────────── HeadChef-\u003e\u003eDishSuggestionService: approveDish(id, approverId) DishSuggestionService-\u003e\u003eDishSuggestionEntity: approve(approver) Note right of DishSuggestionEntity: validate PENDINGset approver + timestampstatus → APPROVED DishSuggestionService-\u003e\u003eDishSuggestionDAO: update(suggestion) DishSuggestionDAO--\u003e\u003eDishSuggestionService: updated DishSuggestionService-\u003e\u003eDishSuggestionService: map suggestion → Dish Note right of DishSuggestionService: nameDAdescriptionDAstation + allergens DishSuggestionService-\u003e\u003eDishDAO: create(dish) Note right of DishDAO: suggestion keptfor audit history DishDAO--\u003e\u003eDishSuggestionService: dish saved DishSuggestionService--\u003e\u003eHeadChef: DishSuggestionDTO (APPROVED) Once dishes exist in the dish bank, the menu publication flow takes over. The head chef creates a weekly menu, adds dishes to slots, and publishes it for guests to see.\nMenu Publication Lifecycle # sequenceDiagram actor HeadChef actor Guest participant DishService participant WeeklyMenuService participant MenuDAO %% ── TRANSLATE DISH ──────────────────────────────── HeadChef-\u003e\u003eDishService: translateDish(editorId, dishId) Note right of DishService: DeepL translationDA → EN DishService--\u003e\u003eHeadChef: DishDTO (translated) %% ── CREATE MENU ─────────────────────────────────── HeadChef-\u003e\u003eWeeklyMenuService: createMenu(creatorId, dto) WeeklyMenuService-\u003e\u003eWeeklyMenuService: checkIfMenuExists() Note right of WeeklyMenuService: Only one menu per week WeeklyMenuService-\u003e\u003eMenuDAO: create(menu) MenuDAO--\u003e\u003eWeeklyMenuService: saved WeeklyMenuService--\u003e\u003eHeadChef: WeeklyMenuDTO (DRAFT) %% ── ADD MENU SLOT ───────────────────────────────── HeadChef-\u003e\u003eWeeklyMenuService: addMenuSlot(menuId, dto) WeeklyMenuService-\u003e\u003eWeeklyMenuService: validateDishForStation() Note right of WeeklyMenuService: dish.station mustmatch slot station WeeklyMenuService-\u003e\u003eWeeklyMenuService: dish.isActive() Note right of WeeklyMenuService: entity guards state WeeklyMenuService-\u003e\u003eMenuDAO: update(menu) MenuDAO--\u003e\u003eWeeklyMenuService: slot added WeeklyMenuService--\u003e\u003eHeadChef: WeeklyMenuDTO %% ── PUBLISH MENU ────────────────────────────────── HeadChef-\u003e\u003eWeeklyMenuService: publishMenu(menuId) WeeklyMenuService-\u003e\u003eWeeklyMenuService: requireNotEmpty() WeeklyMenuService-\u003e\u003eWeeklyMenuService: validateAllDishesTranslated() WeeklyMenuService-\u003e\u003eWeeklyMenuService: menu.publish() Note right of WeeklyMenuService: entity sets PUBLISHEDpublisher + timestamp WeeklyMenuService-\u003e\u003eMenuDAO: update(menu) MenuDAO--\u003e\u003eWeeklyMenuService: published WeeklyMenuService--\u003e\u003eHeadChef: WeeklyMenuDTO (PUBLISHED) %% ── GUEST READS MENU ────────────────────────────── Guest-\u003e\u003eWeeklyMenuService: getCurrentWeekMenu() WeeklyMenuService-\u003e\u003eMenuDAO: findByWeekAndYear() MenuDAO--\u003e\u003eWeeklyMenuService: WeeklyMenu WeeklyMenuService--\u003e\u003eGuest: WeeklyMenuDTO (DA + EN) Every service method follows the same pattern: validate → authorize → fetch → execute → return DTOs.\nTogether, these services orchestrate the full lifecycle of dishes, menus, shopping lists, and ingredient requests across the kitchen system — making the service layer the true coordination point of the application.\nFrom Suggestion to Dish Bank # One of the most important flows this week is what happens when a head chef approves a dish suggestion. It is not just a status change — it creates a real Dish entity from the suggestion data.\n@Override public DishSuggestionDTO approveDish(Long dishId, Long approverId) { ValidationUtil.validateId(dishId); ValidationUtil.validateId(approverId); DishSuggestion suggestion = dishSuggestionDAO.getByID(dishId); User approver = userReader.getByID(approverId); suggestion.approve(approver); DishSuggestion updated = dishSuggestionDAO.update(suggestion); Dish dish = toDish(suggestion); Dish createdDish = dishDAO.create(dish); return DishSuggestionMapper.toDTO(updated); } The suggestion stays in the database with APPROVED status — for history and audit. A new Dish is created carrying the same nameDA, descriptionDA, station, allergens, createdBy, targetWeek and targetYear. That dish now lives in the dish bank and can be placed into a weekly menu slot.\nNo Layer Trusts Another # The most important structural decision this week was defensive programming across all three layers.\nEach layer validates what it owns and nothing else:\nController → Is the HTTP request well-formed? (id \u0026gt; 0, body present, user authenticated) Service → Are the business rules satisfied? (unique name, authorized, valid range) Entity → Is this object in a valid state? (not null, ensureDraft, invariants) ShoppingList.ensureDraft() is the clearest example. The entity refuses to add items, remove items, or finalize itself unless it is in DRAFT state — regardless of what the service already checked:\n// ShoppingList.java private void ensureDraft(String action) { if (this.shoppingListStatus != ShoppingListStatus.DRAFT) { throw new IllegalStateException(\u0026#34;Cannot \u0026#34; + action + \u0026#34; - list is \u0026#34; + shoppingListStatus); } } public void addItem(ShoppingListItem shoppingListItem) { requireNotNull(shoppingListItem, \u0026#34;Shopping list item\u0026#34;); ensureDraft(\u0026#34;add items\u0026#34;); shoppingListItems.add(shoppingListItem); shoppingListItem.setShoppingList(this); } The service could check this too. But the entity does it regardless. Neither layer trusts the other.\nBusiness Logic Belongs in the Entity # The pattern I kept coming back to this week: the service coordinates, the entity decides.\nEarly on I wrote state transitions directly in the service. It worked, but the intent was scattered:\n// Logic in service - wrong place if (suggestion.getStatus() != Status.PENDING) throw ... suggestion.setStatus(Status.APPROVED); suggestion.setApprovedBy(approver); suggestion.setApprovedAt(LocalDateTime.now()); Moving that logic onto the entity makes the service a single readable line, and makes the transition atomic — all fields are set together or not at all:\n// Logic in entity - one call, atomic suggestion.approve(approver); The same pattern applies across the whole domain:\nDishSuggestion.approve(approver) — validates status is PENDING, sets approver and timestamp DishSuggestion.reject(approver, feedback) — requires feedback not blank, validates status WeeklyMenu.publish(publisher) — sets PUBLISHED, records who published and when ShoppingList.finalizeShoppingList() — calls ensureDraft(), sets FINALIZED, records timestamp ShoppingList.addItem(item) — calls ensureDraft(), sets back-reference on item The entity is not a dumb data bag. It knows its own rules.\nThe full lifecycle for a DishSuggestion looks like this:\nstateDiagram-v2 PENDING --\u003e APPROVED : approve(approver) PENDING --\u003e REJECTED : reject(approver, feedback) APPROVED --\u003e [*] REJECTED --\u003e [*] Interface Segregation on DAOs — Catching Mistakes at Compile Time # WeeklyMenuService needs to look up dishes when building a menu slot. But should it be able to delete them? No.\nSo instead of injecting IDishDAO, it depends on IDishReader — a read-only interface:\n// WeeklyMenuService - can only READ dishes private final IDishReader dishReader; private final IStationReader stationReader; private final IUserReader userReader; // DishService - full access private final IDishDAO dishDAO; IDishReader exposes only getByID and getAll. IDishDAO extends it and adds create, update, delete. classDiagram class IDishReader { +getById() +getAll() } class IDishDAO { +create() +update() +delete() } IDishDAO --|\u003e IDishReader class WeeklyMenuService class DishService WeeklyMenuService --\u003e IDishReader DishService --\u003e IDishDAO If someone injects IDishDAO into WeeklyMenuService by mistake, the code will not compile. The interface enforces the boundary — no runtime surprise, no accidental deletion.\nRole-Based Authorization — Guard Methods # Each service method loads the requesting user and passes it through a private guard:\n// DishSuggestionService User editor = userReader.getByID(editorId); ensureIsKitchenStaff(editor); // WeeklyMenuService User editor = userReader.getByID(editorId); requireHeadOrSousChef(editor); The guard methods are named as requirements:\n// DishSuggestionService private void ensureIsKitchenStaff(User user) { if (!user.isKitchenStaff()) { throw new UnauthorizedActionException( \u0026#34;Only kitchen staff can create dish suggestions\u0026#34; ); } } // WeeklyMenuService private void requireHeadOrSousChef(User user) { if (!user.isHeadChef() \u0026amp;\u0026amp; !user.isSousChef()) { throw new UnauthorizedActionException( \u0026#34;Only head chef and sous chef can manage menus\u0026#34; ); } } The role-check helpers live on the User entity (isHeadChef(), isSousChef(), isKitchenStaff()). The service decides who is allowed to act. The entity knows what it is.\nValidation Infrastructure # Rather than repeating min/max bounds in every service, all validation goes through ValidationUtil:\n// AllergenService private void validateNames(String nameDA, String nameEN) { ValidationUtil.validateName(nameDA, \u0026#34;Name DA\u0026#34;); ValidationUtil.validateName(nameEN, \u0026#34;Name EN\u0026#34;); } private void validateDescriptions(String descDA, String descEN) { ValidationUtil.validateDescription(descDA, \u0026#34;Description DA\u0026#34;); ValidationUtil.validateDescription(descEN, \u0026#34;Description EN\u0026#34;); } // DishSuggestionService private void validateCreateInput(DishSuggestionCreateDTO dto) { ValidationUtil.validateNotNull(dto, \u0026#34;Dish Suggestion\u0026#34;); ValidationUtil.validateId(dto.stationId()); ValidationUtil.validateName(dto.nameDA(), \u0026#34;Name\u0026#34;); ValidationUtil.validateDescription(dto.descriptionDA(), \u0026#34;Description\u0026#34;); ValidationUtil.validateRange(dto.targetWeek(), 1, 53, \u0026#34;Target week\u0026#34;); ValidationUtil.validateRange(dto.targetYear(), 2020, 2100, \u0026#34;Target year\u0026#34;); } validateName and validateDescription call validateText internally, which checks length and runs SAFE_TEXT_PATTERN. That pattern blocks characters with help of Regular Expressions, that do not belong in a dish name — one layer of protection against injection attacks. Named constants (NAME_MIN = 2, NAME_MAX = 100) mean there is one place to change a bound, not twenty.\nWhat Worked Well # Interface segregation on DAOs caught potential mistakes at compile time. WeeklyMenuService physically cannot call dishDAO.delete() — it only has IDishReader.\nEntity business methods made services clean and readable. approve(), reject(), publish(), ensureDraft() turned multi-line service logic into single, atomic calls.\nrequireXxx() naming convention — private guard methods that read like requirements. requireHeadChef, requireNotEmpty, requireDraft. Clear intent at a glance.\nValidationUtil named methods — validateName() instead of repeating min/max in every service. Change NAME_MIN in one place.\nThe suggestion → dish mapping on approve — keeping the suggestion in the database for audit history while creating a new Dish entity in the dish bank was the right call. Two separate concerns, two separate entities.\nWhat I Would Do Differently # Define parameter conventions on day one. The rule is simple: userId from JWT, resource IDs from path, relations in DTO body. Writing it down once would have saved several refactor rounds.\nImplement entity methods before writing services. User.update() was empty when UserService.update() was written. Silent incorrect behavior — no fields updated, no error thrown. Entity methods first, services second.\nOne DTO per use case from the start. Some DTOs had IDs that belonged in the path param, not the body. Define DTOs correctly before writing service signatures.\nThe service layer is almost done, and the structure now feels solid. Next week: wiring everything together through controllers, setting up server configuration with Javalin.\nThis is part 5 of my MiseOS development log. Follow along as I build a tool for professional kitchens, one commit at a time.\n","date":"27 February 2026","externalUrl":null,"permalink":"/posts/service-layer/","section":"Posts","summary":"","title":"Designing and Implementing the Service Layer","type":"posts"},{"content":"","date":"27 February 2026","externalUrl":null,"permalink":"/tags/service-layer/","section":"Tags","summary":"","title":"Service-Layer","type":"tags"},{"content":"","date":"20 February 2026","externalUrl":null,"permalink":"/tags/ai/","section":"Tags","summary":"","title":"AI","type":"tags"},{"content":"","date":"20 February 2026","externalUrl":null,"permalink":"/tags/api-integration/","section":"Tags","summary":"","title":"API-Integration","type":"tags"},{"content":"Week four. With the persistence layer finished, I moved up the stack into infrastructure and orchestration.\nThis week was about integration and architectural responsibility.\nI integrated two external APIs:\nDeepL for deterministic translation Google Gemini 2.5 Flash for AI-powered ingredient normalization At the same time, I began shaping a proper Service Layer — separating orchestration from domain logic and pushing business rules down into entities where they belong.\nRemember Last Week\u0026rsquo;s Question? # Last week I wrote:\n\u0026ldquo;If 3 cooks request \u0026rsquo;løg\u0026rsquo;, \u0026lsquo;onions\u0026rsquo;, and \u0026lsquo;Onion\u0026rsquo;, the Head Chef sees all three and creates one aggregated item \u0026lsquo;Onions — 15kg\u0026rsquo;. This is their job anyway.\u0026rdquo;\nBut then I thought: What if there are 40 ingredients? What if someone types \u0026ldquo;carots\u0026rdquo; (typo)? What if the staff is bilingual and half write in Danish, half in English?\nManual aggregation doesn\u0026rsquo;t scale. String comparison is cheap. Semantic understanding is not. That’s where AI becomes useful.\nAnd then there\u0026rsquo;s the menu translation problem. The public menu (US-11, US-12) needs to display in both Danish and English. I could force Line Cooks to write everything twice, but that\u0026rsquo;s tedious and error-prone.\nTime to automate both problems.\nProblem 1: Menu Translation (DeepL Integration) # Requirement (US-12):\nThe public menu must be displayed in both Danish and English.\nLine Cooks create dishes in Danish only.\nInstead of forcing staff to write everything twice, I integrated the DeepL Translation API and automated the process.\nEndpoint: POST https://api-free.deepl.com/v2/translate\nArchitecture Decision # I designed the DeepLTranslationClient as a thin infrastructure component. It has one job: talk to the API. It doesn\u0026rsquo;t know what a \u0026ldquo;Dish\u0026rdquo; is; it only knows how to turn a String into a translated String.\nDeepL DTO Design # DeepL expects:\nA list of texts A target language // DTOs for DeepL public record DeepLRequestDTO( @JsonProperty(\u0026#34;text\u0026#34;) List\u0026lt;String\u0026gt; text, @JsonProperty(\u0026#34;target_lang\u0026#34;) String targetLanguage ) {} @JsonIgnoreProperties(ignoreUnknown = true) public record DeepLResponseDTO( @JsonProperty(\u0026#34;translations\u0026#34;) List\u0026lt;TranslationDTO\u0026gt; translations ) {} public record TranslationDTO(String text) {} Key takeaway: @JsonIgnoreProperties(ignoreUnknown = true) is a lifesaver. It prevents the ObjectMapper from crashing if DeepL decides to add new analytics fields to their response payload in the future. The client is modular. For now in the MVP we only going to give \u0026ldquo;EN\u0026rdquo; as the argument for target language, but in the future we could easily expand to more languages without changing the client code.\nUsage after implementation of DeepL client:\nLine Cooks write dish suggestions in Danish\nMenuService calls DishTranslationService, which calls DeepLTranslationClient to translate the dish name and description into English\nEnglish translations are stored when he menu is published, so unnecessary load is avoided on the translation API.\nThe public menu supports multiple languages\nThis keeps the domain clean while external communication stays isolated in the infrastructure layer.\nOne important distinction:\nDeepL is deterministic. Given the same input and target language, it will always return the same translation. This makes it ideal for user-facing content like menus, where predictability matters.\nGemini, on the other hand, is probabilistic — which required stricter defensive programming (covered below).\nProblem 2: Ingredient Normalization # The Issue: Three cooks request the same ingredient with different spellings for the weekly shopping list:\n\u0026ldquo;løg\u0026rdquo; (Danish)\n\u0026ldquo;onions\u0026rdquo; (English, plural)\n\u0026ldquo;Onion\u0026rdquo; (Capitalized)\nIf we just group these by string matching, the head chef gets a messy list with three different entries for onions. I needed automated, semantic normalization.\nSolution: Use Google Gemini 2.5 Flash to normalize ingredient names based on culinary standards.\nHowever, integrating an LLM into a strongly typed Java backend is not trivial. LLMs are conversational by nature. My backend needs deterministic JSON. Bridging that gap required strict payload modeling and disciplined prompt engineering.\nEndpoint: POST https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent\nThis is how i structured the request and response handling to get clean JSON output from Gemini, which I can then serialize directly into Java objects:\nThe Request: What Google Expects # Google’s Generative Language API is multimodal (it can accept text, images, video). Because of this, even a simple text prompt cannot just be sent as {\u0026ldquo;prompt\u0026rdquo;: \u0026ldquo;Hello\u0026rdquo;}. It must be wrapped in a deeply nested hierarchy.\nI modeled this strict structure using Java Records:\npublic record GeminiRequest(List\u0026lt;Content\u0026gt; contents) {} public record Content(List\u0026lt;Part\u0026gt; parts) {} public record Part(String text) {} To build the HTTP request payload, the prompt should be wrapped in these layers:\nprivate GeminiRequest buildGeminiRequest(String prompt) { // Wrapping the prompt in the required multimodal structure return new GeminiRequest(List.of(new Content(List.of(new Part(prompt))))); } The Art of the Prompt # To make the AI work as a reliable data-transformer, I had to be extremely strict in the prompt. I created a NormalizeTextPromptBuilder to encapsulate this logic:\npublic class NormalizeTextPromptBuilder { public static String buildNormalizeTextPrompt(String ingredientsJson, String languageName) { return String.format( \u0026#34;\u0026#34;\u0026#34; Normalize these ingredient names to standard %s culinary terminology. Return ONLY valid JSON in this exact format: {\u0026#34;ingredient1\u0026#34;: \u0026#34;Normalized1\u0026#34;, \u0026#34;ingredient2\u0026#34;: \u0026#34;Normalized2\u0026#34;} Rules: - Singular form - Capitalize first letter - Fix spelling - Translate to %s - NO markdown, NO explanation Ingredients: %s JSON:\u0026#34;\u0026#34;\u0026#34;, languageName, languageName, ingredientsJson ); } } Why this prompt works:\nSchema anchoring — Providing the exact JSON structure reduces hallucinated formats. Explicit transformation rules — Singular form, capitalization, spelling correction, and translation make the output predictable. Token priming (\u0026ldquo;JSON:\u0026rdquo;) — Ending with JSON: biases the first generated token toward {, reducing conversational filler. Treating the LLM as a strict data transformer — not a chatbot — was the key architectural shift.\nThe Response: Extracting the Data # Just like the request, Gemini\u0026rsquo;s response is buried deep within a nested structure: Response -\u0026gt; Candidates -\u0026gt; Content -\u0026gt; Parts -\u0026gt; Text.\n@JsonIgnoreProperties(ignoreUnknown = true) public record GeminiResponse( @JsonProperty(\u0026#34;candidates\u0026#34;) List\u0026lt;Candidate\u0026gt; candidates, @JsonProperty(\u0026#34;usageMetadata\u0026#34;) UsageMetadata usageMetadata ) {} Even with strict instructions, Gemini frequently wrapped the JSON in Markdown code fences.\nSince Jackson cannot parse markdown, I added a defensive sanitation step inside the client:\n// 1. Traverse the nested DTOs to find the raw string String geminiResponse = response.candidates().get(0).content().parts().get(0).text(); // 2. Strip the markdown backticks that crash Jackson private String cleanGeminiResponse(String geminiResponse) { return geminiResponse.replace(\u0026#34;```json\u0026#34;, \u0026#34;\u0026#34;).replace(\u0026#34;```\u0026#34;, \u0026#34;\u0026#34;).trim(); } Service-Level Usage # With the Client returning a clean JSON string, our AiService uses Jackson\u0026rsquo;s TypeReference to map the result directly into a Java Map\u0026lt;String, String\u0026gt;.\n@Override public Map\u0026lt;String, String\u0026gt; normalizeIngredientList(List\u0026lt;String\u0026gt; ingredients, String targetLanguage) { // 1. Build the prompt String prompt = NormalizeTextPromptBuilder.buildNormalizeTextPrompt( objectMapper.writeValueAsString(ingredients), targetLanguage ); // 2. Get the clean JSON string from our dumb client String jsonResponse = aiClient.generateResponse(prompt); // 3. Map it to a Dictionary (Key: Original, Value: Normalized) return objectMapper.readValue(jsonResponse, new TypeReference\u0026lt;Map\u0026lt;String, String\u0026gt;\u0026gt;() {}); } Usage: ShoppingListService passes ingredient strings through normalization. Result: \u0026ldquo;løg\u0026rdquo; + \u0026ldquo;onions\u0026rdquo; + \u0026ldquo;Onion\u0026rdquo; → all map to \u0026ldquo;Onion\u0026rdquo; or \u0026ldquo;Løg\u0026rdquo; depending on target language → aggregate quantities in Java code without duplicates.\nService Layer: Where Does Business Logic Live? # This is one of those architectural decisions that compounds over time.\nIf business rules live in services, they become scattered and bypassable.\nIf they live in entities, they become enforceable invariants.\nThe Deadline Problem # Business rule (US-06): Line Cooks can only submit dish suggestions before Thursday 12:00 of the week before the target week.\nTarget week 10 (Monday March 3) → Deadline is Thursday February 27.\nDecision: Put Logic in Entity # @Entity public class DishSuggestion { // Fields, constructor, etc. // Entity calculates its own deadline public LocalDate getDeadlineDate() { ValidationUtil.validateNotNull(targetWeek, \u0026#34;Target week\u0026#34;); ValidationUtil.validateNotNull(targetYear, \u0026#34;Target year\u0026#34;); LocalDate targetMonday = LocalDate.of(targetYear, 1, 1) .with(WeekFields.ISO.weekOfYear(), targetWeek) .with(WeekFields.ISO.dayOfWeek(), 1); return targetMonday.minusDays(4); // Thursday before target week } // Entity enforces its own rules public void checkCreationAllowed(LocalDate today) { if (!today.isBefore(getDeadlineDate())) { throw new IllegalStateException(\u0026#34;Deadline passed. Last chance was \u0026#34; + getDeadlineDate()); } } } The Service Layer: The Orchestrator # The DishSuggestionService doesn\u0026rsquo;t know how to calculate a deadline. It just fetches the data, asks the entity if it\u0026rsquo;s allowed, and saves it.\npublic class DishSuggestionService { public DishSuggestionDTO submitSuggestion(DishCreateRequestDTO dto) { // Service validates IDs ValidationUtil.validateId(dto.stationId()); ValidationUtil.validateId(dto.userCreatedById()); // Service fetches related entities Station station = stationReader.getByID(dto.stationId()); User user = userReader.getByID(dto.userCreatedById()); // Entity validates authorization user.ensureIsKitchenStaff(); // Service fetches allergens Set\u0026lt;Allergen\u0026gt; allergens = dto.allergenIds().stream() .map(allergenDAO::getByID) .collect(Collectors.toSet()); // Create entity DishSuggestion dish = new DishSuggestion( dto.nameDA(), dto.descriptionDA(), dto.targetWeek(), dto.targetYear(), station, user, allergens ); // Entity enforces deadline (service provides current date) dish.checkCreationAllowed(LocalDate.now()); // Service persists DishSuggestion saved = dishSuggestionDAO.create(dish); return mapToDTO(saved); } } Pattern:\nService: Validates IDs, fetches entities, provides external dependencies (LocalDate.now()), persists Entity: Enforces invariants, calculates business values, validates state transitions Approve/Reject: Delegate to Entity # // aprove example public DishSuggestionDTO approveDish(Long dishId, Long approverId) { ValidationUtil.validateId(dishId); ValidationUtil.validateId(approverId); DishSuggestion dish = dishSuggestionDAO.getByID(dishId); User approver = userReader.getByID(approverId); // Entity handles authorization + state transition dish.approve(approver); DishSuggestion updated = dishSuggestionDAO.update(dish); return mapToDTO(updated); } Why this works:\nEntity owns business logic (approve() validates approver role and current status) Service just orchestrates (fetch → delegate → persist) Impossible to bypass validation (entity enforces it) What Worked Well # DeepL API is straightforward — Simple request/response, good docs Gemini free tier is plenty — 1500 req/day \u0026raquo; actual usage Prompt engineering works — Right prompt = clean JSON output Business logic in entities is clean — Service orchestrates, entity enforces Records for DTOs — Immutable, concise, perfect for API responses What Was Difficult # Gemini nested DTOs — complex documentation how to handle request / response and navigating in candidates[0].content.parts[0].text Markdown JSON wrapping — Debugging why ObjectMapper crashed and then realizing Gemini wraps JSON in json ... then building a cleaning method to strip it out API key security — Initially put key in query params (logs, browser history risk) Deciding service vs entity responsibility — Not always obvious where logic belongs Trusting AI output — Even with strict prompts, defensive checks were necessary. Never trust external systems blindly. What\u0026rsquo;s Next # Next week: Complete service layer and REST API\nShoppingListService (uses Gemini normalization) MenuService (uses DeepL translation) Simple services for Stations, Users, Allergens This is part 4 of my MiseOS development log. Follow along as I build a tool for professional kitchens, one commit at a time.\n","date":"20 February 2026","externalUrl":null,"permalink":"/posts/integrating_external_apis/","section":"Posts","summary":"","title":"Integrating AI \u0026 Translation: External APIs and Service Layer Design","type":"posts"},{"content":"","date":"13 February 2026","externalUrl":null,"permalink":"/tags/dao/","section":"Tags","summary":"","title":"DAO","type":"tags"},{"content":"","date":"13 February 2026","externalUrl":null,"permalink":"/tags/database/","section":"Tags","summary":"","title":"Database","type":"tags"},{"content":"Week three of MiseOS, and Hibernate stopped being a tutorial — and started being architecture.\nOver the last two weeks, I learned Hibernate through isolated exercises. This week, I finally put those skills into practice by translating a real domain model into a working persistence layer.\nWhat seemed like straightforward persistence work quickly turned into real design tradeoffs:\nShould Station be an enum or a database table?\nHow much validation belongs in a DAO?\nAnd why did three out of four menu slots silently disappear? What I built:\nA complete ERD for the system 9 JPA entities with relationships 7 DAO implementations A DBValidator utility for defensive programming Integration tests for all DAOs using Testcontainers and real PostgreSQL What I learned: Design decisions are harder than writing code.\nThe ERD: Drawing before coding # Last week I had conceptual domain models. This week I turned those into a concrete database design.\nI drew the ERD with actual fields and data types, not just abstract entity boxes. I included:\nPrimary keys Foreign keys Data types Audit fields Mapping the database schema with real fields and types Why include this level of detail? Because it made the JPA entity translation trivial. When I sat down to write DishSuggestion.java, I didn\u0026rsquo;t have to make any design decisions — I just mapped the diagram to annotations. It also forced me to think through the relationships and constraints upfront, which saved a lot of refactoring later.\nThe design decisions # Translating the ERD into working code meant facing a series of design choices. Some were straightforward. Others were more complex.\nI faced dozens of decisions this week, but here are the ones that mattered most—the ones that shaped the entire persistence layer and will impact how I build the rest of the system.\nDecision 1: Station — Enum or Entity? # The dilemma: Should Station be a hardcoded Java enum or a database table that can be modified at runtime?\n// Option A: Enum (compile-time safety) public enum StationType { HOT, COLD, VEGETARIAN, STARTER, BAKERY } // Option B: Entity (runtime flexibility) @Entity public class Station { private Long id; private String stationName; private String description; } I chose Option B: Entity.\nWhy? Real kitchens vary. A hotel kitchen might have 7 stations. A small bistro might only have 2. If Station is an enum, adding a new station requires:\nEditing Java code Recompiling Redeploying That\u0026rsquo;s ridiculous for reference data. If I hardcode stations, I\u0026rsquo;m building software for MY vision of a kitchen, not the actual kitchens that will use this.\nThe tradeoff: I lose type safety. Someone could accidentally create \u0026ldquo;VARM\u0026rdquo; and \u0026ldquo;VARMT\u0026rdquo; as duplicates.\nDecision 2: Ingredient normalization — To normalize or not? # Last week I asked: \u0026ldquo;Can a Line Cook write \u0026rsquo;løg\u0026rsquo;, \u0026lsquo;onions\u0026rsquo;, or \u0026lsquo;Onion\u0026rsquo; — or do I force a dropdown?\u0026rdquo;\nI chose: Keep ingredient names as free text strings.\nNo ingredients table. No foreign keys. Just:\n@Setter @Column(name = \u0026#34;name\u0026#34;, nullable = false) private String name; Why? Creating a normalized ingredients catalog would mean:\nPre-populating hundreds of ingredients Handling edge cases (organic vs regular, different suppliers) Building autocomplete UI Dealing with variations the system doesn\u0026rsquo;t know about That\u0026rsquo;s weeks of work for questionable value.\nThe pragmatic solution: The Head Chef manually reviews ingredient requests and creates shopping list items. If 3 cooks request \u0026ldquo;løg\u0026rdquo;, \u0026ldquo;onions\u0026rdquo;, and \u0026ldquo;Onion\u0026rdquo;, the Head Chef sees all three and creates one aggregated item \u0026ldquo;Onions — 15kg\u0026rdquo;.\nThis is their job anyway. They need to review quantities, check what\u0026rsquo;s in storage, and decide on suppliers. The system supports this workflow instead of trying to automate what requires human judgment.\nIf I change my mind later? I can add normalization without breaking anything. Start with simple, add complexity when it\u0026rsquo;s actually needed.\nDecision 3: Interface segregation — How clean is too clean? # I spent a surprising amount of time thinking about DAO design. Should I follow Interface Segregation Principle strictly and create separate interfaces for each operation?\n// Option A: Split interfaces (ISP purist) public interface ICreateDAO\u0026lt;T\u0026gt; { T create(T entity); } public interface IReadDAO\u0026lt;T\u0026gt; { Optional\u0026lt;T\u0026gt; getById(Long id); } public interface IUpdateDAO\u0026lt;T\u0026gt; { T update(T entity); } public interface IDeleteDAO\u0026lt;T\u0026gt; { void delete(Long id); } // Then services inject exactly what they need: public class ReportService { private final IReadDAO\u0026lt;User\u0026gt; userReader; // Only read, can\u0026#39;t modify } // Option B: One interface per entity with extending a generic crud interface (pragmatic) public interface IEntityDAO\u0026lt;T, I\u0026gt;{ T create(T t); Set\u0026lt;T\u0026gt; getAll(); T getByID(I id); T update(T t); boolean delete(I id); } public interface IUserDAO extends IEntityDAO\u0026lt;User, Long\u0026gt; { Optional\u0026lt;User\u0026gt; findByEmail(String email); Set\u0026lt;User\u0026gt; findByRole(UserRole role); boolean existsByEmail(String email); } I chose Option B.\nWhy? Because in practice, my services need multiple operations:\npublic class DishSuggestionService { private final IDishSuggestionDAO dishDAO; public DishSuggestion submitDish(...) { // Needs: create() AND findByStation() AND getById() } } If I split interfaces, I\u0026rsquo;d inject 3-4 interfaces per service. That\u0026rsquo;s not cleaner, it\u0026rsquo;s just more verbose.\nThe reality: I\u0026rsquo;m one developer building a 10-week school project, not a team of 50 building microservices. I need appropriate abstractions, not maximum abstractions.\nWhat I DO have:\nServices depend on interfaces (testable via mocking) Clear contracts (DAO methods are well-named) Separation of concerns (persistence logic stays in DAOs) That\u0026rsquo;s enough. If I later need a read-only export service, I can extract IReadDAO\u0026lt;T\u0026gt; then. YAGNI applies.\nDecision 4: Abstract BaseDAO — DRY vs Clarity # Every DAO has nearly identical CRUD implementations:\n// UserDAO public User create(User user) { try (EntityManager em = emf.createEntityManager()) { em.getTransaction().begin(); em.persist(user); em.getTransaction().commit(); return user; } } // DishSuggestionDAO public DishSuggestion create(DishSuggestion dish) { try (EntityManager em = emf.createEntityManager()) { em.getTransaction().begin(); em.persist(dish); em.getTransaction().commit(); return dish; } } // ... 7 more DAOs with the same code Obvious DRY violation. I could create an AbstractBaseDAO\u0026lt;T\u0026gt;:\npublic abstract class AbstractBaseDAO\u0026lt;T\u0026gt; { protected final EntityManagerFactory emf; private final Class\u0026lt;T\u0026gt; entityClass; public T create(T entity) { try (EntityManager em = emf.createEntityManager()) { em.getTransaction().begin(); em.persist(entity); em.getTransaction().commit(); return entity; } } // ... same for update, delete, getById, getAll } // Then: public class UserDAO extends AbstractBaseDAO\u0026lt;User\u0026gt; implements IUserDAO { // Only implement custom queries } I chose NOT to do this. Yet.\nTyping out similar CRUD code multiple times helped me internalize Hibernate’s lifecycle. An early abstraction would have hidden that learning behind inheritance.\nMore importantly, requirements may diverge. A ShoppingList.create() might eventually validate delivery dates. A WeeklyMenu.delete() might become a soft delete. Premature abstraction would make those changes harder, not easier.\nIf the number of entities grows or transaction handling becomes more complex, I’ll refactor. Until then, clarity beats DRY.\nDecision 5: Cascade operations - When should children die with parents? # Deciding when to use CascadeType.ALL and orphanRemoval = true was tricky.\nA useful mental model was this question: “If the parent disappears, should the child still exist in the real world?”\nMy rules became:\nParent owns children → Use cascade + orphanRemoval Parent references children → No cascade Examples:\n// WeeklyMenu OWNS its slots @OneToMany(mappedBy = \u0026#34;weeklyMenu\u0026#34;, cascade = CascadeType.ALL, // Save/update/delete together orphanRemoval = true) // Delete slot if removed from menu private Set\u0026lt;WeeklyMenuSlot\u0026gt; weeklyMenuSlots; // DishSuggestion REFERENCES allergens (shared across dishes) @ManyToMany @JoinTable(name = \u0026#34;dish_allergen\u0026#34;, ...) private Set\u0026lt;Allergen\u0026gt; allergens; // No cascade - allergens are shared Why it matters:\nIf I had put cascade = CascadeType.ALL on DishSuggestion.allergens:\nDelete a dish Hibernate deletes the allergens too Now \u0026ldquo;GLUTEN\u0026rdquo; is gone from the database Every other dish that had gluten breaks The test: Ask yourself: \u0026ldquo;If I delete the parent, should the child cease to exist in the real world?\u0026rdquo;\nMenu deleted → Menu slots should be deleted Dish deleted → Allergens still exist for other dishes Defensive programming: How much should DAOs validate? # I decided that each layer validates its own inputs — DAOs don\u0026rsquo;t trust services, services won\u0026rsquo;t trust controllers. Layers are decoupled and shouldn\u0026rsquo;t blindly trust data from above. To avoid duplicating validation logic across 7 DAOs, I created a DBValidator utility for basic checks with generic methods:\npublic class DBValidator { public static void validateId(Long id) { if (id == null || id \u0026lt;= 0) { throw new IllegalArgumentException(\u0026#34;Invalid ID: Must be provided and greater than 0.\u0026#34;); } } public static \u0026lt;T\u0026gt; T validateExists(T entity, Object id, Class\u0026lt;T\u0026gt; entityClass) { if (entity == null) { String className = entityClass.getSimpleName(); throw new EntityNotFoundException(className + \u0026#34; with ID \u0026#34; + id + \u0026#34; was not found.\u0026#34;); } return entity; } public static void validateNotNull(Object obj, String entityName) { if (obj == null) { throw new IllegalArgumentException(entityName + \u0026#34; cannot be null.\u0026#34;); } } public static void validateRange(int number, int min, int max, String fieldName) { if (number \u0026lt; min || number \u0026gt; max) { throw new IllegalArgumentException( String.format(\u0026#34;%s must be between %d and %d, got: %d\u0026#34;, fieldName, min, max, number) ); } } } Usage in DAOs:\n@Override public WeeklyMenu getById(Long id) { DBValidator.validateId(id); // Check before query try (EntityManager em = emf.createEntityManager()) { WeeklyMenu menu = em.find(WeeklyMenu.class, id); return DBValidator.validateExists(menu, id, WeeklyMenu.class); } } Why validate here?\nFail fast: Catch invalid IDs before hitting the database Consistent errors: All DAOs throw the same exceptions for the same problems Alternative I considered: Let everything bubble up and catch at controller level. But then:\nNullPointerException instead of IllegalArgumentException with clear message\nNo consistent error format\nHarder to debug\nI am still considering adding a DatabaseException to wrap all persistence errors, but for now, this is sufficient.\nThe IEntity Interface: Why every entity implements It # All my entities implement a simple interface:\npublic interface IEntity { Long getId(); } It enables generic DAO methods:\npublic interface IEntityDAO\u0026lt;T extends IEntity\u0026gt; { T create(T entity); Optional\u0026lt;T\u0026gt; getById(Long id); // ... } Why? This enabled a surprisingly powerful testing pattern. In my TestPopulator, I can seed Users, Stations, and Dishes once and reuse the same instances across assertions.\nBy having everything implement IEntity, I can store all my seeded test data in a single Map:\nprivate Map\u0026lt;String, IEntity\u0026gt; seeded = new HashMap\u0026lt;\u0026gt;(); // Later in the populator: seeded.put(\u0026#34;station_cold\u0026#34;, stationCold); seeded.put(\u0026#34;user_gordon\u0026#34;, chefGordon); // In my tests: User gordon = (User) seeded.get(\u0026#34;user_gordon\u0026#34;); It keeps the test setup incredibly clean and strongly typed where it matters. The tradeoff: Every entity MUST have a Long id. But that\u0026rsquo;s true anyway for my use case.\nBenefit: Type safety and better testability. The compiler prevents me from passing non-entity objects to DAOs.\nIntegration Testing: Bridging the gap between code and database # I made a strict rule for myself: No DAO is considered \u0026ldquo;done\u0026rdquo; until it has a passing integration test. In JPA, your code is essentially a set of instructions for how Java objects should map to database rows. If you only test your Java logic, you’re only testing half the bridge. You can write perfectly clean Java code that compiles, but if your @Table mapping is off, your @Column names are misspelled, or your @JoinColumn points to a non-existent key, the system will fail the moment it hits the database.\nTestcontainers: Ephemeral Databases and Production Parity To truly prove my DAOs work, I needed to test them against a real database. However, testing against a shared development database is a recipe for \u0026ldquo;flaky\u0026rdquo; tests—where one test fails because another test left behind dirty data.\nI chose Testcontainers to spin up an ephemeral, isolated PostgreSQL Docker container exclusively for the test suite. This ensures that every time I run my tests, I am starting with a 100% pristine environment.\npublic class HibernateTestConfig { private static Properties buildProps() { Properties props = HibernateBaseProperties.createBase(); // Testcontainers intercepts the JDBC connection props.put(\u0026#34;hibernate.connection.driver_class\u0026#34;, \u0026#34;org.testcontainers.jdbc.ContainerDatabaseDriver\u0026#34;); props.put(\u0026#34;hibernate.connection.url\u0026#34;, \u0026#34;jdbc:tc:postgresql:16.2:///test_db\u0026#34;); // Rebuild the schema from scratch for every test run props.put(\u0026#34;hibernate.hbm2ddl.auto\u0026#34;, \u0026#34;create-drop\u0026#34;); return props; } } Why this approach is superior:\nProduction Parity: I am testing against the exact version of PostgreSQL (16.2) that I intend to use in production. This catches subtle bugs like enum mapping issues or PostgreSQL-specific syntax errors that simpler in-memory alternatives might miss.\nCatching Mapping Errors: By running against a real instance, I get immediate feedback if my CascadeType.ALL is actually working or if a NOT NULL constraint is being violated.\nZero Cleanup: Because the container is destroyed after the tests finish, I never have to worry about cleaning up \u0026ldquo;test junk\u0026rdquo; in my development database.\nThe TestPopulator: Reusable Test Data # Testing a query like findByWeekAndYear(int week, int year) requires a deeply nested object graph:\nWeeklyMenu → WeeklyMenuSlot → DishSuggestion → User + Station Writing this setup in every test would be insane. So I built a TestPopulator:\npublic class TestPopulator { private final Map seeded = new HashMap\u0026lt;\u0026gt;(); public void populate() { populateStations(); // Create 4 stations populateUsers(); // Create 4 users (1 head chef, 3 line cooks) populateDishSuggestions(); // Create 5 dishes populateWeeklyMenus(); // Create menu with slots } public Map getSeededData() { return seeded; } } The magic: Store everything in Map\u0026lt;String, IEntity\u0026gt; so tests can retrieve by key:\n@BeforeEach void setUp() { TestCleanDB.truncateTables(emf); // Wipe database TestPopulator populator = new TestPopulator(emf); populator.populate(); seeded = populator.getSeededData(); } @Test @DisplayName(\u0026#34;Update - should update suggestion status to approved\u0026#34;) void updateWithHeadChef() { // Arrange: Pull seeded data from map DishSuggestion seed = (DishSuggestion) seeded.get(\u0026#34;dish_salmon\u0026#34;); User headChef = (User) seeded.get(\u0026#34;user_gordon\u0026#34;); // Act seed.approve(headChef); DishSuggestion updated = dishSuggestionDAO.update(seed); // Assert assertThat(updated.getDishStatus(), is(Status.APPROVED)); assertThat(updated.getReviewedBy(), is(headChef)); assertThat(updated.getReviewedAt(), is(notNullValue())); } Why IEntity interface matters here: Without it, I\u0026rsquo;d need separate maps for each type (Map\u0026lt;String, User\u0026gt;, Map\u0026lt;String, Station\u0026gt;, etc.). With IEntity, one map holds everything.\nWhere testing revealed a silent data loss bug # Here\u0026rsquo;s the test that exposed the silent data loss bug:\n@Test @DisplayName(\u0026#34;Create - should cascade save all menu slots\u0026#34;) void createMenuWithSlots() { // Arrange Station cold = (Station) seeded.get(\u0026#34;station_cold\u0026#34;); DishSuggestion salmon = (DishSuggestion) seeded.get(\u0026#34;dish_salmon\u0026#34;); DishSuggestion steak = (DishSuggestion) seeded.get(\u0026#34;dish_steak\u0026#34;); WeeklyMenu menu = new WeeklyMenu(10, 2025); menu.addMenuSlot(new WeeklyMenuSlot(MONDAY, salmon, cold)); menu.addMenuSlot(new WeeklyMenuSlot(TUESDAY, steak, cold)); menu.addMenuSlot(new WeeklyMenuSlot(WEDNESDAY, null, cold)); menu.addMenuSlot(new WeeklyMenuSlot(THURSDAY, salmon, cold)); // Act menuDAO.create(menu); // Assert WeeklyMenu fetched = menuDAO.getByIdWithSlots(menu.getId()); assertThat(fetched.getWeeklyMenuSlots(), hasSize(4)); //FAILED: size = 1 } Expected: 4 slots\nActual: 1 slot\nNo exception thrown. No error message. Just silent data loss.\nI was warned that the equals and hashcode were problematic in hibernate and i realized: the problem happened before Hibernate even saw the data.\nThe 4 WeeklyMenuSlot objects never made it into the HashSet. They were being silently discarded because:\n// My original equals/hashCode (BROKEN) @Override public int hashCode() { return Objects.hashCode(id); // id is null for new objects! } // All 4 slots had null IDs // Objects.hashCode(null) = 0 for all 4 // HashSet thought: \u0026#34;They all hash to 0 and equal each other → duplicates!\u0026#34; // Only kept 1 The fix that made the test pass:\n@Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof WeeklyMenuSlot)) return false; WeeklyMenuSlot other = (WeeklyMenuSlot) o; return id != null \u0026amp;\u0026amp; id.equals(other.id); } @Override public int hashCode() { return getClass().hashCode(); } Re-ran the test. Green. All 4 slots persisted correctly.\nWhat Worked Well This Week # Domain-Driven Design in entities: Putting business logic directly in DishSuggestion.approve(User headChef) feels right. The entity enforces its own rules.\nStation as entity: Already proved valuable. I can demo the app with different kitchen configurations without touching code.\nTestPopulator pattern: Massive time saver. Write seed logic once, use in 50+ tests\nIntegration testing philosophy: Proved the persistence layer actually works before building on top of it\nequals/hashCode fix: Applied the constant hashCode pattern to ALL entities upfront after learning the hard way\nDBValidator: Centralized validation prevented duplicating null checks everywhere\nWhat Was Difficult # The equals/hashCode trap: An error hard to discover and debug. Silent bugs are the worst.\nDecision fatigue: Every design choice had 5 options. Enum vs entity. Abstract DAO vs duplication. Validate in DAO vs service. I spent as much time reading blogs as writing code.\nBidirectional relationships: Even with helper methods, I still had to remember which side is the \u0026ldquo;owner\u0026rdquo; of the relationship for cascading to work. It\u0026rsquo;s easy to mess up.\nKnowing when to stop: I could spend weeks perfecting the DAO layer. At some point I had to say \u0026ldquo;good enough\u0026rdquo; and move on. I will refactor later if needed, but I can\u0026rsquo;t let perfect be the enemy of done.\nWhat\u0026rsquo;s Next # Next week: Service layer\nStart implementing service layer for business workflows Build DTOs to decouple API from entities Create HTTP client to receive external api for translating dish names (Danish ↔ English) Add exception handling and validation Questions I\u0026rsquo;m thinking about:\nShould i make a DatabaseException for wrapping all persistence errors? Where do transaction boundaries belong? How do I handle translation (Danish ↔ English) in DTOs? How do insure validation is consistent across layers without duplication? This is part 3 of my MiseOS development log. Follow along as I build a tool for professional kitchens, one commit at a time\n","date":"13 February 2026","externalUrl":null,"permalink":"/posts/jpa-and-daos/","section":"Posts","summary":"","title":"From ERD to JPA: Design decisions and Hibernate implementation","type":"posts"},{"content":"","date":"13 February 2026","externalUrl":null,"permalink":"/tags/hibernate/","section":"Tags","summary":"","title":"Hibernate","type":"tags"},{"content":"","date":"13 February 2026","externalUrl":null,"permalink":"/tags/jpa/","section":"Tags","summary":"","title":"JPA","type":"tags"},{"content":"","date":"6 February 2026","externalUrl":null,"permalink":"/tags/domain-modeling/","section":"Tags","summary":"","title":"Domain-Modeling","type":"tags"},{"content":"","date":"6 February 2026","externalUrl":null,"permalink":"/tags/planning/","section":"Tags","summary":"","title":"Planning","type":"tags"},{"content":"Week two of building MiseOS, and I still haven\u0026rsquo;t written a single line of Java.\nGood.\nBecause before you can build software, you need to understand the real world you\u0026rsquo;re trying to model. Not the idealized version. Not what you think happens. What actually happens in a professional kitchen when they plan a weekly menu.\nThe Danger of Jumping Straight to Code # My first instinct was to start coding immediately. Open IntelliJ, create a Menu class, add some fields, and see what happens.\npublic class Menu { private Long id; private int weekNumber; private int year; private List\u0026lt;Dish\u0026gt; dishes; //etc. } I\u0026rsquo;ve done this before on smaller projects. It always ends the same way: three days in, I realize the model is wrong. The relationships don\u0026rsquo;t make sense. The workflow I coded doesn\u0026rsquo;t match reality. And now I have to refactor everything.\nIn my former life as a chef, we had a saying: \u0026ldquo;A good plan at the start of the day saves chaos at service.\u0026rdquo;\nThe same principle applies to software. If you don\u0026rsquo;t understand the domain—the actual business problem—you\u0026rsquo;ll build the wrong thing. So I spent this week doing something that felt unproductive but was actually the most important work: mapping the real world.\nMapping the Real World # I called up former colleagues from my 20 years in the kitchen industry. I wanted to understand the details of the friction points in menu planning.\nThe Messy Reality # The chaos isn\u0026rsquo;t because cooks are disorganized; it\u0026rsquo;s because the creative process (suggesting dishes) and the operational process (ordering ingredients) are disconnected. Notes get lost, verbal requests are forgotten, and the Head Chef ends up guessing the grocery list on Friday afternoon.\nThe Domain Model (The \u0026ldquo;Map\u0026rdquo;) # After these conversations, I sketched out the \u0026ldquo;MiseOS Domain Model.\u0026rdquo; This isn\u0026rsquo;t a database schema yet; it\u0026rsquo;s a map of entities and their responsibilities.\nThe Real World Domain Model for MiseOS Key takeaways from the model:\nAssigned Roles: Line cooks belong to specific Stations (Hot, Cold, Pastry). The Bridge: The Menu Proposal is the bridge between a cook\u0026rsquo;s idea and the final menu. Operational Fuel: Ingredient Requests can be \u0026ldquo;Required for\u0026rdquo; a specific dish or submitted as general stock needs. Aggregation: The system must consolidate these requests into a single Grocery List with a specific Delivery Day. Editor (Head Chef): Reviews, edits, and approves Menu Proposals and Ingredient Requests, then publishes the final Menu. From Domain to User Stories # With the domain mapped, I could write user stories that define the MVP.\nHere are a few key examples:\nEpic 1: Menu Suggestions # As a Line Cook, I want to suggest dishes for next week so my ideas aren\u0026rsquo;t forgotten.\nCriteria: Submit name, description, and allergens. Suggestions are tied to my assigned station. As a Head Chef, I want to review pending suggestions in one place so I can curate the menu.\nCriteria: Approve, reject, or edit suggestions. Epic 2: Ingredient Management # As a Line Cook, I want to request ingredients (either for a dish or general stock) so I have what I need for service.\nCriteria: Specify quantity, unit, and notes (e.g., \u0026ldquo;Check dry storage first\u0026rdquo;). As a Head Chef, I want a consolidated Grocery List so I can order efficiently, without checking five different stations for their needs.\nCriteria: Aggregate identical items (e.g., 3x 5kg onions = 15kg total). Export as a clean list. Translating to Technical Model # User stories reveal requirements. Requirements shape the database design.\nHere\u0026rsquo;s how the domain concepts map to entities and relationships:\nclassDiagram %% User relationships User \"1\" --\u003e \"0..1\" Station : is assigned to User \"1\" --\u003e \"*\" DishSuggestion : creates User \"1\" --\u003e \"*\" DishSuggestion : reviews User \"1\" --\u003e \"*\" IngredientRequest : creates User \"1\" --\u003e \"*\" IngredientRequest : reviews User \"1\" --\u003e \"*\" ShoppingList : creates User \"1\" --\u003e \"*\" WeeklyMenu : publishes %% Station relationships Station \"1\" --\u003e \"*\" DishSuggestion : has suggestions from Station \"1\" --\u003e \"*\" WeeklyMenuSlot : appears in %% DishSuggestion relationships DishSuggestion \"*\" --\u003e \"*\" Allergen : contains DishSuggestion \"1\" --\u003e \"*\" IngredientRequest : requires DishSuggestion \"0..1\" --\u003e \"0..1\" WeeklyMenuSlot : appears in %% WeeklyMenu relationships WeeklyMenu \"1\" --\u003e \"25\" WeeklyMenuSlot : contains %% ShoppingList relationships ShoppingList \"1\" --\u003e \"*\" ShoppingListItem : contains ShoppingListItem \"*\" ..\u003e \"*\" IngredientRequest : aggregates Key technical decisions visible in this model:\nMany-to-Many: DishSuggestion ↔ Allergen requires a junction table. A dish can have multiple allergens (gluten, dairy), and an allergen appears in many dishes.\nOptional Relationships:\nUser → Station is 0..1 because the Head Chef might not be assigned to a specific station DishSuggestion → WeeklyMenuSlot is 0..1 because not every suggestion gets published Aggregation Pattern: ShoppingListItem doesn\u0026rsquo;t directly own IngredientRequest entities—it aggregates them. Multiple requests for \u0026ldquo;onions\u0026rdquo; become one line item with a summed quantity.\nFixed Cardinality: WeeklyMenu always has exactly 25 slots (5 days × 5 stations). Empty slots are represented with is_empty = true.\nThis technical model will become the foundation for the ERD and JPA entities.\nRevised Lessons Learned (The \u0026ldquo;Aha!\u0026rdquo; Moments) # If I had jumped straight into coding, I would have missed several critical business rules that make a kitchen actually function:\nDeadlines and Time-Locks: In the kitchen, \u0026ldquo;time is an ingredient.\u0026rdquo; I realized that suggestions cannot be an open-ended process. I’ve implemented a Wednesday 12:00 PM deadline. After this point, the system locks suggestions for the upcoming week and shifts the focus to the week after. This prevents \u0026ldquo;last-minute chaos\u0026rdquo; for the Head Chef and ensures orders are placed on time.\nHistorical Inspiration: A great dish shouldn\u0026rsquo;t be deleted just because the week is over. I realized I need to store Historical Suggestions. This allows a cook or chef to browse through a library of previously approved (or even rejected) ideas to find inspiration for future menus, rather than starting from a blank Word doc every Monday.\nHybrid Ingredient Requests: Initially, I thought every ingredient had to be tied to a specific dish. But kitchens also need \u0026ldquo;the basics\u0026rdquo;—oil, salt, or milk for the staff\u0026rsquo;s coffee. My model now supports Hybrid Requests:\nDish-specific: \u0026ldquo;I need 2kg of Saffron for the Paella.\u0026rdquo;\nGeneral Stock: \u0026ldquo;We are low on frying oil—add two crates to the list.\u0026rdquo;\nThis ensures the shopping list is 100% accurate, capturing both recipe components and general inventory needs.\nEmpty Slots are Intentional: A blank space on a menu grid isn\u0026rsquo;t a missing piece of data—it\u0026rsquo;s a decision. Whether it\u0026rsquo;s \u0026ldquo;Leftover Wednesday\u0026rdquo; or a \u0026ldquo;Kitchen Cleaning Day,\u0026rdquo; the system must treat an empty slot as a valid state, not an error.\nWhat\u0026rsquo;s Next? # The domain is mapped. The requirements are clear. Time to design the actual database.\nNext post: \u0026ldquo;Designing the Database: From Domain Model to ERD\u0026rdquo;\nSome decisions I need to make:\nCan a Line Cook write \u0026ldquo;løg\u0026rdquo;, \u0026ldquo;onions\u0026rdquo;, or \u0026ldquo;Onion\u0026rdquo; — or do I force a dropdown? When a menu is published, do dish suggestions disappear or stick around for inspiration? How do I represent \u0026ldquo;Leftover Wednesday\u0026rdquo; in a database that expects dishes? That\u0026rsquo;s next task: designing the schema and translating it into JPA entities with Hibernate. But for now, I\u0026rsquo;m glad I spent the time understanding the kitchen before trying to automate it\nThis is part 2 of my MiseOS development log. Follow along as I build a tool for professional kitchens, one commit at a time.\n","date":"6 February 2026","externalUrl":null,"permalink":"/posts/designing-miseos/","section":"Posts","summary":"","title":"Understanding the Kitchen: From Real World to User Stories","type":"posts"},{"content":"","date":"6 February 2026","externalUrl":null,"permalink":"/tags/user-stories/","section":"Tags","summary":"","title":"User-Stories","type":"tags"},{"content":"A selection of finished work and one in-progress build.\n","date":"4 February 2026","externalUrl":null,"permalink":"/projects/","section":"Projects","summary":"","title":"Projects","type":"projects"},{"content":"","date":"30 January 2026","externalUrl":null,"permalink":"/tags/backend/","section":"Tags","summary":"","title":"Backend","type":"tags"},{"content":"","date":"30 January 2026","externalUrl":null,"permalink":"/tags/kitchen-tech/","section":"Tags","summary":"","title":"Kitchen-Tech","type":"tags"},{"content":"","date":"30 January 2026","externalUrl":null,"permalink":"/tags/mvp/","section":"Tags","summary":"","title":"Mvp","type":"tags"},{"content":"","date":"30 January 2026","externalUrl":null,"permalink":"/tags/portfolio/","section":"Tags","summary":"","title":"Portfolio","type":"tags"},{"content":"I spent the last week talking to former colleagues from my 20 years in the kitchen industry. Not for nostalgia—though there was plenty of that—but to validate an idea that\u0026rsquo;s been nagging at me for years.\nProfessional kitchens in 2026 still plan their menus in Word documents.\nLet that sink in for a moment. We\u0026rsquo;re talking about operations that serve hundreds of meals daily, coordinate multiple stations, and manage complex inventory. And they\u0026rsquo;re doing it with loose notes, Word templates, and the occasional lost sticky note.\nMenu Planning in Many Operations # Many canteens and restaurants emphasize that cooks should propose dishes they want to present to customers. This is arguably the strongest creative process in a kitchen: when a worker takes inspiration—from a restaurant visit, something seen online, or a conversation with a local farmer—and transforms that vision into something real for their guests.\nThis creative ownership is what keeps talented cooks engaged. It\u0026rsquo;s the difference between \u0026ldquo;I\u0026rsquo;m just following recipes\u0026rdquo; and \u0026ldquo;I\u0026rsquo;m crafting experiences.\u0026rdquo;\nA menu suggestion from the hot station, hand written on a scrap of paper But here\u0026rsquo;s the problem.\nThe Chaos Nobody\u0026rsquo;s Solving # Here\u0026rsquo;s how this beautiful creative process actually works in practice:\nA cook at the cold station gets inspired. Maybe it\u0026rsquo;s a seasonal asparagus dish they saw at a restaurant, maybe it\u0026rsquo;s a farmers market discovery. They scribble it down on a note. If they\u0026rsquo;re feeling organized, they type it into a Word doc. Then they walk over to the kitchen chef to discuss ingredients.\nThe pastry section does the same thing. So does the hot station.\nBy the end of the week, the kitchen chef has:\nA pile of paper scraps Three different Word docs with conflicting version numbers A mental map of who asked for what ingredients No clear way to consolidate orders across stations This is insane.\nNotes everywhere, no system in place The creative process is brilliant. The execution? A mess.\nEnter MiseOS # \u0026ldquo;Mise en place is the religion of all good line cooks.\u0026rdquo; — Anthony Bourdain\nMise en place is a state of mind In Kitchen Confidential, Bourdain describes mise en place as more than just chopped onions; it’s a state of mind. It’s the gathering and preparation of everything you need before you start. If your mise is messy, your service will be a disaster.\nI’m building MiseOS to bring that same philosophy to the digital side of the kitchen. While a cook\u0026rsquo;s physical station might be perfectly prepped with salt, pepper, and prep-containers, their \u0026ldquo;digital station\u0026rdquo;—the menus, orders, and communication—is often a total mess of sticky notes and old Word docs.\nMiseOS isn\u0026rsquo;t just a tool; it\u0026rsquo;s the \u0026ldquo;Operating System\u0026rdquo; for your kitchen\u0026rsquo;s organization. It connects creative menu planning with operational ingredient management in one fluid motion.\nHere\u0026rsquo;s the core workflow:\nCooks submit weekly menu proposals from their stations (Hot, Cold, Pastry, etc.) They request specific ingredients right then and there The kitchen chef sees everything in one place and approves the plan Centralized ordering happens based on actual needs, not guesswork No more lost notes. No more \u0026ldquo;Did you order the saffron?\u0026rdquo; followed by awkward silence. No more retyping the same menu into three different systems.\nBuilding a Real Backend in 10 Weeks # This is part of my 3rd semester portfolio project. The goal is clear:\nWeeks 1-10: Build a production-ready RESTful backend API in Java\nWeeks 11-14: Build a React frontend that consumes it\nThis isn\u0026rsquo;t a toy project. By week 10, I need:\nAuthentication \u0026amp; authorization (JWT) Database persistence (JPA) External API integration Validation \u0026amp; error handling Integration tests Deployment pipeline Actual users with different roles and permissions Then I get 4 weeks to build a React frontend with routing, CSS modules, CI/CD via GitHub Actions, and deployment to Digital Ocean with Docker Compose and Caddy.\nNo pressure, right?\nThe MVP (Weeks 1-10) # So what will MiseOS v1 actually deliver?\nCore Features: # User Management \u0026amp; Roles\nHead Chef: Full menu control, approval authority, shopping list generation Line Cooks: Station-specific menu suggestions, ingredient requests Guests (public): View current menu, no authentication required Weekly Menu Planning\nLine cooks suggest dishes for their station (Salad, Cold/Starter, Hot, Bakery) Head Chef reviews, approves, rejects, or edits suggestions Final approved menu becomes the active weekly menu Ingredient Request Workflow\nLine cooks submit ingredient requests tied to approved dishes Head Chef reviews and approves requests System aggregates approved requests into a shopping list Public Menu Display\nCurrent week\u0026rsquo;s menu visible without login Multilingual support (Danish/English via translation API) Allergen information clearly marked Mobile-friendly view for guests External Integration\nTranslation API (DeepL/Google Translate/LibreTranslate) for menu items and descriptions That\u0026rsquo;s the foundation. Clean workflow, clear roles, solves the core problem.\nThe Nice-to-Have List # Here\u0026rsquo;s what I\u0026rsquo;m not building in v1, but absolutely want to add once the MVP is stable:\nWeather integration\nPull weather forecasts via API Suggest menu adjustments based on temperature(e.g., lighter dishes on hot days) LLM API to suggest seasonal dishes based on weather trends Customer statistics\nTrack number of guests served per day How many guests were served during the same week last year Menu signs\nShow menu signs by exporting to external display systems using APIs like ScreenCloud Guest meeting integration (Pronestor)\nDashboard for handling internal meetings Sections based meeting notes Other Features\nTakeaway order management Recipe scaling calculator Nutrition information display Some of these will definitely get built if there\u0026rsquo;s time in the 10 weeks. Others will have to wait for v2.\nThe beauty of building with clean architecture? These features can plug in later without breaking what\u0026rsquo;s already working.\nOpen for Extension, Closed for Modification # If you know SOLID principles, you know where I\u0026rsquo;m going with this.\nThe architecture I\u0026rsquo;m building is designed to be open for extension—new features can be added without rewriting the core. But it\u0026rsquo;s closed for modification—the MVP functionality stays stable.\nThink of it like building a house. I\u0026rsquo;m not going to install the swimming pool before the foundation is poured. But I am making sure the plumbing and electrical can handle it when the time comes.\nThe nice-to-haves aren\u0026rsquo;t abandoned. They\u0026rsquo;re just waiting for their turn.\nWhat\u0026rsquo;s Next? # I\u0026rsquo;ve set up the GitHub repository and written a proper README that explains the vision.\nNext week gets technical: designing the data model, structuring JPA entities, and figuring out how menu proposals should flow from cook to kitchen chef. There are some interesting challenges ahead—like how to handle ingredient requests that span multiple menus, or what happens when a cook wants to modify a proposal that\u0026rsquo;s already been approved.\nBut first, the database schema. Everything else builds on that foundation.\nThis is going to be fun.\nThis is part 1 of my MiseOS development log. Follow along as I build a tool for professional kitchens, one commit at a time.\n","date":"30 January 2026","externalUrl":null,"permalink":"/posts/my-first-post/","section":"Posts","summary":"","title":"Why I'm Building MiseOS ","type":"posts"},{"content":"","date":"28 January 2026","externalUrl":null,"permalink":"/series/projects/","section":"Series","summary":"","title":"Projects","type":"series"},{"content":"I’m your host, Morten Jensen, responsible for just about everything on this site, where I document my path from professional kitchens to software development.\nI spent 20 years in kitchens. Now I write code.\nI\u0026rsquo;ve always been interested in how software gets built. Working at Microsoft and talking to developers made me realize I should just try it myself.\nSo here we are.\nFrom Kitchens to Code # I worked my way from chef to kitchen leader — managing teams, planning workflows, keeping operations running under pressure.\nWorking at Fleisch a nose-to-tail restaurant Turns out a lot of that translates to software:\nPlan before you execute Communicate clearly when things move fast Own your systems Fix what\u0026rsquo;s broken, improve what works While working at Microsoft, I spent enough time around engineering teams to see the parallels. Eventually I stopped watching and started learning.\nWhat You\u0026rsquo;ll Find Here # I\u0026rsquo;m currently in my 3rd semester studying Computer Science (AP degree).\nThis site includes:\nProjects — Portfolio work from my studies, things I build for fun or to solve real problems Blog — My journey becoming a full-stack developer Most of what I build focuses on backend development, databases, and systems that actually work.\nTeaching \u0026amp; Community # I volunteer at Coding Pirates, helping kids learn programming basics.\nTurns out explaining for loops to 12-year-olds is a good way to make sure you actually understand them yourself.\nExperience \u0026amp; Journey # Professional Chef ~2006 – 2010 Various professional kitchens Worked in professional kitchens, building a strong foundation in teamwork, discipline, and high-pressure execution. Learned early the importance of preparation, structure, and consistency. Kitchen Leader ~2010 – 2017 Leadership \u0026amp; operations Led kitchen teams, managed workflows, and ensured quality and efficiency in daily operations. Developed skills in communication, organization, and problem-solving. Microsoft ~2017 – present Where tech curiosity turned into a career change Served as Sous Chef for 8 years, in their canteen. Responsible for leading teams, planning workflows, and ensuring quality and efficiency in daily operations. Working daily in the Microsoft environment exposed me to modern technology, development culture, and engineering teams. This is where my interest in software development truly began, and where I decided to change career paths. Datamatiker Student Present Software development \u0026amp; systems thinking Currently studying Datamatiker, focusing on backend and frontend development, databases, and clean code. Actively building projects and documenting my learning journey through this site. Check out my projects or read the blog.\n","externalUrl":null,"permalink":"/about/","section":"About Morten Jensen","summary":"","title":"About Morten Jensen","type":"page"},{"content":"","externalUrl":null,"permalink":"/authors/","section":"Authors","summary":"","title":"Authors","type":"authors"}]