Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions .github/workflows/pr-title-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,11 @@ jobs:
- name: Check PR title
run: |
TITLE="${{ github.event.pull_request.title }}"
if [[ ! "$TITLE" =~ ^(feat|fix|chore|docs|refactor|perf|test)(\([a-zA-Z0-9_-]+\))?:[[:space:]].+ ]]; then
echo "❌ PR title must follow conventional commit format: type(scope): description"
echo "Examples:"
if [[ ! "$TITLE" =~ ^(feat|fix|docs|refactor|perf|test|chore)(\([a-zA-Z0-9_:-]+\))?:[[:space:]].+ ]]; then
echo " ✅ feat: add user authentication"
echo " ✅ fix(api): resolve endpoint bug"
echo " ✅ docs: update README"
echo " ✅ chore: update dependencies"
exit 1
fi
echo "✅ PR title follows conventional commit format"
21 changes: 11 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,18 +76,19 @@ jobs:
# ---------------------------------------
- name: Run semantic-release
id: semantic_release
uses: cycjimmy/semantic-release-action@v4
with:
semantic_version: 22
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npx semantic-release

# ---------------------------------------
# GET RELEASE VERSION (only if new release was published)
# ---------------------------------------
- name: Get release version
if: steps.semantic_release.outputs.new_release_published == 'true'
run: |
VERSION=$(git describe --tags --abbrev=0)
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "VERSION=${{ steps.semantic_release.outputs.new_release_version }}" >> $GITHUB_ENV

# ---------------------------------------
# LOGIN TO DOCKER HUB
Expand All @@ -107,14 +108,14 @@ jobs:
run: |
docker build \
-f src/Backend/Dockerfile \
-t ${{ secrets.DOCKERHUB_USERNAME }}/order-processing-backend:${VERSION#v} \
-t ${{ secrets.DOCKERHUB_USERNAME }}/order-processing-backend:${VERSION} \
-t ${{ secrets.DOCKERHUB_USERNAME }}/order-processing-backend:latest \
src/Backend

- name: Push Backend Images
if: steps.semantic_release.outputs.new_release_published == 'true'
run: |
docker push ${{ secrets.DOCKERHUB_USERNAME }}/order-processing-backend:${VERSION#v}
docker push ${{ secrets.DOCKERHUB_USERNAME }}/order-processing-backend:${VERSION}
docker push ${{ secrets.DOCKERHUB_USERNAME }}/order-processing-backend:latest

# ---------------------------------------
Expand All @@ -125,14 +126,14 @@ jobs:
run: |
docker build \
-f src/Frontend/book-store-app/Dockerfile \
-t ${{ secrets.DOCKERHUB_USERNAME }}/order-processing-frontend:${VERSION#v} \
-t ${{ secrets.DOCKERHUB_USERNAME }}/order-processing-frontend:${VERSION} \
-t ${{ secrets.DOCKERHUB_USERNAME }}/order-processing-frontend:latest \
src/Frontend/book-store-app

- name: Push Frontend Images
if: steps.semantic_release.outputs.new_release_published == 'true'
run: |
docker push ${{ secrets.DOCKERHUB_USERNAME }}/order-processing-frontend:${VERSION#v}
docker push ${{ secrets.DOCKERHUB_USERNAME }}/order-processing-frontend:${VERSION}
docker push ${{ secrets.DOCKERHUB_USERNAME }}/order-processing-frontend:latest

# ---------------------------------------
Expand All @@ -143,12 +144,12 @@ jobs:
run: |
docker build \
-f src/Database/Dockerfile \
-t ${{ secrets.DOCKERHUB_USERNAME }}/order-processing-db:${VERSION#v} \
-t ${{ secrets.DOCKERHUB_USERNAME }}/order-processing-db:${VERSION} \
-t ${{ secrets.DOCKERHUB_USERNAME }}/order-processing-db:latest \
src/Database

- name: Push Database Images
if: steps.semantic_release.outputs.new_release_published == 'true'
run: |
docker push ${{ secrets.DOCKERHUB_USERNAME }}/order-processing-db:${VERSION#v}
docker push ${{ secrets.DOCKERHUB_USERNAME }}/order-processing-db:latest
docker push ${{ secrets.DOCKERHUB_USERNAME }}/order-processing-db:${VERSION}
docker push ${{ secrets.DOCKERHUB_USERNAME }}/order-processing-db:latest
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,5 @@ npm-debug.log*
yarn-error.log*
.env
.env.local
.vite/
.map
30 changes: 17 additions & 13 deletions docs/PR_CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,26 +20,30 @@ chore: update dependencies

---


## Allowed Types and Their Meaning

### Release-triggering
### Release-triggering (will create a release and changelog entry)
- feat → Minor version bump
- fix → Patch version bump

### Non-breaking / maintenance
- refactor → Patch
- perf → Patch
- docs → Patch
- test → Patch
- chore → Patch
- docs → Patch version bump
- refactor → Patch version bump
- perf → Patch version bump
- test → Patch version bump

### Breaking Changes
Use ! to indicate a breaking change:
To trigger a major version bump (breaking change), use one of the following:

- Add an exclamation mark after the type: `feat!:` or `fix!:`
- Or, add `BREAKING CHANGE:` in the body or footer of the PR description or commit message:

```
feat(api): remove deprecated endpoint

feat!: change authentication response format
fix!: remove legacy endpoint
BREAKING CHANGE: The /old-endpoint route has been removed and will break clients using it.
```

→ Major version bump
Both methods are supported by semantic-release and will trigger a major version bump.

---

Expand All @@ -61,7 +65,7 @@ Common scopes:
## Important Rules

- PR title **must follow the format**
- Use **Squash and merge**
- Use **Squash and merge** (when merging to the dev branch)
- Feature branch commit messages can be anything
- The squash commit message = PR title

Expand Down
19 changes: 14 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,30 @@
"@semantic-release/commit-analyzer",
{
"preset": "angular",
"releaseRules": [
{ "type": "docs", "scope": "README", "release": "patch" },
{ "type": "*", "release": "patch" }
]
"releaseRules": [
{ "type": "docs", "release": "patch" },
{ "type": "refactor", "release": "patch" },
{ "type": "test", "release": "patch" }
]
}
],
"@semantic-release/release-notes-generator",
"@semantic-release/changelog",
"@semantic-release/github"
"@semantic-release/github",
[
"@semantic-release/git",
{
"assets": ["CHANGELOG.md", "package.json"],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}
]
]
},
"devDependencies": {
"@semantic-release/changelog": "^6.0.0",
"@semantic-release/commit-analyzer": "^11.0.0",
"@semantic-release/github": "^11.0.0",
"@semantic-release/git": "^10.0.1",
"@semantic-release/release-notes-generator": "^13.0.0",
"semantic-release": "^25.0.2"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,6 @@ public async Task<Results<Ok<ShoppingCartDetailsDto>, UnauthorizedHttpResult>> G
return TypedResults.Ok(cartDetails);
}

[HttpGet("test")]
public IActionResult Test()
{
return Ok(new { message = "Test endpoint working", timestamp = DateTime.Now });
}

[HttpDelete]
public async Task<Results<NoContent, UnauthorizedHttpResult>> ClearCart()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ private Task HandleExceptionAsync(HttpContext context, Exception exception)
{
UnauthorizedAccessException ex => (HttpStatusCode.Unauthorized, ex.Message),
NotFoundException ex => (HttpStatusCode.NotFound, ex.Message),
InsufficientStockException ex => (HttpStatusCode.BadRequest, ex.Message),
DuplicateResourceException ex => (HttpStatusCode.Conflict, ex.Message),
BusinessRuleViolationException ex => (HttpStatusCode.BadRequest, ex.Message),
ArgumentException ex => (HttpStatusCode.BadRequest, ex.Message),
Expand All @@ -52,15 +53,32 @@ DllNotFoundException ex when ex.Message.Contains("libgssapi") =>
context.Response.ContentType = "application/json";
context.Response.StatusCode = (int)statusCode;

// Special-case insufficient stock to return structured details (single or multiple items)
if (exception is InsufficientStockException ise)
{
// Build a typed response DTO for clarity and testability
var dtoItems = (ise.Items ?? new[] { new InsufficientStockException.InsufficientItem(ise.ISBN ?? string.Empty, ise.Available ?? 0, ise.Title) })
.Select(i => new InsufficientStockItemDto(i.ISBN, i.Title, i.Available));

var body = new InsufficientStockResponse("Insufficient stock", dtoItems);
return context.Response.WriteAsync(JsonSerializer.Serialize(body));
}

var response = new ErrorResponse(message, (int)statusCode);
return context.Response.WriteAsync(JsonSerializer.Serialize(response));
}

private void LogException(Exception exception, HttpStatusCode statusCode, string message)
{
if (statusCode == HttpStatusCode.InternalServerError)
{
// For internal server errors include exception and stack trace
_logger.LogError(exception, "Unhandled exception: {Message}", message);
}
else
_logger.LogWarning(exception, "{Type}: {Message}", exception.GetType().Name, message);
{
// For expected/non-critical errors (e.g. domain validations) log only type and message
_logger.LogWarning("{Type}: {Message}", exception.GetType().Name, message);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace OrderProcessing.Api.Models;

public record InsufficientStockItemDto(string isbn, string? title, int available);

public record InsufficientStockResponse(string error, IEnumerable<InsufficientStockItemDto> items);
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ public static CartItemDetailsDto ToCartItemDetailsDto(this CartItem item, string
);
}

// Overload to support read-model mapping used in repository return types
public static CartItemDetailsDto ToCartItemDetailsDto(this CartItemReadModel item, string title, List<string> authors, int stock)
{
return new CartItemDetailsDto(
item.ISBN,
title,
authors,
item.Quantity,
item.UnitPrice,
item.Quantity * item.UnitPrice,
stock
);
}

public static ShoppingCartDetailsDto ToShoppingCartDetailsDto(this ShoppingCartReadModel cart, List<CartItemDetailsDto> items)
{
return new ShoppingCartDetailsDto(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,20 +40,9 @@ public async Task<IReadOnlyList<CustomerOrderDto>> GetMyOrdersAsync(string token
{
// Get order items
var orderItems = await _orderRepository.GetOrderItemsAsync(order.OrderNumber);

// Get book details for each item
var itemDtos = new List<OrderItemDto>();
foreach (var item in orderItems)
{
var book = await _bookRepository.GetBookDetailsAsync(item.ISBN);
// If book details not found, use ISBN as title fallback
itemDtos.Add(new OrderItemDto(
item.ISBN,
book?.Title ?? item.ISBN,
item.Quantity,
item.UnitPrice
));
}
var itemDtos = orderItems
.Select(item => new OrderItemDto(item.ISBN, item.Title, item.Quantity, item.UnitPrice))
.ToList();

// Use shipping address from order (snapshot at order time)
orderDtos.Add(new CustomerOrderDto(
Expand Down Expand Up @@ -93,7 +82,7 @@ public async Task<CustomerOrderDto> CreateOrderAsync(string token, CreateOrderRe
throw new InvalidOperationException($"Product with ISBN {item.ISBN} not found.");
var unitPrice = product.SellingPrice;
totalPrice += unitPrice * item.Quantity;
orderItems.Add(new CustomerOrderItem { ISBN = item.ISBN, OrderNum = 0, Quantity = item.Quantity, UnitPrice = unitPrice });
orderItems.Add(new CustomerOrderItem { ISBN = item.ISBN, OrderNum = 0, Quantity = item.Quantity, UnitPrice = unitPrice, Title = product.Title });
}

CustomerOrder newOrder;
Expand All @@ -110,17 +99,9 @@ public async Task<CustomerOrderDto> CreateOrderAsync(string token, CreateOrderRe

// Get order items with book details for response
var orderItemsFromDb = await _orderRepository.GetOrderItemsAsync(orderId);
var itemDtos = new List<OrderItemDto>();
foreach (var item in orderItemsFromDb)
{
var book = await _bookRepository.GetBookDetailsAsync(item.ISBN);
itemDtos.Add(new OrderItemDto(
item.ISBN,
book?.Title ?? item.ISBN,
item.Quantity,
item.UnitPrice
));
}
var itemDtos = orderItemsFromDb
.Select(item => new OrderItemDto(item.ISBN, item.Title, item.Quantity, item.UnitPrice))
.ToList();

return new CustomerOrderDto(
orderId,
Expand Down
Loading
Loading