diff --git a/databases/grocery-db/init.sql b/databases/grocery-db/init.sql
index 9316198..34d3cda 100644
--- a/databases/grocery-db/init.sql
+++ b/databases/grocery-db/init.sql
@@ -23,7 +23,8 @@ CREATE TABLE IF NOT EXISTS grocery_lists (
grocery_list_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
outdated BOOLEAN NOT NULL DEFAULT FALSE,
- user_id UUID NOT NULL
+ user_id UUID NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS grocery_list_recipes (
diff --git a/databases/user-db/init.sql b/databases/user-db/init.sql
index 201fe84..f588637 100644
--- a/databases/user-db/init.sql
+++ b/databases/user-db/init.sql
@@ -8,3 +8,9 @@ CREATE TABLE IF NOT EXISTS users (
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
+-- Dev admin account: login with admin@bytebite.dev / password
+INSERT INTO users (name, email, password_hash) VALUES (
+ 'Admin',
+ 'admin@bytebite.dev',
+ '$2b$12$F0rLm1sYDRHEUjQkni8z3e7dpeFNW4irxo4jkMgP4YMGffswtD.OS'
+) ON CONFLICT (email) DO NOTHING;
diff --git a/server/api-gateway/src/main/resources/application.yml b/server/api-gateway/src/main/resources/application.yml
index 543a185..67b10a8 100644
--- a/server/api-gateway/src/main/resources/application.yml
+++ b/server/api-gateway/src/main/resources/application.yml
@@ -32,7 +32,7 @@ spring:
- id: grocery-service
uri: ${GROCERY_SERVICE_BASE_URL:http://localhost:8082}
predicates:
- - Path=/api/recipes/**
+ - Path=/api/recipes/**,/api/grocery-list/**
- id: user-service
uri: ${USER_SERVICE_BASE_URL:http://localhost:8083}
predicates:
diff --git a/server/grocery-service/pom.xml b/server/grocery-service/pom.xml
index eb1b6bf..ef8b7f7 100644
--- a/server/grocery-service/pom.xml
+++ b/server/grocery-service/pom.xml
@@ -42,6 +42,17 @@
${springdoc-openapi.version}
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+
+ org.postgresql
+ postgresql
+ runtime
+
+
org.springframework.boot
spring-boot-starter-test
diff --git a/server/grocery-service/src/main/java/com/bytebite/server/GroceryListController.java b/server/grocery-service/src/main/java/com/bytebite/server/GroceryListController.java
new file mode 100644
index 0000000..cfc0d80
--- /dev/null
+++ b/server/grocery-service/src/main/java/com/bytebite/server/GroceryListController.java
@@ -0,0 +1,64 @@
+package com.bytebite.server;
+
+import com.bytebite.server.dto.GroceryListDetailDTO;
+import com.bytebite.server.dto.GroceryListSummaryDTO;
+import com.bytebite.server.service.GroceryListService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+@RestController
+@RequestMapping("/api/grocery-list")
+@Tag(name = "Grocery Lists", description = "Stored grocery-list history and details")
+public class GroceryListController {
+
+ private final GroceryListService service;
+
+ public GroceryListController(GroceryListService service) {
+ this.service = service;
+ }
+
+ @GetMapping(value = "/history", produces = MediaType.APPLICATION_JSON_VALUE)
+ @Operation(
+ summary = "List recent grocery lists",
+ description = "Returns a summary of the 20 most recently created grocery lists, newest first.",
+ security = @SecurityRequirement(name = "bearerAuth"),
+ responses = {
+ @ApiResponse(responseCode = "200", description = "Grocery-list summaries"),
+ @ApiResponse(responseCode = "401", description = "Missing, expired, or invalid JWT", content = @Content)
+ }
+ )
+ public List getHistory() {
+ return service.getHistory();
+ }
+
+ @GetMapping(value = "/history/{id}", produces = MediaType.APPLICATION_JSON_VALUE)
+ @Operation(
+ summary = "Get a grocery list by id",
+ description = "Returns a single grocery list including its items.",
+ security = @SecurityRequirement(name = "bearerAuth"),
+ responses = {
+ @ApiResponse(responseCode = "200", description = "Grocery list with items"),
+ @ApiResponse(responseCode = "401", description = "Missing, expired, or invalid JWT", content = @Content),
+ @ApiResponse(responseCode = "404", description = "Grocery list not found", content = @Content(schema = @Schema(implementation = Map.class)))
+ }
+ )
+ public GroceryListDetailDTO getById(
+ @Parameter(description = "Identifier of the grocery list", required = true)
+ @PathVariable UUID id) {
+ return service.getById(id);
+ }
+}
diff --git a/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryItemResponseDTO.java b/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryItemResponseDTO.java
new file mode 100644
index 0000000..d5c10f4
--- /dev/null
+++ b/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryItemResponseDTO.java
@@ -0,0 +1,5 @@
+package com.bytebite.server.dto;
+
+import java.util.UUID;
+
+public record GroceryItemResponseDTO(UUID id, String name, double quantity, String unit, String category, boolean purchased) {}
diff --git a/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryListDetailDTO.java b/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryListDetailDTO.java
new file mode 100644
index 0000000..a3970c3
--- /dev/null
+++ b/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryListDetailDTO.java
@@ -0,0 +1,7 @@
+package com.bytebite.server.dto;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.UUID;
+
+public record GroceryListDetailDTO(UUID id, String name, Instant createdAt, List items) {}
diff --git a/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryListSummaryDTO.java b/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryListSummaryDTO.java
new file mode 100644
index 0000000..06fd30b
--- /dev/null
+++ b/server/grocery-service/src/main/java/com/bytebite/server/dto/GroceryListSummaryDTO.java
@@ -0,0 +1,6 @@
+package com.bytebite.server.dto;
+
+import java.time.Instant;
+import java.util.UUID;
+
+public record GroceryListSummaryDTO(UUID id, String name, Instant createdAt) {}
diff --git a/server/grocery-service/src/main/java/com/bytebite/server/entity/GroceryCategory.java b/server/grocery-service/src/main/java/com/bytebite/server/entity/GroceryCategory.java
new file mode 100644
index 0000000..16f800d
--- /dev/null
+++ b/server/grocery-service/src/main/java/com/bytebite/server/entity/GroceryCategory.java
@@ -0,0 +1,14 @@
+package com.bytebite.server.entity;
+
+public enum GroceryCategory {
+ PRODUCE,
+ DAIRY,
+ MEAT,
+ SEAFOOD,
+ BAKERY,
+ PANTRY,
+ FROZEN,
+ BEVERAGES,
+ SPICES,
+ OTHER
+}
diff --git a/server/grocery-service/src/main/java/com/bytebite/server/entity/GroceryItem.java b/server/grocery-service/src/main/java/com/bytebite/server/entity/GroceryItem.java
new file mode 100644
index 0000000..2cff949
--- /dev/null
+++ b/server/grocery-service/src/main/java/com/bytebite/server/entity/GroceryItem.java
@@ -0,0 +1,40 @@
+package com.bytebite.server.entity;
+
+import jakarta.persistence.*;
+import java.util.UUID;
+
+@Entity
+@Table(name = "grocery_items")
+public class GroceryItem {
+
+ @Id
+ @Column(name = "item_id")
+ private UUID id;
+
+ @Column(nullable = false)
+ private String name;
+
+ @Column(nullable = false)
+ private double quantity;
+
+ @Column(nullable = false)
+ private String unit;
+
+ @Enumerated(EnumType.STRING)
+ @Column(columnDefinition = "grocery_category", nullable = false)
+ private GroceryCategory category;
+
+ @Column(name = "is_purchased", nullable = false)
+ private boolean purchased;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "grocery_list_id")
+ private GroceryList groceryList;
+
+ public UUID getId() { return id; }
+ public String getName() { return name; }
+ public double getQuantity() { return quantity; }
+ public String getUnit() { return unit; }
+ public GroceryCategory getCategory() { return category; }
+ public boolean isPurchased() { return purchased; }
+}
diff --git a/server/grocery-service/src/main/java/com/bytebite/server/entity/GroceryList.java b/server/grocery-service/src/main/java/com/bytebite/server/entity/GroceryList.java
new file mode 100644
index 0000000..8311867
--- /dev/null
+++ b/server/grocery-service/src/main/java/com/bytebite/server/entity/GroceryList.java
@@ -0,0 +1,37 @@
+package com.bytebite.server.entity;
+
+import jakarta.persistence.*;
+import java.time.Instant;
+import java.util.List;
+import java.util.UUID;
+
+@Entity
+@Table(name = "grocery_lists")
+public class GroceryList {
+
+ @Id
+ @Column(name = "grocery_list_id")
+ private UUID id;
+
+ @Column(nullable = false)
+ private String name;
+
+ @Column(nullable = false)
+ private boolean outdated;
+
+ @Column(name = "user_id", nullable = false)
+ private UUID userId;
+
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private Instant createdAt;
+
+ @OneToMany(mappedBy = "groceryList", fetch = FetchType.LAZY)
+ private List items;
+
+ public UUID getId() { return id; }
+ public String getName() { return name; }
+ public boolean isOutdated() { return outdated; }
+ public UUID getUserId() { return userId; }
+ public Instant getCreatedAt() { return createdAt; }
+ public List getItems() { return items; }
+}
diff --git a/server/grocery-service/src/main/java/com/bytebite/server/repository/GroceryListRepository.java b/server/grocery-service/src/main/java/com/bytebite/server/repository/GroceryListRepository.java
new file mode 100644
index 0000000..3038a99
--- /dev/null
+++ b/server/grocery-service/src/main/java/com/bytebite/server/repository/GroceryListRepository.java
@@ -0,0 +1,14 @@
+package com.bytebite.server.repository;
+
+import com.bytebite.server.entity.GroceryList;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.UUID;
+
+@Repository
+public interface GroceryListRepository extends JpaRepository {
+
+ List findTop20ByOrderByCreatedAtDesc();
+}
diff --git a/server/grocery-service/src/main/java/com/bytebite/server/service/GroceryListService.java b/server/grocery-service/src/main/java/com/bytebite/server/service/GroceryListService.java
new file mode 100644
index 0000000..65ceb17
--- /dev/null
+++ b/server/grocery-service/src/main/java/com/bytebite/server/service/GroceryListService.java
@@ -0,0 +1,51 @@
+package com.bytebite.server.service;
+
+import com.bytebite.server.dto.GroceryItemResponseDTO;
+import com.bytebite.server.dto.GroceryListDetailDTO;
+import com.bytebite.server.dto.GroceryListSummaryDTO;
+import com.bytebite.server.entity.GroceryList;
+import com.bytebite.server.repository.GroceryListRepository;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.server.ResponseStatusException;
+
+import java.util.List;
+import java.util.UUID;
+
+@Service
+public class GroceryListService {
+
+ private final GroceryListRepository repository;
+
+ public GroceryListService(GroceryListRepository repository) {
+ this.repository = repository;
+ }
+
+ @Transactional(readOnly = true)
+ public List getHistory() {
+ return repository.findTop20ByOrderByCreatedAtDesc().stream()
+ .map(gl -> new GroceryListSummaryDTO(gl.getId(), gl.getName(), gl.getCreatedAt()))
+ .toList();
+ }
+
+ @Transactional(readOnly = true)
+ public GroceryListDetailDTO getById(UUID id) {
+ GroceryList groceryList = repository.findById(id)
+ .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
+ "Grocery list not found: " + id));
+
+ List items = groceryList.getItems().stream()
+ .map(item -> new GroceryItemResponseDTO(
+ item.getId(),
+ item.getName(),
+ item.getQuantity(),
+ item.getUnit(),
+ item.getCategory().name(),
+ item.isPurchased()
+ ))
+ .toList();
+
+ return new GroceryListDetailDTO(groceryList.getId(), groceryList.getName(), groceryList.getCreatedAt(), items);
+ }
+}
diff --git a/server/grocery-service/src/main/resources/application.properties b/server/grocery-service/src/main/resources/application.properties
index 6920a85..adecbc4 100644
--- a/server/grocery-service/src/main/resources/application.properties
+++ b/server/grocery-service/src/main/resources/application.properties
@@ -1,3 +1,9 @@
spring.application.name=grocery-service
server.port=8082
genai.base-url=http://localhost:8000
+
+spring.datasource.url=${GROCERY_DB_URL:jdbc:postgresql://localhost:5432/grocerydb}
+spring.datasource.username=${GROCERY_DB_USER:postgres}
+spring.datasource.password=${GROCERY_DB_PASSWORD:postgres}
+spring.jpa.hibernate.ddl-auto=none
+spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect