diff --git a/backend/.gitignore b/backend/.gitignore
new file mode 100644
index 00000000..1a527ca6
--- /dev/null
+++ b/backend/.gitignore
@@ -0,0 +1,55 @@
+target/
+!.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea/*
+!.idea/runConfigurations/
+!.idea/.gitignore
+!.idea/compiler.xml
+!.idea/encodings.xml
+!.idea/jarRepositories.xml
+!.idea/misc.xml
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
+
+### Mac ###
+.DS_Store
+
+### Project Specific ###
+tmp/
+uploads/
+
+### Temp and backup files ###
+*.backup
+*.backup*
+*.bak
+*.tmp
+*.py
+temp_*.json
+last_part.json
+fix_*.py
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 00000000..95c76bc2
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,13 @@
+# Build
+FROM maven:3.9-eclipse-temurin-17 AS build
+WORKDIR /app
+COPY pom.xml .
+COPY src ./src
+RUN mvn -q -DskipTests package
+
+# Run
+FROM eclipse-temurin:17-jre
+WORKDIR /app
+COPY --from=build /app/target/*.jar app.jar
+EXPOSE 8080
+ENTRYPOINT ["java","-jar","app.jar"]
diff --git a/backend/docker-compose.db.yml b/backend/docker-compose.db.yml
new file mode 100644
index 00000000..7d8408d0
--- /dev/null
+++ b/backend/docker-compose.db.yml
@@ -0,0 +1,21 @@
+services:
+ db:
+ image: mysql:8.0
+ container_name: petshop-db
+ environment:
+ MYSQL_ROOT_PASSWORD: root
+ MYSQL_DATABASE: Petstoredb
+ MYSQL_USER: petshop
+ MYSQL_PASSWORD: petshop
+ ports:
+ - "3306:3306"
+ volumes:
+ - db_data:/var/lib/mysql
+ healthcheck:
+ test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot"]
+ interval: 5s
+ timeout: 5s
+ retries: 30
+
+volumes:
+ db_data:
diff --git a/backend/docker-compose.dev.yml b/backend/docker-compose.dev.yml
new file mode 100644
index 00000000..774e5393
--- /dev/null
+++ b/backend/docker-compose.dev.yml
@@ -0,0 +1,23 @@
+services:
+ db:
+ image: mysql:8.0
+ container_name: petshop-db
+ restart: always
+ environment:
+ MYSQL_ROOT_PASSWORD: root
+ MYSQL_DATABASE: Petstoredb
+ MYSQL_USER: petshop
+ MYSQL_PASSWORD: petshop
+ ports:
+ - "3306:3306"
+ volumes:
+ - db_data:/var/lib/mysql
+ healthcheck:
+ test: ["CMD", "mysql", "-uroot", "-proot", "-e", "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='Petstoredb' AND table_name='users';"]
+ interval: 10s
+ timeout: 5s
+ retries: 30
+ start_period: 40s
+
+volumes:
+ db_data:
diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml
new file mode 100644
index 00000000..1966e7e6
--- /dev/null
+++ b/backend/docker-compose.yml
@@ -0,0 +1,36 @@
+services:
+ db:
+ image: mysql:8.0
+ container_name: petshop-db
+ environment:
+ MYSQL_ROOT_PASSWORD: root
+ MYSQL_DATABASE: Petstoredb
+ MYSQL_USER: petshop
+ MYSQL_PASSWORD: petshop
+ ports:
+ - "3306:3306"
+ volumes:
+ - db_data:/var/lib/mysql
+ healthcheck:
+ test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot"]
+ interval: 5s
+ timeout: 5s
+ retries: 30
+
+ api:
+ build: .
+ container_name: petshop-api
+ environment:
+ SPRING_DATASOURCE_URL: jdbc:mysql://db:3306/Petstoredb?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
+ SPRING_DATASOURCE_USERNAME: petshop
+ SPRING_DATASOURCE_PASSWORD: petshop
+ # Change this in real use (must be at least 32 characters)
+ JWT_SECRET: change_me_please_this_secret_key_is_long_enough_for_jwt_hmac_sha256
+ ports:
+ - "8080:8080"
+ depends_on:
+ db:
+ condition: service_healthy
+
+volumes:
+ db_data:
diff --git a/backend/petshop-api.postman_collection.json b/backend/petshop-api.postman_collection.json
new file mode 100644
index 00000000..d95a5b86
--- /dev/null
+++ b/backend/petshop-api.postman_collection.json
@@ -0,0 +1,4471 @@
+{
+ "info": {
+ "name": "PetShop API Complete Collection",
+ "_postman_id": "petshop-api-complete-v1",
+ "description": "Complete API collection with all 95+ verified endpoints",
+ "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
+ },
+ "variable": [
+ {
+ "key": "baseUrl",
+ "value": "http://localhost:8080"
+ },
+ {
+ "key": "customerToken",
+ "value": ""
+ },
+ {
+ "key": "staffToken",
+ "value": ""
+ },
+ {
+ "key": "adminToken",
+ "value": ""
+ },
+ {
+ "key": "petId",
+ "value": ""
+ },
+ {
+ "key": "productId",
+ "value": ""
+ },
+ {
+ "key": "saleId",
+ "value": ""
+ },
+ {
+ "key": "customerSaleId",
+ "value": ""
+ },
+ {
+ "key": "serviceId",
+ "value": "1"
+ },
+ {
+ "key": "categoryId",
+ "value": "1"
+ },
+ {
+ "key": "appointmentId",
+ "value": ""
+ },
+ {
+ "key": "adoptionId",
+ "value": ""
+ },
+ {
+ "key": "refundId",
+ "value": ""
+ },
+ {
+ "key": "conversationId",
+ "value": ""
+ },
+ {
+ "key": "customerId",
+ "value": "1"
+ },
+ {
+ "key": "disposableCustomerId",
+ "value": ""
+ },
+ {
+ "key": "userId",
+ "value": ""
+ },
+ {
+ "key": "storeId",
+ "value": "1"
+ },
+ {
+ "key": "inventoryId",
+ "value": "1"
+ },
+ {
+ "key": "supplierId",
+ "value": "1"
+ },
+ {
+ "key": "avatarFile",
+ "value": "postman/avatar.png"
+ },
+ {
+ "key": "bulkPetId",
+ "value": ""
+ },
+ {
+ "key": "bulkServiceId",
+ "value": ""
+ },
+ {
+ "key": "bulkCategoryId",
+ "value": ""
+ },
+ {
+ "key": "bulkCustomerId",
+ "value": ""
+ },
+ {
+ "key": "bulkUserId",
+ "value": ""
+ },
+ {
+ "key": "bulkStoreId",
+ "value": ""
+ },
+ {
+ "key": "bulkInventoryId",
+ "value": ""
+ }
+ ],
+ "item": [
+ {
+ "name": "Health",
+ "item": [
+ {
+ "name": "Health Check",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/health",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "Authentication",
+ "item": [
+ {
+ "name": "Register Customer",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/auth/register",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"username\": \"newcustomer{{$timestamp}}\",\n \"password\": \"password123\",\n \"email\": \"new{{$timestamp}}@example.com\",\n \"phone\": \"+1-555-01{{$randomInt}}\",\n \"fullName\": \"New Customer\"\n}"
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 201', function () {",
+ " pm.response.to.have.status(201);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "if (jsonData.token) pm.collectionVariables.set('customerToken', jsonData.token);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Login as Customer",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/auth/login",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"username\": \"customer\",\n \"password\": \"customer123\"\n}"
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "if (jsonData.token) pm.collectionVariables.set('customerToken', jsonData.token);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Login as Staff",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/auth/login",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"username\": \"staff\",\n \"password\": \"staff123\"\n}"
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "if (jsonData.token) pm.collectionVariables.set('staffToken', jsonData.token);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Login as Admin",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/auth/login",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"username\": \"admin\",\n \"password\": \"admin123\"\n}"
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "if (jsonData.token) pm.collectionVariables.set('adminToken', jsonData.token);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Get My Profile",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/auth/me",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{customerToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Update My Profile",
+ "request": {
+ "method": "PUT",
+ "url": "{{baseUrl}}/api/v1/auth/me",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{customerToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"fullName\": \"Updated Name\",\n \"email\": \"updated@example.com\"\n}"
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Upload Avatar",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/auth/me/avatar",
+ "header": [
+ {
+ "key": "Authorization",
+ "value": "Bearer {{customerToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "formdata",
+ "formdata": [
+ {
+ "key": "avatar",
+ "type": "file",
+ "src": "{{avatarFile}}"
+ }
+ ]
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "pm.expect(jsonData.avatarUrl).to.be.a('string');"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Get Avatar",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/auth/me/avatar",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{customerToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "pm.expect(jsonData.avatarUrl).to.be.a('string');"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Delete Avatar",
+ "request": {
+ "method": "DELETE",
+ "url": "{{baseUrl}}/api/v1/auth/me/avatar",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{customerToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Logout",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/auth/logout",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{customerToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "Pets",
+ "item": [
+ {
+ "name": "Get All Pets",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/pets",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Get Pet by ID",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/pets/1",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Get Pets Dropdown",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/dropdowns/pets",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Create Pet",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/pets",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"petName\": \"Postman Pet\",\n \"petSpecies\": \"Dog\",\n \"petBreed\": \"Mixed\",\n \"petAge\": 2,\n \"petStatus\": \"Available\",\n \"petPrice\": 350.00\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 201', function () {",
+ " pm.response.to.have.status(201);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "if (jsonData.petId !== undefined) pm.collectionVariables.set('petId', jsonData.petId);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Update Pet",
+ "request": {
+ "method": "PUT",
+ "url": "{{baseUrl}}/api/v1/pets/{{petId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"petName\": \"Postman Pet Updated\",\n \"petSpecies\": \"Dog\",\n \"petBreed\": \"Mixed\",\n \"petAge\": 3,\n \"petStatus\": \"Available\",\n \"petPrice\": 375.00\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Delete Pet",
+ "request": {
+ "method": "DELETE",
+ "url": "{{baseUrl}}/api/v1/pets/{{petId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 204', function () {",
+ " pm.response.to.have.status(204);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Create Pet For Bulk Delete",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/pets",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"petName\": \"Postman Pet\",\n \"petSpecies\": \"Dog\",\n \"petBreed\": \"Mixed\",\n \"petAge\": 2,\n \"petStatus\": \"Available\",\n \"petPrice\": 350.00\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 201', function () {",
+ " pm.response.to.have.status(201);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "if (jsonData.petId !== undefined) pm.collectionVariables.set('bulkPetId', jsonData.petId);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Bulk Delete Pets",
+ "request": {
+ "method": "DELETE",
+ "url": "{{baseUrl}}/api/v1/pets",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"ids\": [\n {{bulkPetId}}\n ]\n}"
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 204', function () {",
+ " pm.response.to.have.status(204);",
+ "});"
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "Products",
+ "item": [
+ {
+ "name": "Get All Products",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/products",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Get Product by ID",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/products/1",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Get Products Dropdown",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/dropdowns/products",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Create Product",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/products",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"prodName\": \"Postman Product\",\n \"categoryId\": {{categoryId}},\n \"prodDesc\": \"Created from Postman\",\n \"prodPrice\": 19.99\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 201', function () {",
+ " pm.response.to.have.status(201);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "if (jsonData.prodId !== undefined) pm.collectionVariables.set('productId', jsonData.prodId);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Update Product",
+ "request": {
+ "method": "PUT",
+ "url": "{{baseUrl}}/api/v1/products/{{productId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"prodName\": \"Postman Product Updated\",\n \"categoryId\": {{categoryId}},\n \"prodDesc\": \"Updated from Postman\",\n \"prodPrice\": 24.99\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Delete Product",
+ "request": {
+ "method": "DELETE",
+ "url": "{{baseUrl}}/api/v1/products/{{productId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 403', function () {",
+ " pm.response.to.have.status(403);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Bulk Delete Products",
+ "request": {
+ "method": "DELETE",
+ "url": "{{baseUrl}}/api/v1/products",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"ids\": [\n 1\n ]\n}"
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 403', function () {",
+ " pm.response.to.have.status(403);",
+ "});"
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "Sales",
+ "item": [
+ {
+ "name": "Get All Sales",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/sales",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Get Sale by ID",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/sales/1",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Create Sale",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/sales",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"storeId\": 1,\n \"paymentMethod\": \"Card\",\n \"customerId\": 1,\n \"items\": [\n {\n \"prodId\": 1,\n \"quantity\": 2\n },\n {\n \"prodId\": 2,\n \"quantity\": 1\n }\n ],\n \"isRefund\": false\n}"
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 201', function () {",
+ " pm.response.to.have.status(201);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "if (jsonData.saleId !== undefined) {",
+ " pm.collectionVariables.set('saleId', jsonData.saleId);",
+ " pm.collectionVariables.set('customerSaleId', jsonData.saleId);",
+ "}"
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "Services",
+ "item": [
+ {
+ "name": "Get All Services",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/services",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Get Service by ID",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/services/1",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Get Services Dropdown",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/dropdowns/services",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Create Service",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/services",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"serviceName\": \"Postman Service\",\n \"serviceDesc\": \"Created from Postman\",\n \"serviceDuration\": 30,\n \"servicePrice\": 25.00\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 201', function () {",
+ " pm.response.to.have.status(201);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "if (jsonData.serviceId !== undefined) pm.collectionVariables.set('serviceId', jsonData.serviceId);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Update Service",
+ "request": {
+ "method": "PUT",
+ "url": "{{baseUrl}}/api/v1/services/{{serviceId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"serviceName\": \"Postman Service Updated\",\n \"serviceDesc\": \"Updated from Postman\",\n \"serviceDuration\": 30,\n \"servicePrice\": 30.00\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Delete Service",
+ "request": {
+ "method": "DELETE",
+ "url": "{{baseUrl}}/api/v1/services/{{serviceId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 204', function () {",
+ " pm.response.to.have.status(204);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Create Service For Bulk Delete",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/services",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"serviceName\": \"Postman Service\",\n \"serviceDesc\": \"Created from Postman\",\n \"serviceDuration\": 30,\n \"servicePrice\": 25.00\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 201', function () {",
+ " pm.response.to.have.status(201);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "if (jsonData.serviceId !== undefined) pm.collectionVariables.set('bulkServiceId', jsonData.serviceId);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Bulk Delete Services",
+ "request": {
+ "method": "DELETE",
+ "url": "{{baseUrl}}/api/v1/services",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"ids\": [\n {{bulkServiceId}}\n ]\n}"
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 204', function () {",
+ " pm.response.to.have.status(204);",
+ "});"
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "Categories",
+ "item": [
+ {
+ "name": "Get All Categories",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/categories",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Get Category by ID",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/categories/1",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Get Categories Dropdown",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/dropdowns/categories",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Create Category",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/categories",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"categoryName\": \"Postman Category\",\n \"categoryType\": \"Product\"\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 201', function () {",
+ " pm.response.to.have.status(201);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "if (jsonData.categoryId !== undefined) pm.collectionVariables.set('categoryId', jsonData.categoryId);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Update Category",
+ "request": {
+ "method": "PUT",
+ "url": "{{baseUrl}}/api/v1/categories/{{categoryId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"categoryName\": \"Postman Category Updated\",\n \"categoryType\": \"Product\"\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Delete Category",
+ "request": {
+ "method": "DELETE",
+ "url": "{{baseUrl}}/api/v1/categories/{{categoryId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 204', function () {",
+ " pm.response.to.have.status(204);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Create Category For Bulk Delete",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/categories",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"categoryName\": \"Postman Category\",\n \"categoryType\": \"Product\"\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 201', function () {",
+ " pm.response.to.have.status(201);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "if (jsonData.categoryId !== undefined) pm.collectionVariables.set('bulkCategoryId', jsonData.categoryId);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Bulk Delete Categories",
+ "request": {
+ "method": "DELETE",
+ "url": "{{baseUrl}}/api/v1/categories",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"ids\": [\n {{bulkCategoryId}}\n ]\n}"
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 204', function () {",
+ " pm.response.to.have.status(204);",
+ "});"
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "Appointments",
+ "item": [
+ {
+ "name": "Check Appointment Availability",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/appointments/availability?storeId=1&serviceId=1&date=2026-12-20",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "List Appointments",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/appointments",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Get Appointment",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/appointments/1",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Create Appointment",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/appointments",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"customerId\": 1,\n \"storeId\": 1,\n \"serviceId\": 1,\n \"appointmentDate\": \"2026-12-20\",\n \"appointmentTime\": \"10:00:00\",\n \"appointmentStatus\": \"Booked\",\n \"petIds\": [1]\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 201', function () {",
+ " pm.response.to.have.status(201);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "if (jsonData.appointmentId !== undefined) pm.collectionVariables.set('appointmentId', jsonData.appointmentId);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Update Appointment",
+ "request": {
+ "method": "PUT",
+ "url": "{{baseUrl}}/api/v1/appointments/{{appointmentId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"customerId\": 1,\n \"storeId\": 1,\n \"serviceId\": 1,\n \"appointmentDate\": \"2026-12-20\",\n \"appointmentTime\": \"11:00:00\",\n \"appointmentStatus\": \"Booked\",\n \"petIds\": [1]\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Delete Appointment",
+ "request": {
+ "method": "DELETE",
+ "url": "{{baseUrl}}/api/v1/appointments/{{appointmentId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 204', function () {",
+ " pm.response.to.have.status(204);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Bulk Delete Appointments",
+ "request": {
+ "method": "DELETE",
+ "url": "{{baseUrl}}/api/v1/appointments",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"ids\": [\n 1\n ]\n}"
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 403', function () {",
+ " pm.response.to.have.status(403);",
+ "});"
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "Adoptions",
+ "item": [
+ {
+ "name": "List Adoptions",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/adoptions",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Get Adoption",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/adoptions/1",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Create Adoption",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/adoptions",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"petId\": 3,\n \"customerId\": 1,\n \"adoptionDate\": \"2026-12-21\",\n \"adoptionStatus\": \"Pending\"\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 201', function () {",
+ " pm.response.to.have.status(201);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "if (jsonData.adoptionId !== undefined) pm.collectionVariables.set('adoptionId', jsonData.adoptionId);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Update Adoption",
+ "request": {
+ "method": "PUT",
+ "url": "{{baseUrl}}/api/v1/adoptions/{{adoptionId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"petId\": 3,\n \"customerId\": 1,\n \"adoptionDate\": \"2026-12-22\",\n \"adoptionStatus\": \"Completed\"\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Delete Adoption",
+ "request": {
+ "method": "DELETE",
+ "url": "{{baseUrl}}/api/v1/adoptions/{{adoptionId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 204', function () {",
+ " pm.response.to.have.status(204);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Bulk Delete Adoptions",
+ "request": {
+ "method": "DELETE",
+ "url": "{{baseUrl}}/api/v1/adoptions",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"ids\": [\n 1\n ]\n}"
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 403', function () {",
+ " pm.response.to.have.status(403);",
+ "});"
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "Refunds",
+ "item": [
+ {
+ "name": "Create Refund",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/refunds",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"saleId\": 1,\n \"reason\": \"Defective product\"\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 201', function () {",
+ " pm.response.to.have.status(201);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "if (jsonData.id !== undefined) pm.collectionVariables.set('refundId', jsonData.id);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "List Refunds",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/refunds",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Get Refund",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/refunds/{{refundId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Update Refund",
+ "request": {
+ "method": "PUT",
+ "url": "{{baseUrl}}/api/v1/refunds/{{refundId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"status\": \"APPROVED\"\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Delete Refund",
+ "request": {
+ "method": "DELETE",
+ "url": "{{baseUrl}}/api/v1/refunds/{{refundId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 403', function () {",
+ " pm.response.to.have.status(403);",
+ "});"
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "Chat",
+ "item": [
+ {
+ "name": "Create Conversation",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/chat/conversations",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{customerToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"message\": \"I need help\"\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 201', function () {",
+ " pm.response.to.have.status(201);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "pm.expect(jsonData.id).to.exist;",
+ "pm.collectionVariables.set('conversationId', jsonData.id);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Customer List Conversations",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/chat/conversations",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{customerToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Staff List Conversations",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/chat/conversations",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Get Conversation",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/chat/conversations/{{conversationId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{customerToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Customer Request Human",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/chat/conversations/{{conversationId}}/request-human",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{customerToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "pm.expect(jsonData.mode).to.eql('AUTOMATED');"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Customer Send Message",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/chat/conversations/{{conversationId}}/messages",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{customerToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"content\": \"Customer follow-up from Postman\"\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 201', function () {",
+ " pm.response.to.have.status(201);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Staff Send Message",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/chat/conversations/{{conversationId}}/messages",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"content\": \"Reply from Postman\"\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 201', function () {",
+ " pm.response.to.have.status(201);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "pm.expect(jsonData.senderId).to.exist;",
+ "pm.expect(jsonData.content).to.eql('Reply from Postman');"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Customer Get Messages",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/chat/conversations/{{conversationId}}/messages",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{customerToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "pm.expect(jsonData.length).to.be.above(0);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Staff Get Messages",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/chat/conversations/{{conversationId}}/messages",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "pm.expect(jsonData.length).to.be.above(0);"
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "Customers",
+ "item": [
+ {
+ "name": "Get Customers Dropdown",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/dropdowns/customers",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "List Customers",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/customers",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Get Customer",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/customers/{{customerId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Create Customer",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/customers",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"firstName\": \"Postman\",\n \"lastName\": \"Customer\",\n \"email\": \"postman.customer.{{$guid}}@example.com\",\n \"phone\": \"555-100-2000\"\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 201', function () {",
+ " pm.response.to.have.status(201);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "if (jsonData.customerId !== undefined) pm.collectionVariables.set('customerId', jsonData.customerId);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Update Customer",
+ "request": {
+ "method": "PUT",
+ "url": "{{baseUrl}}/api/v1/customers/{{customerId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"firstName\": \"Postman\",\n \"lastName\": \"Customer Updated\",\n \"email\": \"postman.customer.updated.{{$timestamp}}@example.com\",\n \"phone\": \"555-100-2001\"\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Delete Customer",
+ "request": {
+ "method": "DELETE",
+ "url": "{{baseUrl}}/api/v1/customers/{{customerId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 204', function () {",
+ " pm.response.to.have.status(204);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Create Customer For Bulk Delete",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/customers",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"firstName\": \"Postman\",\n \"lastName\": \"Customer\",\n \"email\": \"postman.customer.{{$guid}}@example.com\",\n \"phone\": \"555-100-2000\"\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 201', function () {",
+ " pm.response.to.have.status(201);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "if (jsonData.customerId !== undefined) pm.collectionVariables.set('bulkCustomerId', jsonData.customerId);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Bulk Delete Customers",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/customers/bulk-delete",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"ids\": [\n {{bulkCustomerId}}\n ]\n}"
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 204', function () {",
+ " pm.response.to.have.status(204);",
+ "});"
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "Users",
+ "item": [
+ {
+ "name": "List Users",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/users",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "List Staff Users",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/users?role=STAFF",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "pm.test('All returned users are STAFF', function () {",
+ " pm.expect(jsonData.content.every(function (user) { return user.role === 'STAFF'; })).to.be.true;",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "List Admin Users",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/users?role=ADMIN",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "pm.test('All returned users are ADMIN', function () {",
+ " pm.expect(jsonData.content.every(function (user) { return user.role === 'ADMIN'; })).to.be.true;",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "List Customer Users",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/users?role=CUSTOMER",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "pm.test('All returned users are CUSTOMER', function () {",
+ " pm.expect(jsonData.content.every(function (user) { return user.role === 'CUSTOMER'; })).to.be.true;",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Get User",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/users/1",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Create User",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/users",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"username\": \"postman_user_{{$guid}}\",\n \"password\": \"secret123\",\n \"fullName\": \"Postman User\",\n \"email\": \"postman.user.{{$guid}}@example.com\",\n \"role\": \"STAFF\",\n \"active\": true\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 201', function () {",
+ " pm.response.to.have.status(201);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "if (jsonData.id !== undefined) pm.collectionVariables.set('userId', jsonData.id);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Update User",
+ "request": {
+ "method": "PUT",
+ "url": "{{baseUrl}}/api/v1/users/{{userId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"username\": \"postman_user_{{$timestamp}}\",\n \"password\": \"secret123\",\n \"fullName\": \"Postman User Updated\",\n \"email\": \"postman.user.updated.{{$timestamp}}@example.com\",\n \"role\": \"STAFF\",\n \"active\": true\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Delete User",
+ "request": {
+ "method": "DELETE",
+ "url": "{{baseUrl}}/api/v1/users/{{userId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 204', function () {",
+ " pm.response.to.have.status(204);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Create User For Bulk Delete",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/users",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"username\": \"postman_user_{{$guid}}\",\n \"password\": \"secret123\",\n \"fullName\": \"Postman User\",\n \"email\": \"postman.user.{{$guid}}@example.com\",\n \"role\": \"STAFF\",\n \"active\": true\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 201', function () {",
+ " pm.response.to.have.status(201);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "if (jsonData.id !== undefined) pm.collectionVariables.set('bulkUserId', jsonData.id);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Bulk Delete Users",
+ "request": {
+ "method": "DELETE",
+ "url": "{{baseUrl}}/api/v1/users",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"ids\": [\n {{bulkUserId}}\n ]\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 204', function () {",
+ " pm.response.to.have.status(204);",
+ "});"
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "Stores",
+ "item": [
+ {
+ "name": "Get Stores Dropdown",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/dropdowns/stores",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "List Stores",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/stores",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Get Store",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/stores/{{storeId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Create Store",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/stores",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"storeName\": \"Postman Store {{$timestamp}}\",\n \"address\": \"100 Postman Ave\",\n \"phone\": \"555-200-3000\",\n \"email\": \"postman.store.{{$guid}}@example.com\"\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 201', function () {",
+ " pm.response.to.have.status(201);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "if (jsonData.storeId !== undefined) pm.collectionVariables.set('storeId', jsonData.storeId);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Update Store",
+ "request": {
+ "method": "PUT",
+ "url": "{{baseUrl}}/api/v1/stores/{{storeId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"storeName\": \"Postman Store Updated\",\n \"address\": \"101 Postman Ave\",\n \"phone\": \"555-200-3001\",\n \"email\": \"postman.store.updated@example.com\"\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Delete Store",
+ "request": {
+ "method": "DELETE",
+ "url": "{{baseUrl}}/api/v1/stores/{{storeId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 204', function () {",
+ " pm.response.to.have.status(204);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Create Store For Bulk Delete",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/stores",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"storeName\": \"Postman Store {{$timestamp}}\",\n \"address\": \"100 Postman Ave\",\n \"phone\": \"555-200-3000\",\n \"email\": \"postman.store.{{$guid}}@example.com\"\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 201', function () {",
+ " pm.response.to.have.status(201);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "if (jsonData.storeId !== undefined) pm.collectionVariables.set('bulkStoreId', jsonData.storeId);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Bulk Delete Stores",
+ "request": {
+ "method": "DELETE",
+ "url": "{{baseUrl}}/api/v1/stores",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"ids\": [\n {{bulkStoreId}}\n ]\n}"
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 204', function () {",
+ " pm.response.to.have.status(204);",
+ "});"
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "Inventory",
+ "item": [
+ {
+ "name": "List Inventory",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/inventory",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Get Inventory Item",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/inventory/1",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Create Inventory",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/inventory",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"prodId\": {{productId}},\n \"quantity\": 10\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 201', function () {",
+ " pm.response.to.have.status(201);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "if (jsonData.inventoryId !== undefined) pm.collectionVariables.set('inventoryId', jsonData.inventoryId);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Update Inventory",
+ "request": {
+ "method": "PUT",
+ "url": "{{baseUrl}}/api/v1/inventory/{{inventoryId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"prodId\": {{productId}},\n \"quantity\": 12\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Delete Inventory",
+ "request": {
+ "method": "DELETE",
+ "url": "{{baseUrl}}/api/v1/inventory/{{inventoryId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 204', function () {",
+ " pm.response.to.have.status(204);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Create Inventory For Bulk Delete",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/inventory",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"prodId\": {{productId}},\n \"quantity\": 10\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 201', function () {",
+ " pm.response.to.have.status(201);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "if (jsonData.inventoryId !== undefined) pm.collectionVariables.set('bulkInventoryId', jsonData.inventoryId);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Bulk Delete Inventory",
+ "request": {
+ "method": "DELETE",
+ "url": "{{baseUrl}}/api/v1/inventory",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"ids\": [\n {{bulkInventoryId}}\n ]\n}"
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 204', function () {",
+ " pm.response.to.have.status(204);",
+ "});"
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "Suppliers",
+ "item": [
+ {
+ "name": "List Suppliers",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/suppliers",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Get Supplier",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/suppliers/{{supplierId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Create Supplier",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/suppliers",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"supCompany\": \"Postman Supplier {{$timestamp}}\",\n \"supContactFirstName\": \"Post\",\n \"supContactLastName\": \"Man\",\n \"supEmail\": \"postman.supplier.{{$guid}}@example.com\",\n \"supPhone\": \"555-300-4000\"\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 201', function () {",
+ " pm.response.to.have.status(201);",
+ "});",
+ "var jsonData = pm.response.json();",
+ "if (jsonData.supId !== undefined) pm.collectionVariables.set('supplierId', jsonData.supId);"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Update Supplier",
+ "request": {
+ "method": "PUT",
+ "url": "{{baseUrl}}/api/v1/suppliers/{{supplierId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"supCompany\": \"Postman Supplier Updated {{$timestamp}}\",\n \"supContactFirstName\": \"Post\",\n \"supContactLastName\": \"Man\",\n \"supEmail\": \"postman.supplier.updated.{{$timestamp}}@example.com\",\n \"supPhone\": \"555-300-4001\"\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Delete Supplier",
+ "request": {
+ "method": "DELETE",
+ "url": "{{baseUrl}}/api/v1/suppliers/{{supplierId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 403', function () {",
+ " pm.response.to.have.status(403);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Bulk Delete Suppliers",
+ "request": {
+ "method": "DELETE",
+ "url": "{{baseUrl}}/api/v1/suppliers",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{staffToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"ids\": [\n 1\n ]\n}"
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 403', function () {",
+ " pm.response.to.have.status(403);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Get Suppliers Dropdown",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/dropdowns/suppliers",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "Purchase Orders",
+ "item": [
+ {
+ "name": "List Purchase Orders",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/purchase-orders",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Get Purchase Order",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/purchase-orders/1",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "Product-Suppliers",
+ "item": [
+ {
+ "name": "List Product Suppliers",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/product-suppliers",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Get Product Supplier",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/product-suppliers/1/1",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Create Product Supplier",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/product-suppliers",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"productId\": {{productId}},\n \"supplierId\": {{supplierId}},\n \"cost\": 7.50\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 201', function () {",
+ " pm.response.to.have.status(201);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Update Product Supplier",
+ "request": {
+ "method": "PUT",
+ "url": "{{baseUrl}}/api/v1/product-suppliers/{{productId}}/{{supplierId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"productId\": {{productId}},\n \"supplierId\": {{supplierId}},\n \"cost\": 7.75\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Delete Product Supplier",
+ "request": {
+ "method": "DELETE",
+ "url": "{{baseUrl}}/api/v1/product-suppliers/{{productId}}/{{supplierId}}",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 204', function () {",
+ " pm.response.to.have.status(204);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Create Product Supplier For Bulk Delete",
+ "request": {
+ "method": "POST",
+ "url": "{{baseUrl}}/api/v1/product-suppliers",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"productId\": {{productId}},\n \"supplierId\": {{supplierId}},\n \"cost\": 7.50\n}",
+ "options": {
+ "raw": {
+ "language": "json"
+ }
+ }
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 201', function () {",
+ " pm.response.to.have.status(201);",
+ "});"
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "name": "Bulk Delete Product Suppliers",
+ "request": {
+ "method": "DELETE",
+ "url": "{{baseUrl}}/api/v1/product-suppliers",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"keys\": [\n {\"productId\": {{productId}}, \"supplierId\": {{supplierId}}}\n ]\n}"
+ }
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 204', function () {",
+ " pm.response.to.have.status(204);",
+ "});"
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "Analytics",
+ "item": [
+ {
+ "name": "Analytics Dashboard",
+ "request": {
+ "method": "GET",
+ "url": "{{baseUrl}}/api/v1/analytics/dashboard",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ },
+ {
+ "key": "Authorization",
+ "value": "Bearer {{adminToken}}",
+ "type": "text"
+ }
+ ]
+ },
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "pm.test('Status code is 200', function () {",
+ " pm.response.to.have.status(200);",
+ "});"
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
diff --git a/backend/pom.xml b/backend/pom.xml
new file mode 100644
index 00000000..de496351
--- /dev/null
+++ b/backend/pom.xml
@@ -0,0 +1,199 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.0.3
+
+
+
+ com.petshop
+ backend
+ 1.0.0
+ PetShop Backend
+ Spring Boot backend for PetShop desktop application
+
+
+ 25
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+
+ org.springframework.boot
+ spring-boot-starter-security
+
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+
+ org.springframework.boot
+ spring-boot-starter-websocket
+
+
+
+ com.mysql
+ mysql-connector-j
+ runtime
+
+
+
+ org.flywaydb
+ flyway-core
+
+
+
+ org.flywaydb
+ flyway-mysql
+
+
+
+ io.jsonwebtoken
+ jjwt-api
+ 0.12.3
+
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ 0.12.3
+ runtime
+
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ 0.12.3
+ runtime
+
+
+
+ org.springdoc
+ springdoc-openapi-starter-webmvc-ui
+ 3.0.1
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+ 25
+
+
+
+ org.apache.maven.plugins
+ maven-enforcer-plugin
+ 3.5.0
+
+
+ require-java-25
+
+ enforce
+
+
+
+
+ [25,)
+ JDK 25 or newer is required. Configure IntelliJ and Maven to use JDK 25 before running the backend.
+
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+ org.codehaus.mojo
+ exec-maven-plugin
+ 3.1.0
+
+
+ dev-stack
+
+ java
+
+
+ com.petshop.backend.DevStackApplication
+ runtime
+
+
+
+ reset-db
+
+ java
+
+
+ com.petshop.backend.ResetDatabaseApplication
+ runtime
+
+
+
+ docker-up
+
+ exec
+
+
+ docker
+
+ compose
+ -f
+ docker-compose.dev.yml
+ up
+ -d
+ --wait
+ db
+
+
+
+
+ docker-down
+
+ exec
+
+
+ docker
+
+ compose
+ -f
+ docker-compose.dev.yml
+ down
+ -v
+ --remove-orphans
+
+
+
+
+
+
+
+
diff --git a/backend/postman/avatar.png b/backend/postman/avatar.png
new file mode 100644
index 00000000..05feaba1
Binary files /dev/null and b/backend/postman/avatar.png differ
diff --git a/backend/src/main/java/com/petshop/backend/BackendApplication.java b/backend/src/main/java/com/petshop/backend/BackendApplication.java
new file mode 100644
index 00000000..87268e9a
--- /dev/null
+++ b/backend/src/main/java/com/petshop/backend/BackendApplication.java
@@ -0,0 +1,17 @@
+package com.petshop.backend;
+
+import com.petshop.backend.config.FlywayContextInitializer;
+import org.springframework.boot.builder.SpringApplicationBuilder;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.data.web.config.EnableSpringDataWebSupport;
+
+@SpringBootApplication
+@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO)
+public class BackendApplication {
+ public static void main(String[] args) {
+ RuntimeClasspathValidator.validate();
+ new SpringApplicationBuilder(BackendApplication.class)
+ .initializers(new FlywayContextInitializer())
+ .run(args);
+ }
+}
diff --git a/backend/src/main/java/com/petshop/backend/DevStackApplication.java b/backend/src/main/java/com/petshop/backend/DevStackApplication.java
new file mode 100644
index 00000000..38846aaa
--- /dev/null
+++ b/backend/src/main/java/com/petshop/backend/DevStackApplication.java
@@ -0,0 +1,177 @@
+package com.petshop.backend;
+
+import com.petshop.backend.config.FlywayContextInitializer;
+import org.springframework.boot.builder.SpringApplicationBuilder;
+import org.springframework.boot.web.server.PortInUseException;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.context.event.ContextClosedEvent;
+
+import java.net.BindException;
+import java.time.Duration;
+import java.util.Locale;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+public class DevStackApplication {
+
+ private static final Duration WATCH_INTERVAL = Duration.ofSeconds(2);
+
+ public static void main(String[] args) {
+ DockerComposeSupport docker = new DockerComposeSupport();
+ ConfigurableApplicationContext context = null;
+ CountDownLatch shutdownLatch = new CountDownLatch(1);
+ ScheduledExecutorService scheduler = null;
+ AtomicBoolean shuttingDown = new AtomicBoolean(false);
+ RuntimeException startupFailure = null;
+
+ try {
+ validateJavaVersion();
+ RuntimeClasspathValidator.validate();
+ docker.ensureDockerAvailable();
+ docker.startDatabase();
+ context = new SpringApplicationBuilder(BackendApplication.class)
+ .initializers(new FlywayContextInitializer())
+ .run(args);
+ context.addApplicationListener(event -> {
+ if (event instanceof ContextClosedEvent) {
+ shutdownLatch.countDown();
+ }
+ });
+ scheduler = startDatabaseWatch(docker, context, shuttingDown);
+ shutdownLatch.await();
+ } catch (RuntimeException ex) {
+ startupFailure = ex;
+ throw new IllegalStateException(describeStartupFailure(ex), ex);
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException("Dev stack interrupted", ex);
+ } finally {
+ if (scheduler != null) {
+ scheduler.shutdownNow();
+ }
+ if (context != null && context.isActive()) {
+ context.close();
+ }
+ if (!shuttingDown.get()) {
+ try {
+ docker.stopDatabase();
+ } catch (RuntimeException stopFailure) {
+ if (startupFailure != null) {
+ System.err.println(stopFailure.getMessage());
+ } else {
+ throw stopFailure;
+ }
+ }
+ }
+ }
+ }
+
+ private static void validateJavaVersion() {
+ int feature = Runtime.version().feature();
+ if (feature < 25) {
+ throw new IllegalStateException("JDK 25 or newer is required. IntelliJ is currently using Java " + Runtime.version() + ".");
+ }
+ }
+
+ private static ScheduledExecutorService startDatabaseWatch(DockerComposeSupport docker, ConfigurableApplicationContext context, AtomicBoolean shuttingDown) {
+ ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
+ scheduler.scheduleWithFixedDelay(() -> {
+ if (shuttingDown.get()) {
+ return;
+ }
+ if (!docker.isDatabaseRunning()) {
+ shuttingDown.set(true);
+ if (context.isActive()) {
+ context.close();
+ }
+ }
+ }, WATCH_INTERVAL.toSeconds(), WATCH_INTERVAL.toSeconds(), TimeUnit.SECONDS);
+ return scheduler;
+ }
+
+ private static String describeStartupFailure(RuntimeException ex) {
+ Throwable cause = deepestCause(ex);
+ String message = cause.getMessage();
+ String lowerMessage = "";
+ if (message != null) {
+ lowerMessage = message.toLowerCase(Locale.ROOT);
+ }
+
+ switch (classifyStartupFailure(cause, lowerMessage)) {
+ case SERVER_PORT_IN_USE:
+ return "Backend startup failed because port 8080 is already in use. Stop the other process or set SERVER_PORT to a different value.";
+ case DOCKER_NOT_RUNNING:
+ return "Backend startup failed because Docker is not available. Start Docker Desktop and try again.";
+ case DOCKER_NOT_INSTALLED:
+ return "Backend startup failed because Docker is not installed or not on PATH. Install Docker Desktop and reopen IntelliJ.";
+ case DATABASE_PORT_IN_USE:
+ return "Backend startup failed because port 3306 is already in use. Stop the conflicting MySQL service or container, then run Reset Database and try again.";
+ case STALE_SCHEMA:
+ return "Backend startup failed because the database schema is stale or incomplete. Run Reset Database and then start Pet Shop Application again.";
+ case FLYWAY_MIGRATION_FAILED:
+ return "Backend startup failed during Flyway migration. Run Reset Database and check the database logs if the problem persists.";
+ case DATASOURCE_CONFIG_MISSING:
+ return "Backend startup failed because datasource configuration was not loaded. Reload the Maven project and rerun Pet Shop Application.";
+ case DATABASE_UNREACHABLE:
+ return "Backend startup failed because it could not connect to MySQL. Make sure Docker Desktop is running, the database container is healthy, and port 3306 is reachable.";
+ case UNKNOWN:
+ if (message == null || message.isBlank()) {
+ return "Backend startup failed: " + ex.getClass().getSimpleName();
+ }
+ return "Backend startup failed: " + message;
+ default:
+ throw new IllegalStateException("Unhandled startup failure classification");
+ }
+ }
+
+ private static StartupFailure classifyStartupFailure(Throwable cause, String lowerMessage) {
+ if (cause instanceof PortInUseException || cause instanceof BindException || lowerMessage.contains("port 8080") && lowerMessage.contains("already in use")) {
+ return StartupFailure.SERVER_PORT_IN_USE;
+ }
+ if (lowerMessage.contains("docker desktop") || lowerMessage.contains("docker daemon") || lowerMessage.contains("docker engine") || lowerMessage.contains("cannot connect to the docker daemon")) {
+ return StartupFailure.DOCKER_NOT_RUNNING;
+ }
+ if (lowerMessage.contains("docker executable") || lowerMessage.contains("no such file or directory") && lowerMessage.contains("docker")) {
+ return StartupFailure.DOCKER_NOT_INSTALLED;
+ }
+ if (lowerMessage.contains("port is already allocated") || lowerMessage.contains("address already in use") && lowerMessage.contains("3306")) {
+ return StartupFailure.DATABASE_PORT_IN_USE;
+ }
+ if (lowerMessage.contains("schema validation: missing table")) {
+ return StartupFailure.STALE_SCHEMA;
+ }
+ if (lowerMessage.contains("flyway") || lowerMessage.contains("migration")) {
+ return StartupFailure.FLYWAY_MIGRATION_FAILED;
+ }
+ if (lowerMessage.contains("datasource properties are required")) {
+ return StartupFailure.DATASOURCE_CONFIG_MISSING;
+ }
+ if (lowerMessage.contains("access denied for user") || lowerMessage.contains("communications link failure") || lowerMessage.contains("connection refused") || lowerMessage.contains("unable to determine dialect without jdbc metadata")) {
+ return StartupFailure.DATABASE_UNREACHABLE;
+ }
+ return StartupFailure.UNKNOWN;
+ }
+
+ private static Throwable deepestCause(Throwable throwable) {
+ Throwable current = throwable;
+ while (current.getCause() != null && current.getCause() != current) {
+ current = current.getCause();
+ }
+ return current;
+ }
+
+ private enum StartupFailure {
+ SERVER_PORT_IN_USE,
+ DOCKER_NOT_RUNNING,
+ DOCKER_NOT_INSTALLED,
+ DATABASE_PORT_IN_USE,
+ STALE_SCHEMA,
+ FLYWAY_MIGRATION_FAILED,
+ DATASOURCE_CONFIG_MISSING,
+ DATABASE_UNREACHABLE,
+ UNKNOWN
+ }
+}
diff --git a/backend/src/main/java/com/petshop/backend/DockerComposeSupport.java b/backend/src/main/java/com/petshop/backend/DockerComposeSupport.java
new file mode 100644
index 00000000..1ff43a77
--- /dev/null
+++ b/backend/src/main/java/com/petshop/backend/DockerComposeSupport.java
@@ -0,0 +1,173 @@
+package com.petshop.backend;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+final class DockerComposeSupport {
+
+ private final Path projectDir = Paths.get("").toAbsolutePath();
+
+ void ensureDockerAvailable() {
+ CommandResult versionResult = runCommand(List.of(resolveDockerExecutable(), "version"), false);
+ if (versionResult.exitCode != 0) {
+ throw new IllegalStateException(describeDockerFailure(versionResult.output));
+ }
+ }
+
+ void startDatabase() {
+ runCommand(composeCommand("up", "-d", "--wait", "db"));
+ }
+
+ void stopDatabase() {
+ runCommand(composeCommand("stop", "db"));
+ }
+
+ void resetDatabase() {
+ runCommand(composeCommand("down", "-v", "--remove-orphans"));
+ String volumeName = getDatabaseVolumeName();
+ if (volumeExists(volumeName)) {
+ runCommand(List.of(resolveDockerExecutable(), "volume", "rm", "-f", volumeName));
+ }
+ }
+
+ boolean isDatabaseRunning() {
+ CommandResult result = runCommand(composeCommand("ps", "--status", "running", "--services", "db"), false);
+ if (result.exitCode != 0) {
+ return false;
+ }
+ return result.output.lines()
+ .map(String::trim)
+ .anyMatch("db"::equals);
+ }
+
+ private boolean volumeExists(String volumeName) {
+ CommandResult result = runCommand(List.of(resolveDockerExecutable(), "volume", "ls", "--format", "{{.Name}}"), false);
+ if (result.exitCode != 0) {
+ return false;
+ }
+ return result.output.lines()
+ .map(String::trim)
+ .anyMatch(volumeName::equals);
+ }
+
+ private String getDatabaseVolumeName() {
+ return projectDir.getFileName().toString() + "_db_data";
+ }
+
+ private List composeCommand(String... args) {
+ List command = new ArrayList<>();
+ command.add(resolveDockerExecutable());
+ command.add("compose");
+ command.add("-f");
+ command.add("docker-compose.dev.yml");
+ for (String arg : args) {
+ command.add(arg);
+ }
+ return command;
+ }
+
+ private String resolveDockerExecutable() {
+ String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
+ if (os.contains("win")) {
+ Path dockerPath = Paths.get("C:", "Program Files", "Docker", "Docker", "resources", "bin", "docker.exe");
+ if (Files.isRegularFile(dockerPath)) {
+ return dockerPath.toString();
+ }
+ Path dockerPathAlt = Paths.get("C:", "Program Files", "Docker", "Docker", "resources", "docker.exe");
+ if (Files.isRegularFile(dockerPathAlt)) {
+ return dockerPathAlt.toString();
+ }
+ return "docker.exe";
+ }
+ return "docker";
+ }
+
+ private void runCommand(List command) {
+ CommandResult result = runCommand(command, true);
+ if (result.exitCode != 0) {
+ throw new IllegalStateException(describeCommandFailure(command, result.output));
+ }
+ }
+
+ private CommandResult runCommand(List command, boolean printOutput) {
+ ProcessBuilder builder = new ProcessBuilder(command);
+ builder.directory(projectDir.toFile());
+ builder.redirectErrorStream(true);
+
+ try {
+ Process process = builder.start();
+ StringBuilder output = new StringBuilder();
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ output.append(line).append(System.lineSeparator());
+ if (printOutput) {
+ System.out.println(line);
+ }
+ }
+ }
+ int exitCode = process.waitFor();
+ return new CommandResult(exitCode, output.toString().trim());
+ } catch (IOException ex) {
+ String executable = command.isEmpty() ? "docker" : command.getFirst();
+ throw new IllegalStateException("Unable to run " + executable + ". Install Docker Desktop and make sure it is available on PATH.", ex);
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException("Docker command interrupted", ex);
+ }
+ }
+
+ private String describeDockerFailure(String output) {
+ String lowerOutput = "";
+ if (output != null) {
+ lowerOutput = output.toLowerCase(Locale.ROOT);
+ }
+ if (lowerOutput.contains("docker desktop") || lowerOutput.contains("docker daemon") || lowerOutput.contains("docker engine") || lowerOutput.contains("cannot connect") || lowerOutput.contains("failed to connect") || lowerOutput.contains("docker_api") || lowerOutput.contains("docker api") || lowerOutput.contains("docker_engine") || lowerOutput.contains("pipe/docker_engine") || lowerOutput.contains("npipe")) {
+ return "Docker Desktop is not running. Start Docker Desktop and rerun the command.";
+ }
+ if (output == null || output.isBlank()) {
+ return "Docker is unavailable. Start Docker Desktop and rerun the command.";
+ }
+ return output;
+ }
+
+ private String describeCommandFailure(List command, String output) {
+ String renderedCommand = String.join(" ", command);
+ String lowerOutput = "";
+ if (output != null) {
+ lowerOutput = output.toLowerCase(Locale.ROOT);
+ }
+ if (renderedCommand.contains(" up ") || renderedCommand.endsWith(" up -d --wait db")) {
+ if (lowerOutput.contains("port is already allocated") || lowerOutput.contains("address already in use")) {
+ return "Database startup failed because port 3306 is already in use. Stop the conflicting MySQL service or container, then run Reset Database.";
+ }
+ if (lowerOutput.contains("docker desktop") || lowerOutput.contains("docker daemon") || lowerOutput.contains("docker engine")) {
+ return "Database startup failed because Docker Desktop is not running.";
+ }
+ if (output == null || output.isBlank()) {
+ return "Database startup failed while bringing up the Docker MySQL container.";
+ }
+ return output;
+ }
+ if (renderedCommand.contains(" volume rm ")) {
+ if (output == null || output.isBlank()) {
+ return "Database reset failed while removing the Docker volume.";
+ }
+ return output;
+ }
+ if (output == null || output.isBlank()) {
+ return "Command failed: " + renderedCommand;
+ }
+ return output;
+ }
+
+ private record CommandResult(int exitCode, String output) {
+ }
+}
diff --git a/backend/src/main/java/com/petshop/backend/ResetDatabaseApplication.java b/backend/src/main/java/com/petshop/backend/ResetDatabaseApplication.java
new file mode 100644
index 00000000..056ec36e
--- /dev/null
+++ b/backend/src/main/java/com/petshop/backend/ResetDatabaseApplication.java
@@ -0,0 +1,10 @@
+package com.petshop.backend;
+
+public class ResetDatabaseApplication {
+
+ public static void main(String[] args) {
+ DockerComposeSupport docker = new DockerComposeSupport();
+ docker.ensureDockerAvailable();
+ docker.resetDatabase();
+ }
+}
diff --git a/backend/src/main/java/com/petshop/backend/RuntimeClasspathValidator.java b/backend/src/main/java/com/petshop/backend/RuntimeClasspathValidator.java
new file mode 100644
index 00000000..ad18d198
--- /dev/null
+++ b/backend/src/main/java/com/petshop/backend/RuntimeClasspathValidator.java
@@ -0,0 +1,24 @@
+package com.petshop.backend;
+
+import java.net.URL;
+
+final class RuntimeClasspathValidator {
+
+ private RuntimeClasspathValidator() {
+ }
+
+ static void validate() {
+ if (!resourceExists("application.yml")) {
+ throw new IllegalStateException("Backend resources are missing from the runtime classpath. Reimport the Maven project in IntelliJ and run the shared Maven run configuration.");
+ }
+ if (!resourceExists("db/migration/V1__baseline_schema.sql")) {
+ throw new IllegalStateException("Flyway migration files are missing from the runtime classpath. Reimport the Maven project in IntelliJ and run the shared Maven run configuration.");
+ }
+ }
+
+ private static boolean resourceExists(String path) {
+ ClassLoader classLoader = RuntimeClasspathValidator.class.getClassLoader();
+ URL resource = classLoader.getResource(path);
+ return resource != null;
+ }
+}
diff --git a/backend/src/main/java/com/petshop/backend/config/DataInitializer.java b/backend/src/main/java/com/petshop/backend/config/DataInitializer.java
new file mode 100644
index 00000000..4a8c7470
--- /dev/null
+++ b/backend/src/main/java/com/petshop/backend/config/DataInitializer.java
@@ -0,0 +1,167 @@
+package com.petshop.backend.config;
+
+import com.petshop.backend.entity.User;
+import com.petshop.backend.repository.UserRepository;
+import com.petshop.backend.service.StoreAssignmentService;
+import com.petshop.backend.service.UserBusinessLinkageService;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Component;
+
+@Component
+public class DataInitializer implements CommandLineRunner {
+
+ private final UserRepository userRepository;
+ private final PasswordEncoder passwordEncoder;
+ private final UserBusinessLinkageService userBusinessLinkageService;
+ private final StoreAssignmentService storeAssignmentService;
+
+ public DataInitializer(UserRepository userRepository, PasswordEncoder passwordEncoder, UserBusinessLinkageService userBusinessLinkageService, StoreAssignmentService storeAssignmentService) {
+ this.userRepository = userRepository;
+ this.passwordEncoder = passwordEncoder;
+ this.userBusinessLinkageService = userBusinessLinkageService;
+ this.storeAssignmentService = storeAssignmentService;
+ }
+
+ @Override
+ public void run(String... args) {
+ System.out.println("==== DataInitializer: Starting user creation ====");
+
+ User admin = userRepository.findByUsername("admin").orElse(null);
+ if (admin == null) {
+ System.out.println("Creating admin user...");
+ admin = new User();
+ admin.setUsername("admin");
+ admin.setPassword(passwordEncoder.encode("admin123"));
+ admin.setEmail("admin@petshop.com");
+ admin.setFullName("Admin User");
+ admin.setPhone("000-000-1000");
+ admin.setRole(User.Role.ADMIN);
+ admin.setActive(true);
+ admin = userRepository.save(admin);
+ System.out.println("Admin user created successfully");
+ } else {
+ System.out.println("Admin user already exists");
+ // Normalize missing fields if needed
+ boolean updated = false;
+ if (admin.getFullName() == null || admin.getFullName().isEmpty()) {
+ admin.setFullName("Admin User");
+ updated = true;
+ }
+ if (admin.getEmail() == null || admin.getEmail().isEmpty()) {
+ admin.setEmail("admin@petshop.com");
+ updated = true;
+ }
+ if (admin.getPhone() == null || admin.getPhone().isEmpty()) {
+ admin.setPhone("000-000-1000");
+ updated = true;
+ }
+ if (admin.getActive() == null) {
+ admin.setActive(true);
+ updated = true;
+ }
+ if (admin.getRole() == null) {
+ admin.setRole(User.Role.ADMIN);
+ updated = true;
+ }
+ if (updated) {
+ admin = userRepository.save(admin);
+ System.out.println("Admin user normalized");
+ }
+ }
+ // Ensure linked employee
+ storeAssignmentService.assignStoreIfMissing(userBusinessLinkageService.ensureLinkedEmployee(admin), 1L);
+
+ User staff = userRepository.findByUsername("staff").orElse(null);
+ if (staff == null) {
+ System.out.println("Creating staff user...");
+ staff = new User();
+ staff.setUsername("staff");
+ staff.setPassword(passwordEncoder.encode("staff123"));
+ staff.setEmail("staff@petshop.com");
+ staff.setFullName("Staff User");
+ staff.setPhone("000-000-1001");
+ staff.setRole(User.Role.STAFF);
+ staff.setActive(true);
+ staff = userRepository.save(staff);
+ System.out.println("Staff user created successfully");
+ } else {
+ System.out.println("Staff user already exists");
+ // Normalize missing fields if needed
+ boolean updated = false;
+ if (staff.getFullName() == null || staff.getFullName().isEmpty()) {
+ staff.setFullName("Staff User");
+ updated = true;
+ }
+ if (staff.getEmail() == null || staff.getEmail().isEmpty()) {
+ staff.setEmail("staff@petshop.com");
+ updated = true;
+ }
+ if (staff.getPhone() == null || staff.getPhone().isEmpty()) {
+ staff.setPhone("000-000-1001");
+ updated = true;
+ }
+ if (staff.getActive() == null) {
+ staff.setActive(true);
+ updated = true;
+ }
+ if (staff.getRole() == null) {
+ staff.setRole(User.Role.STAFF);
+ updated = true;
+ }
+ if (updated) {
+ staff = userRepository.save(staff);
+ System.out.println("Staff user normalized");
+ }
+ }
+ // Ensure linked employee
+ storeAssignmentService.assignStoreIfMissing(userBusinessLinkageService.ensureLinkedEmployee(staff), 1L);
+
+ User customer = userRepository.findByUsername("customer").orElse(null);
+ if (customer == null) {
+ System.out.println("Creating customer user...");
+ customer = new User();
+ customer.setUsername("customer");
+ customer.setPassword(passwordEncoder.encode("customer123"));
+ customer.setEmail("customer@petshop.com");
+ customer.setFullName("Test Customer");
+ customer.setPhone("000-000-1002");
+ customer.setRole(User.Role.CUSTOMER);
+ customer.setActive(true);
+ customer = userRepository.save(customer);
+ System.out.println("Customer user created successfully");
+ } else {
+ System.out.println("Customer user already exists");
+ // Normalize missing fields if needed
+ boolean updated = false;
+ if (customer.getFullName() == null || customer.getFullName().isEmpty()) {
+ customer.setFullName("Test Customer");
+ updated = true;
+ }
+ if (customer.getEmail() == null || customer.getEmail().isEmpty()) {
+ customer.setEmail("customer@petshop.com");
+ updated = true;
+ }
+ if (customer.getPhone() == null || customer.getPhone().isEmpty()) {
+ customer.setPhone("000-000-1002");
+ updated = true;
+ }
+ if (customer.getActive() == null) {
+ customer.setActive(true);
+ updated = true;
+ }
+ if (customer.getRole() == null) {
+ customer.setRole(User.Role.CUSTOMER);
+ updated = true;
+ }
+ if (updated) {
+ customer = userRepository.save(customer);
+ System.out.println("Customer user normalized");
+ }
+ }
+ // Ensure linked customer
+ userBusinessLinkageService.ensureLinkedCustomer(customer);
+
+ System.out.println("==== DataInitializer: Completed ====");
+ }
+}
diff --git a/backend/src/main/java/com/petshop/backend/config/FlywayContextInitializer.java b/backend/src/main/java/com/petshop/backend/config/FlywayContextInitializer.java
new file mode 100644
index 00000000..3fb28d10
--- /dev/null
+++ b/backend/src/main/java/com/petshop/backend/config/FlywayContextInitializer.java
@@ -0,0 +1,40 @@
+package com.petshop.backend.config;
+
+import org.flywaydb.core.Flyway;
+import org.flywaydb.core.api.MigrationVersion;
+import org.springframework.context.ApplicationContextInitializer;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.core.env.ConfigurableEnvironment;
+
+import java.util.Arrays;
+
+public class FlywayContextInitializer implements ApplicationContextInitializer {
+
+ @Override
+ public void initialize(ConfigurableApplicationContext applicationContext) {
+ ConfigurableEnvironment environment = applicationContext.getEnvironment();
+
+ String url = environment.getProperty("spring.datasource.url");
+ String username = environment.getProperty("spring.datasource.username");
+ String password = environment.getProperty("spring.datasource.password");
+
+ if (url == null || username == null || password == null) {
+ throw new IllegalStateException("Datasource properties are required before startup");
+ }
+
+ String[] locations = Arrays.stream(environment
+ .getProperty("spring.flyway.locations", "classpath:db/migration")
+ .split(","))
+ .map(String::trim)
+ .filter(location -> !location.isEmpty())
+ .toArray(String[]::new);
+
+ Flyway.configure()
+ .dataSource(url, username, password)
+ .locations(locations)
+ .baselineOnMigrate(environment.getProperty("spring.flyway.baseline-on-migrate", Boolean.class, false))
+ .baselineVersion(MigrationVersion.fromVersion(environment.getProperty("spring.flyway.baseline-version", "1")))
+ .load()
+ .migrate();
+ }
+}
diff --git a/backend/src/main/java/com/petshop/backend/config/TomcatPathToleranceConfig.java b/backend/src/main/java/com/petshop/backend/config/TomcatPathToleranceConfig.java
new file mode 100644
index 00000000..9a89c5ab
--- /dev/null
+++ b/backend/src/main/java/com/petshop/backend/config/TomcatPathToleranceConfig.java
@@ -0,0 +1,19 @@
+package com.petshop.backend.config;
+
+import org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory;
+import org.springframework.boot.web.server.WebServerFactoryCustomizer;
+import org.springframework.stereotype.Component;
+
+@Component
+public class TomcatPathToleranceConfig implements WebServerFactoryCustomizer {
+
+ @Override
+ public void customize(TomcatServletWebServerFactory factory) {
+ factory.addConnectorCustomizers(connector -> {
+ connector.setAllowBackslash(true);
+ connector.setEncodedReverseSolidusHandling("decode");
+ connector.setProperty("relaxedPathChars", "\\");
+ connector.setProperty("relaxedQueryChars", "\\");
+ });
+ }
+}
diff --git a/backend/src/main/java/com/petshop/backend/config/TrailingSlashNormalizationFilter.java b/backend/src/main/java/com/petshop/backend/config/TrailingSlashNormalizationFilter.java
new file mode 100644
index 00000000..38ececb9
--- /dev/null
+++ b/backend/src/main/java/com/petshop/backend/config/TrailingSlashNormalizationFilter.java
@@ -0,0 +1,91 @@
+package com.petshop.backend.config;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletRequestWrapper;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+
+@Component
+@Order(Ordered.HIGHEST_PRECEDENCE)
+public class TrailingSlashNormalizationFilter extends OncePerRequestFilter {
+
+ @Override
+ protected boolean shouldNotFilter(HttpServletRequest request) {
+ String requestUri = request.getRequestURI();
+ if (requestUri == null || requestUri.isBlank()) {
+ return true;
+ }
+ return requestUri.equals(normalizePath(requestUri));
+ }
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
+ String normalizedUri = normalizePath(request.getRequestURI());
+ String normalizedServletPath = normalizePath(request.getServletPath());
+ String normalizedPathInfo = normalizePath(request.getPathInfo());
+
+ HttpServletRequestWrapper wrapper = new HttpServletRequestWrapper(request) {
+ @Override
+ public String getRequestURI() {
+ return normalizedUri;
+ }
+
+ @Override
+ public StringBuffer getRequestURL() {
+ String original = super.getRequestURL().toString();
+ int schemeSeparator = original.indexOf("://");
+ int pathStart = schemeSeparator >= 0 ? original.indexOf('/', schemeSeparator + 3) : original.indexOf('/');
+ if (pathStart < 0) {
+ return new StringBuffer(original);
+ }
+ String prefix = original.substring(0, pathStart);
+ return new StringBuffer(prefix + normalizedUri);
+ }
+
+ @Override
+ public String getServletPath() {
+ return normalizedServletPath;
+ }
+
+ @Override
+ public String getPathInfo() {
+ return normalizedPathInfo;
+ }
+ };
+
+ filterChain.doFilter(wrapper, response);
+ }
+
+ private String normalizePath(String value) {
+ if (value == null) {
+ return null;
+ }
+ String normalized = value.replace('\\', '/');
+ while (normalized.contains("//")) {
+ normalized = normalized.replace("//", "/");
+ }
+ if (shouldLowercase(normalized)) {
+ normalized = normalized.toLowerCase(java.util.Locale.ROOT);
+ }
+ int end = normalized.length();
+ while (end > 1 && normalized.charAt(end - 1) == '/') {
+ end--;
+ }
+ return normalized.substring(0, end);
+ }
+
+ private boolean shouldLowercase(String path) {
+ String lower = path.toLowerCase(java.util.Locale.ROOT);
+ return lower.startsWith("/api/")
+ || lower.equals("/api")
+ || lower.startsWith("/ws/")
+ || lower.equals("/ws");
+ }
+}
diff --git a/backend/src/main/java/com/petshop/backend/config/WebSocketAuthChannelInterceptor.java b/backend/src/main/java/com/petshop/backend/config/WebSocketAuthChannelInterceptor.java
new file mode 100644
index 00000000..c7f23fc4
--- /dev/null
+++ b/backend/src/main/java/com/petshop/backend/config/WebSocketAuthChannelInterceptor.java
@@ -0,0 +1,237 @@
+package com.petshop.backend.config;
+
+import com.petshop.backend.entity.User;
+import com.petshop.backend.repository.UserRepository;
+import com.petshop.backend.security.AppPrincipal;
+import com.petshop.backend.security.JwtUtil;
+import com.petshop.backend.service.ChatService;
+import io.jsonwebtoken.JwtException;
+import org.springframework.messaging.Message;
+import org.springframework.messaging.MessageChannel;
+import org.springframework.messaging.simp.stomp.StompCommand;
+import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
+import org.springframework.messaging.support.ChannelInterceptor;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.stereotype.Component;
+
+import java.security.Principal;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+@Component
+public class WebSocketAuthChannelInterceptor implements ChannelInterceptor {
+
+ private final JwtUtil jwtUtil;
+ private final UserRepository userRepository;
+ private final ChatService chatService;
+
+ public WebSocketAuthChannelInterceptor(JwtUtil jwtUtil, UserRepository userRepository, ChatService chatService) {
+ this.jwtUtil = jwtUtil;
+ this.userRepository = userRepository;
+ this.chatService = chatService;
+ }
+
+ @Override
+ public Message> preSend(Message> message, MessageChannel channel) {
+ StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
+ StompCommand command = accessor.getCommand();
+
+ if (command == null) {
+ return message;
+ }
+
+ if (StompCommand.CONNECT.equals(command)) {
+ String tokenHeader = firstHeader(accessor, "Authorization");
+ String token = extractToken(tokenHeader != null ? tokenHeader : firstHeader(accessor, "token"));
+ if (token == null || token.isBlank()) {
+ throw new IllegalArgumentException("Missing websocket token");
+ }
+
+ Long userId = extractUserId(token);
+ User user = userId == null ? null : userRepository.findById(userId).orElse(null);
+ if (user == null) {
+ throw new IllegalArgumentException("User not found");
+ }
+ if (user.getActive() == null || !user.getActive()) {
+ throw new IllegalArgumentException("User account is inactive");
+ }
+ if (!jwtUtil.validateToken(token, user)) {
+ throw new IllegalArgumentException("Invalid websocket token");
+ }
+
+ AppPrincipal principal = new AppPrincipal(
+ user.getId(),
+ user.getUsername(),
+ user.getRole(),
+ user.getTokenVersion()
+ );
+ UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
+ principal,
+ null,
+ principal.getAuthorities()
+ );
+ accessor.setUser(authentication);
+ accessor.getSessionAttributes().put("user", authentication);
+ return message;
+ }
+
+ if (StompCommand.DISCONNECT.equals(command) || StompCommand.UNSUBSCRIBE.equals(command)) {
+ return message;
+ }
+
+ User user = resolveUser(accessor.getUser(), accessor);
+ if (user == null) {
+ throw new IllegalArgumentException("Unauthenticated websocket session");
+ }
+
+ if (StompCommand.SUBSCRIBE.equals(command)) {
+ authorizeSubscription(accessor.getDestination(), user);
+ } else if (StompCommand.SEND.equals(command)) {
+ authorizeSend(accessor.getDestination(), user);
+ }
+
+ return message;
+ }
+
+ private User resolveUser(Principal principal, StompHeaderAccessor accessor) {
+ Principal currentPrincipal = principal;
+ if (currentPrincipal == null && accessor.getSessionAttributes() != null) {
+ Object sessionUser = accessor.getSessionAttributes().get("user");
+ if (sessionUser instanceof Principal storedPrincipal) {
+ accessor.setUser(storedPrincipal);
+ currentPrincipal = storedPrincipal;
+ }
+ }
+
+ if (currentPrincipal instanceof UsernamePasswordAuthenticationToken authenticationToken
+ && authenticationToken.getPrincipal() instanceof AppPrincipal appPrincipal) {
+ return userRepository.findById(appPrincipal.getUserId())
+ .orElseThrow(() -> new IllegalArgumentException("User not found"));
+ }
+
+ if (currentPrincipal instanceof AppPrincipal appPrincipal) {
+ return userRepository.findById(appPrincipal.getUserId())
+ .orElseThrow(() -> new IllegalArgumentException("User not found"));
+ }
+
+ String tokenHeader = firstHeader(accessor, "Authorization");
+ String token = extractToken(tokenHeader != null ? tokenHeader : firstHeader(accessor, "token"));
+ if (token == null || token.isBlank()) {
+ return null;
+ }
+
+ Long userId = extractUserId(token);
+ User user = userId == null ? null : userRepository.findById(userId).orElse(null);
+ if (user == null) {
+ throw new IllegalArgumentException("User not found");
+ }
+ if (user.getActive() == null || !user.getActive()) {
+ throw new IllegalArgumentException("User account is inactive");
+ }
+ if (!jwtUtil.validateToken(token, user)) {
+ throw new IllegalArgumentException("Invalid websocket token");
+ }
+ return user;
+ }
+
+ private void authorizeSubscription(String destination, User user) {
+ destination = normalizeDestination(destination);
+ if (destination == null || destination.startsWith("/user/queue/")) {
+ return;
+ }
+
+ if ("/topic/chat/conversations".equals(destination)) {
+ if (user.getRole() == User.Role.CUSTOMER) {
+ throw new IllegalArgumentException("Customers cannot subscribe to staff conversation feed");
+ }
+ return;
+ }
+
+ Long conversationId = extractConversationId(destination, "/topic/chat/conversations/");
+ if (conversationId != null && chatService.hasConversationAccess(conversationId, user.getId(), user.getRole())) {
+ return;
+ }
+
+ throw new IllegalArgumentException("Not authorized to subscribe to destination");
+ }
+
+ private void authorizeSend(String destination, User user) {
+ destination = normalizeDestination(destination);
+ Long conversationId = extractConversationId(destination, "/app/chat/conversations/");
+ if (conversationId != null && destination.endsWith("/messages") && chatService.hasConversationAccess(conversationId, user.getId(), user.getRole())) {
+ return;
+ }
+
+ throw new IllegalArgumentException("Not authorized to send to destination");
+ }
+
+ private Long extractConversationId(String destination, String prefix) {
+ if (destination == null || !destination.startsWith(prefix)) {
+ return null;
+ }
+
+ String suffix = destination.substring(prefix.length());
+ String[] parts = suffix.split("/");
+ if (parts.length == 0 || parts[0].isBlank()) {
+ return null;
+ }
+
+ try {
+ return Long.parseLong(parts[0]);
+ } catch (NumberFormatException ex) {
+ return null;
+ }
+ }
+
+ private String firstHeader(StompHeaderAccessor accessor, String name) {
+ List values = accessor.getNativeHeader(name);
+ if (values != null && !values.isEmpty()) {
+ return values.get(0);
+ }
+ for (String headerName : accessor.toNativeHeaderMap().keySet()) {
+ if (headerName.equalsIgnoreCase(name)) {
+ List alternateValues = accessor.getNativeHeader(headerName);
+ return alternateValues == null || alternateValues.isEmpty() ? null : alternateValues.get(0);
+ }
+ }
+ return null;
+ }
+
+ private String extractToken(String rawValue) {
+ if (rawValue == null || rawValue.isBlank()) {
+ return null;
+ }
+ String normalized = rawValue.trim();
+ return normalized.regionMatches(true, 0, "Bearer ", 0, 7) ? normalized.substring(7) : normalized;
+ }
+
+ private String normalizeDestination(String destination) {
+ if (destination == null || destination.isBlank()) {
+ return destination;
+ }
+ String normalized = destination.replace('\\', '/');
+ while (normalized.contains("//")) {
+ normalized = normalized.replace("//", "/");
+ }
+ return normalized.toLowerCase(Locale.ROOT);
+ }
+
+ private Long extractUserId(String token) {
+ try {
+ return jwtUtil.extractUserId(token);
+ } catch (JwtException | IllegalArgumentException ex) {
+ throw new IllegalArgumentException("Invalid websocket token: " + ex.getMessage(), ex);
+ }
+ }
+
+ public Map buildErrorPayload(Exception ex, String destination, Principal principal) {
+ Map response = new LinkedHashMap<>();
+ response.put("message", ex.getMessage() == null || ex.getMessage().isBlank() ? "WebSocket request failed" : ex.getMessage());
+ response.put("details", ex.getClass().getSimpleName());
+ response.put("destination", normalizeDestination(destination));
+ response.put("authenticated", principal != null);
+ return response;
+ }
+}
diff --git a/backend/src/main/java/com/petshop/backend/config/WebSocketConfig.java b/backend/src/main/java/com/petshop/backend/config/WebSocketConfig.java
new file mode 100644
index 00000000..67dc1048
--- /dev/null
+++ b/backend/src/main/java/com/petshop/backend/config/WebSocketConfig.java
@@ -0,0 +1,45 @@
+package com.petshop.backend.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.messaging.simp.config.ChannelRegistration;
+import org.springframework.messaging.simp.config.MessageBrokerRegistry;
+import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
+import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
+import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
+
+@Configuration
+@EnableWebSocketMessageBroker
+public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
+
+ private final WebSocketAuthChannelInterceptor webSocketAuthChannelInterceptor;
+
+ public WebSocketConfig(WebSocketAuthChannelInterceptor webSocketAuthChannelInterceptor) {
+ this.webSocketAuthChannelInterceptor = webSocketAuthChannelInterceptor;
+ }
+
+ @Override
+ public void configureMessageBroker(MessageBrokerRegistry config) {
+ config.enableSimpleBroker("/topic", "/queue");
+ config.setApplicationDestinationPrefixes("/app");
+ config.setUserDestinationPrefix("/user");
+ }
+
+ @Override
+ public void configureClientInboundChannel(ChannelRegistration registration) {
+ registration.interceptors(webSocketAuthChannelInterceptor);
+ }
+
+ @Override
+ public void registerStompEndpoints(StompEndpointRegistry registry) {
+ registry.addEndpoint("/ws/chat")
+ .setAllowedOriginPatterns("*");
+ registry.addEndpoint("/ws/chat/")
+ .setAllowedOriginPatterns("*");
+ registry.addEndpoint("/ws/chat-sockjs")
+ .setAllowedOriginPatterns("*")
+ .withSockJS();
+ registry.addEndpoint("/ws/chat-sockjs/")
+ .setAllowedOriginPatterns("*")
+ .withSockJS();
+ }
+}
diff --git a/backend/src/main/java/com/petshop/backend/controller/AdoptionController.java b/backend/src/main/java/com/petshop/backend/controller/AdoptionController.java
new file mode 100644
index 00000000..a3f67002
--- /dev/null
+++ b/backend/src/main/java/com/petshop/backend/controller/AdoptionController.java
@@ -0,0 +1,113 @@
+package com.petshop.backend.controller;
+
+import com.petshop.backend.dto.adoption.AdoptionRequest;
+import com.petshop.backend.dto.adoption.AdoptionResponse;
+import com.petshop.backend.dto.common.BulkDeleteRequest;
+import com.petshop.backend.entity.Customer;
+import com.petshop.backend.repository.CustomerRepository;
+import com.petshop.backend.repository.UserRepository;
+import com.petshop.backend.service.AdoptionService;
+import com.petshop.backend.util.AuthenticationHelper;
+import jakarta.validation.Valid;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/v1/adoptions")
+public class AdoptionController {
+
+ private final AdoptionService adoptionService;
+ private final UserRepository userRepository;
+ private final CustomerRepository customerRepository;
+
+ public AdoptionController(AdoptionService adoptionService, UserRepository userRepository, CustomerRepository customerRepository) {
+ this.adoptionService = adoptionService;
+ this.userRepository = userRepository;
+ this.customerRepository = customerRepository;
+ }
+
+ @GetMapping
+ @PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
+ public ResponseEntity> getAllAdoptions(
+ @RequestParam(required = false) String q,
+ Pageable pageable) {
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ String role = authentication.getAuthorities().stream()
+ .findFirst()
+ .map(authority -> authority.getAuthority().replace("ROLE_", ""))
+ .orElse(null);
+
+ Long customerId = null;
+ if (role != null && role.equals("CUSTOMER")) {
+ Customer customer = AuthenticationHelper.getAuthenticatedCustomer(userRepository, customerRepository);
+ customerId = customer.getCustomerId();
+ }
+
+ return ResponseEntity.ok(adoptionService.getAllAdoptions(q, pageable, customerId));
+ }
+
+ @GetMapping("/{id}")
+ @PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
+ public ResponseEntity getAdoptionById(@PathVariable Long id) {
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ String role = authentication.getAuthorities().stream()
+ .findFirst()
+ .map(authority -> authority.getAuthority().replace("ROLE_", ""))
+ .orElse(null);
+
+ Long customerId = null;
+ if (role != null && role.equals("CUSTOMER")) {
+ Customer customer = AuthenticationHelper.getAuthenticatedCustomer(userRepository, customerRepository);
+ customerId = customer.getCustomerId();
+ }
+
+ return ResponseEntity.ok(adoptionService.getAdoptionById(id, customerId));
+ }
+
+ @PostMapping
+ @PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
+ public ResponseEntity createAdoption(@Valid @RequestBody AdoptionRequest request) {
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ String role = authentication.getAuthorities().stream()
+ .findFirst()
+ .map(authority -> authority.getAuthority().replace("ROLE_", ""))
+ .orElse(null);
+
+ if (role != null && role.equals("CUSTOMER")) {
+ Customer customer = AuthenticationHelper.getAuthenticatedCustomer(userRepository, customerRepository);
+ if (!request.getCustomerId().equals(customer.getCustomerId())) {
+ throw new org.springframework.security.access.AccessDeniedException("You can only create adoptions for yourself");
+ }
+ }
+
+ return ResponseEntity.status(HttpStatus.CREATED).body(adoptionService.createAdoption(request));
+ }
+
+ @PutMapping("/{id}")
+ @PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
+ public ResponseEntity updateAdoption(
+ @PathVariable Long id,
+ @Valid @RequestBody AdoptionRequest request) {
+ return ResponseEntity.ok(adoptionService.updateAdoption(id, request));
+ }
+
+ @DeleteMapping("/{id}")
+ @PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
+ public ResponseEntity deleteAdoption(@PathVariable Long id) {
+ adoptionService.deleteAdoption(id);
+ return ResponseEntity.noContent().build();
+ }
+
+ @DeleteMapping
+ @PreAuthorize("hasRole('ADMIN')")
+ public ResponseEntity bulkDeleteAdoptions(@Valid @RequestBody BulkDeleteRequest request) {
+ adoptionService.bulkDeleteAdoptions(request);
+ return ResponseEntity.noContent().build();
+ }
+}
diff --git a/backend/src/main/java/com/petshop/backend/controller/AnalyticsController.java b/backend/src/main/java/com/petshop/backend/controller/AnalyticsController.java
new file mode 100644
index 00000000..6c7b9b9e
--- /dev/null
+++ b/backend/src/main/java/com/petshop/backend/controller/AnalyticsController.java
@@ -0,0 +1,26 @@
+package com.petshop.backend.controller;
+
+import com.petshop.backend.dto.analytics.DashboardResponse;
+import com.petshop.backend.service.AnalyticsService;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/v1/analytics")
+@PreAuthorize("hasRole('ADMIN')")
+public class AnalyticsController {
+
+ private final AnalyticsService analyticsService;
+
+ public AnalyticsController(AnalyticsService analyticsService) {
+ this.analyticsService = analyticsService;
+ }
+
+ @GetMapping("/dashboard")
+ public ResponseEntity getDashboard(
+ @RequestParam(defaultValue = "30") int days,
+ @RequestParam(defaultValue = "10") int top) {
+ return ResponseEntity.ok(analyticsService.getDashboardData(days, top));
+ }
+}
diff --git a/backend/src/main/java/com/petshop/backend/controller/AppointmentController.java b/backend/src/main/java/com/petshop/backend/controller/AppointmentController.java
new file mode 100644
index 00000000..35246e05
--- /dev/null
+++ b/backend/src/main/java/com/petshop/backend/controller/AppointmentController.java
@@ -0,0 +1,125 @@
+package com.petshop.backend.controller;
+
+import com.petshop.backend.dto.appointment.AppointmentRequest;
+import com.petshop.backend.dto.appointment.AppointmentResponse;
+import com.petshop.backend.dto.common.BulkDeleteRequest;
+import com.petshop.backend.entity.Customer;
+import com.petshop.backend.repository.CustomerRepository;
+import com.petshop.backend.repository.UserRepository;
+import com.petshop.backend.service.AppointmentService;
+import com.petshop.backend.util.AuthenticationHelper;
+import jakarta.validation.Valid;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.LocalDate;
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/v1/appointments")
+public class AppointmentController {
+
+ private final AppointmentService appointmentService;
+ private final UserRepository userRepository;
+ private final CustomerRepository customerRepository;
+
+ public AppointmentController(AppointmentService appointmentService, UserRepository userRepository, CustomerRepository customerRepository) {
+ this.appointmentService = appointmentService;
+ this.userRepository = userRepository;
+ this.customerRepository = customerRepository;
+ }
+
+ @GetMapping
+ @PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
+ public ResponseEntity> getAllAppointments(
+ @RequestParam(required = false) String q,
+ Pageable pageable) {
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ String role = authentication.getAuthorities().stream()
+ .findFirst()
+ .map(authority -> authority.getAuthority().replace("ROLE_", ""))
+ .orElse(null);
+
+ Long customerId = null;
+ if (role != null && role.equals("CUSTOMER")) {
+ Customer customer = AuthenticationHelper.getAuthenticatedCustomer(userRepository, customerRepository);
+ customerId = customer.getCustomerId();
+ }
+
+ return ResponseEntity.ok(appointmentService.getAllAppointments(q, pageable, customerId));
+ }
+
+ @GetMapping("/{id}")
+ @PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
+ public ResponseEntity getAppointmentById(@PathVariable Long id) {
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ String role = authentication.getAuthorities().stream()
+ .findFirst()
+ .map(authority -> authority.getAuthority().replace("ROLE_", ""))
+ .orElse(null);
+
+ Long customerId = null;
+ if (role != null && role.equals("CUSTOMER")) {
+ Customer customer = AuthenticationHelper.getAuthenticatedCustomer(userRepository, customerRepository);
+ customerId = customer.getCustomerId();
+ }
+
+ return ResponseEntity.ok(appointmentService.getAppointmentById(id, customerId));
+ }
+
+ @PostMapping
+ @PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
+ public ResponseEntity createAppointment(@Valid @RequestBody AppointmentRequest request) {
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ String role = authentication.getAuthorities().stream()
+ .findFirst()
+ .map(authority -> authority.getAuthority().replace("ROLE_", ""))
+ .orElse(null);
+
+ if (role != null && role.equals("CUSTOMER")) {
+ Customer customer = AuthenticationHelper.getAuthenticatedCustomer(userRepository, customerRepository);
+ if (!request.getCustomerId().equals(customer.getCustomerId())) {
+ throw new org.springframework.security.access.AccessDeniedException("You can only create appointments for yourself");
+ }
+ }
+
+ return ResponseEntity.status(HttpStatus.CREATED).body(appointmentService.createAppointment(request));
+ }
+
+ @PutMapping("/{id}")
+ @PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
+ public ResponseEntity updateAppointment(
+ @PathVariable Long id,
+ @Valid @RequestBody AppointmentRequest request) {
+ return ResponseEntity.ok(appointmentService.updateAppointment(id, request));
+ }
+
+ @DeleteMapping("/{id}")
+ @PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
+ public ResponseEntity deleteAppointment(@PathVariable Long id) {
+ appointmentService.deleteAppointment(id);
+ return ResponseEntity.noContent().build();
+ }
+
+ @DeleteMapping
+ @PreAuthorize("hasRole('ADMIN')")
+ public ResponseEntity bulkDeleteAppointments(@Valid @RequestBody BulkDeleteRequest request) {
+ appointmentService.bulkDeleteAppointments(request);
+ return ResponseEntity.noContent().build();
+ }
+
+ @GetMapping("/availability")
+ public ResponseEntity> checkAvailability(
+ @RequestParam Long storeId,
+ @RequestParam Long serviceId,
+ @RequestParam String date) {
+ LocalDate appointmentDate = LocalDate.parse(date);
+ return ResponseEntity.ok(appointmentService.checkAvailability(storeId, serviceId, appointmentDate));
+ }
+}
diff --git a/backend/src/main/java/com/petshop/backend/controller/AuthController.java b/backend/src/main/java/com/petshop/backend/controller/AuthController.java
new file mode 100644
index 00000000..2bd2b47d
--- /dev/null
+++ b/backend/src/main/java/com/petshop/backend/controller/AuthController.java
@@ -0,0 +1,353 @@
+package com.petshop.backend.controller;
+
+import com.petshop.backend.dto.auth.AvatarUploadResponse;
+import com.petshop.backend.dto.auth.LoginRequest;
+import com.petshop.backend.dto.auth.LoginResponse;
+import com.petshop.backend.dto.auth.ProfileUpdateRequest;
+import com.petshop.backend.dto.auth.RegisterRequest;
+import com.petshop.backend.dto.auth.RegisterResponse;
+import com.petshop.backend.dto.auth.UserInfoResponse;
+import com.petshop.backend.entity.EmployeeStore;
+import com.petshop.backend.entity.User;
+import com.petshop.backend.repository.EmployeeRepository;
+import com.petshop.backend.repository.EmployeeStoreRepository;
+import com.petshop.backend.repository.UserRepository;
+import com.petshop.backend.security.JwtUtil;
+import com.petshop.backend.service.UserBusinessLinkageService;
+import com.petshop.backend.util.AuthenticationHelper;
+import jakarta.validation.Valid;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.security.authentication.DisabledException;
+import org.springframework.security.authentication.InternalAuthenticationServiceException;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+@RestController
+@RequestMapping("/api/v1/auth")
+public class AuthController {
+
+ private final AuthenticationManager authenticationManager;
+ private final UserRepository userRepository;
+ private final JwtUtil jwtUtil;
+ private final PasswordEncoder passwordEncoder;
+ private final UserBusinessLinkageService userBusinessLinkageService;
+ private final EmployeeRepository employeeRepository;
+ private final EmployeeStoreRepository employeeStoreRepository;
+
+ public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, UserBusinessLinkageService userBusinessLinkageService, EmployeeRepository employeeRepository, EmployeeStoreRepository employeeStoreRepository) {
+ this.authenticationManager = authenticationManager;
+ this.userRepository = userRepository;
+ this.jwtUtil = jwtUtil;
+ this.passwordEncoder = passwordEncoder;
+ this.userBusinessLinkageService = userBusinessLinkageService;
+ this.employeeRepository = employeeRepository;
+ this.employeeStoreRepository = employeeStoreRepository;
+ }
+
+ @PostMapping("/register")
+ public ResponseEntity> register(@Valid @RequestBody RegisterRequest request) {
+ if (userRepository.findByUsername(request.getUsername()).isPresent()) {
+ Map error = new HashMap<>();
+ error.put("message", "Username already exists");
+ return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
+ }
+
+ if (userRepository.findByEmail(request.getEmail()).isPresent()) {
+ Map error = new HashMap<>();
+ error.put("message", "Email already exists");
+ return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
+ }
+
+ String phone = trimToNull(request.getPhone());
+ if (phone != null && userRepository.findByPhone(phone).isPresent()) {
+ Map error = new HashMap<>();
+ error.put("message", "Phone already exists");
+ return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
+ }
+
+ User user = new User();
+ user.setUsername(request.getUsername());
+ user.setPassword(passwordEncoder.encode(request.getPassword()));
+ user.setEmail(request.getEmail());
+ user.setFullName(request.getFullName());
+ user.setPhone(phone);
+ user.setRole(User.Role.CUSTOMER);
+ user.setActive(true);
+
+ User savedUser = userRepository.save(user);
+
+ // Create or link customer record
+ userBusinessLinkageService.ensureLinkedCustomer(savedUser);
+
+ String token = jwtUtil.generateToken(savedUser);
+
+ return ResponseEntity.status(HttpStatus.CREATED).body(new RegisterResponse(
+ savedUser.getId(),
+ savedUser.getUsername(),
+ savedUser.getEmail(),
+ savedUser.getPhone(),
+ savedUser.getRole().name(),
+ token
+ ));
+ }
+
+ @PostMapping("/login")
+ public ResponseEntity> login(@Valid @RequestBody LoginRequest request) {
+ try {
+ authenticationManager.authenticate(
+ new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())
+ );
+
+ User user = userRepository.findByUsername(request.getUsername())
+ .orElseThrow(() -> new UsernameNotFoundException("User not found"));
+
+ String token = jwtUtil.generateToken(user);
+
+ return ResponseEntity.ok(new LoginResponse(
+ token,
+ user.getUsername(),
+ user.getRole().name()
+ ));
+
+ } catch (BadCredentialsException e) {
+ Map error = new HashMap<>();
+ error.put("message", "Invalid username or password");
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
+ } catch (InternalAuthenticationServiceException e) {
+ if (e.getCause() instanceof DisabledException disabledException) {
+ Map error = new HashMap<>();
+ error.put("message", disabledException.getMessage());
+ return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error);
+ }
+ throw e;
+ } catch (DisabledException e) {
+ Map error = new HashMap<>();
+ error.put("message", e.getMessage());
+ return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error);
+ }
+ }
+
+ @GetMapping("/me")
+ public ResponseEntity getCurrentUser() {
+ User user = getAuthenticatedUser();
+
+ EmployeeStore employeeStore = resolveEmployeeStore(user);
+
+ return ResponseEntity.ok(new UserInfoResponse(
+ user.getId(),
+ user.getUsername(),
+ user.getEmail(),
+ user.getFullName(),
+ user.getPhone(),
+ user.getAvatarUrl(),
+ user.getRole().name(),
+ employeeStore != null ? employeeStore.getStore().getStoreId() : null,
+ employeeStore != null ? employeeStore.getStore().getStoreName() : null
+ ));
+ }
+
+ @PutMapping("/me")
+ public ResponseEntity> updateProfile(@Valid @RequestBody ProfileUpdateRequest request) {
+ User user = getAuthenticatedUser();
+ boolean invalidateToken = false;
+
+ if (request.getUsername() != null && !request.getUsername().equals(user.getUsername())) {
+ if (userRepository.findByUsername(request.getUsername()).isPresent()) {
+ Map error = new HashMap<>();
+ error.put("message", "Username already exists");
+ return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
+ }
+ user.setUsername(request.getUsername());
+ invalidateToken = true;
+ }
+
+ if (request.getEmail() != null && !request.getEmail().equals(user.getEmail())) {
+ if (userRepository.findByEmail(request.getEmail()).isPresent()) {
+ Map error = new HashMap<>();
+ error.put("message", "Email already exists");
+ return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
+ }
+ user.setEmail(request.getEmail());
+ }
+
+ if (request.getFullName() != null) {
+ user.setFullName(request.getFullName());
+ }
+
+ if (request.getPhone() != null) {
+ String phone = trimToNull(request.getPhone());
+ if (!java.util.Objects.equals(phone, user.getPhone())) {
+ if (phone != null && userRepository.findByPhone(phone)
+ .filter(existing -> !existing.getId().equals(user.getId()))
+ .isPresent()) {
+ Map error = new HashMap<>();
+ error.put("message", "Phone already exists");
+ return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
+ }
+ user.setPhone(phone);
+ }
+ }
+
+ if (request.getPassword() != null && !request.getPassword().isEmpty()) {
+ user.setPassword(passwordEncoder.encode(request.getPassword()));
+ invalidateToken = true;
+ }
+
+ if (invalidateToken) {
+ user.setTokenVersion(user.getTokenVersion() + 1);
+ }
+
+ User updatedUser = userRepository.save(user);
+ userBusinessLinkageService.syncLinkedRecords(updatedUser);
+
+ EmployeeStore employeeStore = resolveEmployeeStore(updatedUser);
+
+ return ResponseEntity.ok(new UserInfoResponse(
+ updatedUser.getId(),
+ updatedUser.getUsername(),
+ updatedUser.getEmail(),
+ updatedUser.getFullName(),
+ updatedUser.getPhone(),
+ updatedUser.getAvatarUrl(),
+ updatedUser.getRole().name(),
+ employeeStore != null ? employeeStore.getStore().getStoreId() : null,
+ employeeStore != null ? employeeStore.getStore().getStoreName() : null
+ ));
+ }
+
+ private EmployeeStore resolveEmployeeStore(User user) {
+ if (user.getRole() == User.Role.CUSTOMER) {
+ return null;
+ }
+
+ return employeeRepository.findByUserId(user.getId())
+ .flatMap(employee -> employeeStoreRepository.findByEmployeeEmployeeId(employee.getEmployeeId()))
+ .orElse(null);
+ }
+
+ private String trimToNull(String value) {
+ if (value == null) {
+ return null;
+ }
+ String trimmed = value.trim();
+ return trimmed.isEmpty() ? null : trimmed;
+ }
+
+ @PostMapping("/me/avatar")
+ public ResponseEntity> uploadAvatar(@RequestParam("avatar") MultipartFile file) {
+ User user = getAuthenticatedUser();
+
+ if (file.isEmpty()) {
+ Map error = new HashMap<>();
+ error.put("message", "Please select a file to upload");
+ return ResponseEntity.badRequest().body(error);
+ }
+
+ if (file.getSize() > 5 * 1024 * 1024) {
+ Map error = new HashMap<>();
+ error.put("message", "File size must not exceed 5MB");
+ return ResponseEntity.badRequest().body(error);
+ }
+
+ String contentType = file.getContentType();
+ if (contentType == null || (!contentType.equals("image/jpeg") && !contentType.equals("image/png") && !contentType.equals("image/gif"))) {
+ Map error = new HashMap<>();
+ error.put("message", "Only JPG, PNG, and GIF images are allowed");
+ return ResponseEntity.badRequest().body(error);
+ }
+
+ try {
+ String uploadDir = "uploads/avatars";
+ File directory = new File(uploadDir);
+ if (!directory.exists()) {
+ directory.mkdirs();
+ }
+
+ String originalFilename = file.getOriginalFilename();
+ String extension = originalFilename != null && originalFilename.contains(".")
+ ? originalFilename.substring(originalFilename.lastIndexOf("."))
+ : ".jpg";
+ String filename = UUID.randomUUID().toString() + extension;
+ Path filePath = Paths.get(uploadDir, filename);
+
+ Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);
+
+ String avatarUrl = "/uploads/avatars/" + filename;
+ user.setAvatarUrl(avatarUrl);
+ userRepository.save(user);
+
+ return ResponseEntity.ok(new AvatarUploadResponse(avatarUrl, "Avatar uploaded successfully"));
+
+ } catch (IOException e) {
+ Map error = new HashMap<>();
+ error.put("message", "Failed to upload avatar: " + e.getMessage());
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
+ }
+ }
+
+ @GetMapping("/me/avatar")
+ public ResponseEntity> getAvatar() {
+ User user = getAuthenticatedUser();
+
+ if (user.getAvatarUrl() == null || user.getAvatarUrl().isEmpty()) {
+ Map error = new HashMap<>();
+ error.put("message", "No avatar uploaded");
+ return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
+ }
+
+ Map response = new HashMap<>();
+ response.put("avatarUrl", user.getAvatarUrl());
+ return ResponseEntity.ok(response);
+ }
+
+ @DeleteMapping("/me/avatar")
+ public ResponseEntity> deleteAvatar() {
+ User user = getAuthenticatedUser();
+
+ if (user.getAvatarUrl() != null && !user.getAvatarUrl().isEmpty()) {
+ try {
+ Path filePath = Paths.get("." + user.getAvatarUrl());
+ Files.deleteIfExists(filePath);
+ } catch (IOException e) {
+ }
+ user.setAvatarUrl(null);
+ userRepository.save(user);
+ }
+
+ Map response = new HashMap<>();
+ response.put("message", "Avatar deleted successfully");
+ return ResponseEntity.ok(response);
+ }
+
+ @PostMapping("/logout")
+ public ResponseEntity> logout() {
+ Map response = new HashMap<>();
+ response.put("message", "Logged out successfully");
+ response.put("note", "Token remains valid until expiration. Clear token from client storage.");
+ return ResponseEntity.ok(response);
+ }
+
+ private User getAuthenticatedUser() {
+ try {
+ return AuthenticationHelper.getAuthenticatedUser(userRepository);
+ } catch (RuntimeException ex) {
+ throw new UsernameNotFoundException(ex.getMessage(), ex);
+ }
+ }
+}
diff --git a/backend/src/main/java/com/petshop/backend/controller/CategoryController.java b/backend/src/main/java/com/petshop/backend/controller/CategoryController.java
new file mode 100644
index 00000000..fb938dd9
--- /dev/null
+++ b/backend/src/main/java/com/petshop/backend/controller/CategoryController.java
@@ -0,0 +1,64 @@
+package com.petshop.backend.controller;
+
+import com.petshop.backend.dto.category.CategoryRequest;
+import com.petshop.backend.dto.category.CategoryResponse;
+import com.petshop.backend.dto.common.BulkDeleteRequest;
+import com.petshop.backend.service.CategoryService;
+import jakarta.validation.Valid;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/v1/categories")
+public class CategoryController {
+
+ private final CategoryService categoryService;
+
+ public CategoryController(CategoryService categoryService) {
+ this.categoryService = categoryService;
+ }
+
+ @GetMapping
+ public ResponseEntity> getAllCategories(
+ @RequestParam(required = false) String q,
+ Pageable pageable) {
+ return ResponseEntity.ok(categoryService.getAllCategories(q, pageable));
+ }
+
+ @GetMapping("/{id}")
+ public ResponseEntity getCategoryById(@PathVariable Long id) {
+ return ResponseEntity.ok(categoryService.getCategoryById(id));
+ }
+
+ @PostMapping
+ @PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
+ public ResponseEntity createCategory(@Valid @RequestBody CategoryRequest request) {
+ return ResponseEntity.status(HttpStatus.CREATED).body(categoryService.createCategory(request));
+ }
+
+ @PutMapping("/{id}")
+ @PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
+ public ResponseEntity updateCategory(
+ @PathVariable Long id,
+ @Valid @RequestBody CategoryRequest request) {
+ return ResponseEntity.ok(categoryService.updateCategory(id, request));
+ }
+
+ @DeleteMapping("/{id}")
+ @PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
+ public ResponseEntity deleteCategory(@PathVariable Long id) {
+ categoryService.deleteCategory(id);
+ return ResponseEntity.noContent().build();
+ }
+
+ @DeleteMapping
+ @PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
+ public ResponseEntity bulkDeleteCategories(@Valid @RequestBody BulkDeleteRequest request) {
+ categoryService.bulkDeleteCategories(request);
+ return ResponseEntity.noContent().build();
+ }
+}
diff --git a/backend/src/main/java/com/petshop/backend/controller/ChatController.java b/backend/src/main/java/com/petshop/backend/controller/ChatController.java
new file mode 100644
index 00000000..8cfb5df3
--- /dev/null
+++ b/backend/src/main/java/com/petshop/backend/controller/ChatController.java
@@ -0,0 +1,99 @@
+package com.petshop.backend.controller;
+
+import com.petshop.backend.dto.chat.ConversationRequest;
+import com.petshop.backend.dto.chat.ConversationResponse;
+import com.petshop.backend.dto.chat.MessageRequest;
+import com.petshop.backend.dto.chat.MessageResponse;
+import com.petshop.backend.entity.User;
+import com.petshop.backend.repository.CustomerRepository;
+import com.petshop.backend.repository.UserRepository;
+import com.petshop.backend.service.ChatRealtimeService;
+import com.petshop.backend.service.ChatService;
+import com.petshop.backend.util.AuthenticationHelper;
+import jakarta.validation.Valid;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/v1/chat")
+public class ChatController {
+
+ private final ChatService chatService;
+ private final ChatRealtimeService chatRealtimeService;
+ private final UserRepository userRepository;
+ private final CustomerRepository customerRepository;
+
+ public ChatController(ChatService chatService, ChatRealtimeService chatRealtimeService, UserRepository userRepository, CustomerRepository customerRepository) {
+ this.chatService = chatService;
+ this.chatRealtimeService = chatRealtimeService;
+ this.userRepository = userRepository;
+ this.customerRepository = customerRepository;
+ }
+
+ private User getCurrentUser() {
+ try {
+ return AuthenticationHelper.getAuthenticatedUser(userRepository);
+ } catch (RuntimeException ex) {
+ throw new UsernameNotFoundException(ex.getMessage(), ex);
+ }
+ }
+
+ @PostMapping("/conversations")
+ @PreAuthorize("hasRole('CUSTOMER')")
+ public ResponseEntity createConversation(@Valid @RequestBody ConversationRequest request) {
+ User user = getCurrentUser();
+ ConversationResponse response = chatService.createConversation(user.getId(), request);
+ chatRealtimeService.publishNewConversation(response);
+ return ResponseEntity.status(HttpStatus.CREATED).body(response);
+ }
+
+ @GetMapping("/conversations")
+ @PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
+ public ResponseEntity> getConversations() {
+ User user = getCurrentUser();
+ List conversations = chatService.getConversations(user.getId(), user.getRole());
+ return ResponseEntity.ok(conversations);
+ }
+
+ @GetMapping("/conversations/{id}")
+ @PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
+ public ResponseEntity getConversation(@PathVariable Long id) {
+ User user = getCurrentUser();
+ ConversationResponse conversation = chatService.getConversation(id, user.getId(), user.getRole());
+ return ResponseEntity.ok(conversation);
+ }
+
+ @PostMapping("/conversations/{id}/messages")
+ @PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
+ public ResponseEntity sendMessage(
+ @PathVariable Long id,
+ @Valid @RequestBody MessageRequest request) {
+ User user = getCurrentUser();
+ MessageResponse message = chatService.sendMessage(id, user.getId(), user.getRole(), request);
+ chatRealtimeService.publishMessage(id, message);
+ chatRealtimeService.publishConversationUpdate(id);
+ return ResponseEntity.status(HttpStatus.CREATED).body(message);
+ }
+
+ @GetMapping("/conversations/{id}/messages")
+ @PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')")
+ public ResponseEntity> getMessages(@PathVariable Long id) {
+ User user = getCurrentUser();
+ List messages = chatService.getMessages(id, user.getId(), user.getRole());
+ return ResponseEntity.ok(messages);
+ }
+
+ @PostMapping("/conversations/{id}/request-human")
+ @PreAuthorize("hasRole('CUSTOMER')")
+ public ResponseEntity requestHumanTakeover(@PathVariable Long id) {
+ User user = getCurrentUser();
+ ConversationResponse conversation = chatService.requestHumanTakeover(id, user.getId(), user.getRole());
+ chatRealtimeService.publishConversationUpdate(id);
+ return ResponseEntity.ok(conversation);
+ }
+}
diff --git a/backend/src/main/java/com/petshop/backend/controller/ChatWebSocketController.java b/backend/src/main/java/com/petshop/backend/controller/ChatWebSocketController.java
new file mode 100644
index 00000000..ed0a3718
--- /dev/null
+++ b/backend/src/main/java/com/petshop/backend/controller/ChatWebSocketController.java
@@ -0,0 +1,123 @@
+package com.petshop.backend.controller;
+
+import com.petshop.backend.config.WebSocketAuthChannelInterceptor;
+import com.petshop.backend.dto.chat.MessageRequest;
+import com.petshop.backend.dto.chat.MessageResponse;
+import com.petshop.backend.entity.User;
+import com.petshop.backend.repository.UserRepository;
+import com.petshop.backend.security.AppPrincipal;
+import com.petshop.backend.security.JwtUtil;
+import com.petshop.backend.service.ChatRealtimeService;
+import com.petshop.backend.service.ChatService;
+import jakarta.validation.Valid;
+import org.springframework.messaging.handler.annotation.DestinationVariable;
+import org.springframework.messaging.handler.annotation.MessageExceptionHandler;
+import org.springframework.messaging.handler.annotation.MessageMapping;
+import org.springframework.messaging.handler.annotation.Payload;
+import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
+import org.springframework.messaging.simp.annotation.SendToUser;
+import org.springframework.stereotype.Controller;
+
+import java.security.Principal;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Controller
+public class ChatWebSocketController {
+
+ private final ChatService chatService;
+ private final ChatRealtimeService chatRealtimeService;
+ private final UserRepository userRepository;
+ private final JwtUtil jwtUtil;
+ private final WebSocketAuthChannelInterceptor webSocketAuthChannelInterceptor;
+
+ public ChatWebSocketController(
+ ChatService chatService,
+ ChatRealtimeService chatRealtimeService,
+ UserRepository userRepository,
+ JwtUtil jwtUtil,
+ WebSocketAuthChannelInterceptor webSocketAuthChannelInterceptor
+ ) {
+ this.chatService = chatService;
+ this.chatRealtimeService = chatRealtimeService;
+ this.userRepository = userRepository;
+ this.jwtUtil = jwtUtil;
+ this.webSocketAuthChannelInterceptor = webSocketAuthChannelInterceptor;
+ }
+
+ @MessageMapping("/chat/conversations/{id}/messages")
+ @SendToUser("/queue/chat/errors")
+ public void sendMessage(@DestinationVariable Long id, @Valid @Payload MessageRequest request, SimpMessageHeaderAccessor headerAccessor) {
+ User user = resolveUser(headerAccessor);
+ MessageResponse message = chatService.sendMessage(id, user.getId(), user.getRole(), request);
+ chatRealtimeService.publishMessage(id, message);
+ chatRealtimeService.publishConversationUpdate(id);
+ }
+
+ @MessageExceptionHandler({IllegalArgumentException.class, RuntimeException.class})
+ @SendToUser("/queue/chat/errors")
+ public Map handleMessageException(Exception ex, SimpMessageHeaderAccessor headerAccessor) {
+ return webSocketAuthChannelInterceptor.buildErrorPayload(ex, headerAccessor.getDestination(), headerAccessor.getUser());
+ }
+
+ private User resolveUser(SimpMessageHeaderAccessor headerAccessor) {
+ Principal principal = headerAccessor.getUser();
+ if (principal instanceof org.springframework.security.authentication.UsernamePasswordAuthenticationToken authenticationToken
+ && authenticationToken.getPrincipal() instanceof AppPrincipal appPrincipal) {
+ return userRepository.findById(appPrincipal.getUserId())
+ .orElseThrow(() -> new IllegalArgumentException("User not found"));
+ }
+
+ if (principal instanceof AppPrincipal appPrincipal) {
+ return userRepository.findById(appPrincipal.getUserId())
+ .orElseThrow(() -> new IllegalArgumentException("User not found"));
+ }
+
+ String tokenHeader = firstHeader(headerAccessor, "Authorization");
+ if (tokenHeader == null || tokenHeader.isBlank()) {
+ tokenHeader = firstHeader(headerAccessor, "token");
+ }
+ if (tokenHeader == null || tokenHeader.isBlank()) {
+ throw new IllegalArgumentException("User not authenticated");
+ }
+
+ String token = extractToken(tokenHeader);
+ Long userId;
+ try {
+ userId = jwtUtil.extractUserId(token);
+ } catch (RuntimeException ex) {
+ throw new IllegalArgumentException("Invalid websocket token", ex);
+ }
+ User user = userId == null ? null : userRepository.findById(userId).orElse(null);
+ if (user == null) {
+ throw new IllegalArgumentException("User not found");
+ }
+ if (user.getActive() == null || !user.getActive()) {
+ throw new IllegalArgumentException("User account is inactive");
+ }
+ if (!jwtUtil.validateToken(token, user)) {
+ throw new IllegalArgumentException("Invalid websocket token");
+ }
+ return user;
+ }
+
+ private String firstHeader(SimpMessageHeaderAccessor headerAccessor, String name) {
+ List values = headerAccessor.getNativeHeader(name);
+ if (values != null && !values.isEmpty()) {
+ return values.get(0);
+ }
+ Map> headers = headerAccessor.toNativeHeaderMap();
+ for (Map.Entry> entry : headers.entrySet()) {
+ if (entry.getKey().equalsIgnoreCase(name)) {
+ return entry.getValue() == null || entry.getValue().isEmpty() ? null : entry.getValue().get(0);
+ }
+ }
+ return null;
+ }
+
+ private String extractToken(String rawValue) {
+ String normalized = rawValue.trim();
+ return normalized.regionMatches(true, 0, "Bearer ", 0, 7) ? normalized.substring(7) : normalized;
+ }
+}
diff --git a/backend/src/main/java/com/petshop/backend/controller/CustomerController.java b/backend/src/main/java/com/petshop/backend/controller/CustomerController.java
new file mode 100644
index 00000000..f3ab880e
--- /dev/null
+++ b/backend/src/main/java/com/petshop/backend/controller/CustomerController.java
@@ -0,0 +1,61 @@
+package com.petshop.backend.controller;
+
+import com.petshop.backend.dto.common.BulkDeleteRequest;
+import com.petshop.backend.dto.customer.CustomerRequest;
+import com.petshop.backend.dto.customer.CustomerResponse;
+import com.petshop.backend.service.CustomerService;
+import jakarta.validation.Valid;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/v1/customers")
+@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
+public class CustomerController {
+
+ private final CustomerService customerService;
+
+ public CustomerController(CustomerService customerService) {
+ this.customerService = customerService;
+ }
+
+ @GetMapping
+ public ResponseEntity> getAllCustomers(
+ @RequestParam(required = false) String q,
+ Pageable pageable) {
+ return ResponseEntity.ok(customerService.getAllCustomers(q, pageable));
+ }
+
+ @GetMapping("/{id}")
+ public ResponseEntity getCustomerById(@PathVariable Long id) {
+ return ResponseEntity.ok(customerService.getCustomerById(id));
+ }
+
+ @PostMapping
+ public ResponseEntity createCustomer(@Valid @RequestBody CustomerRequest request) {
+ return ResponseEntity.status(HttpStatus.CREATED).body(customerService.createCustomer(request));
+ }
+
+ @PutMapping("/{id}")
+ public ResponseEntity updateCustomer(
+ @PathVariable Long id,
+ @Valid @RequestBody CustomerRequest request) {
+ return ResponseEntity.ok(customerService.updateCustomer(id, request));
+ }
+
+ @DeleteMapping("/{id}")
+ public ResponseEntity deleteCustomer(@PathVariable Long id) {
+ customerService.deleteCustomer(id);
+ return ResponseEntity.noContent().build();
+ }
+
+ @PostMapping("/bulk-delete")
+ public ResponseEntity bulkDeleteCustomers(@Valid @RequestBody BulkDeleteRequest request) {
+ customerService.bulkDeleteCustomers(request);
+ return ResponseEntity.noContent().build();
+ }
+}
diff --git a/backend/src/main/java/com/petshop/backend/controller/DropdownController.java b/backend/src/main/java/com/petshop/backend/controller/DropdownController.java
new file mode 100644
index 00000000..16763b8a
--- /dev/null
+++ b/backend/src/main/java/com/petshop/backend/controller/DropdownController.java
@@ -0,0 +1,103 @@
+package com.petshop.backend.controller;
+
+import com.petshop.backend.dto.common.DropdownOption;
+import com.petshop.backend.repository.*;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@RestController
+@RequestMapping("/api/v1/dropdowns")
+public class DropdownController {
+
+ private final PetRepository petRepository;
+ private final CustomerRepository customerRepository;
+ private final ServiceRepository serviceRepository;
+ private final ProductRepository productRepository;
+ private final CategoryRepository categoryRepository;
+ private final StoreRepository storeRepository;
+ private final SupplierRepository supplierRepository;
+
+ public DropdownController(PetRepository petRepository, CustomerRepository customerRepository,
+ ServiceRepository serviceRepository, ProductRepository productRepository,
+ CategoryRepository categoryRepository, StoreRepository storeRepository,
+ SupplierRepository supplierRepository) {
+ this.petRepository = petRepository;
+ this.customerRepository = customerRepository;
+ this.serviceRepository = serviceRepository;
+ this.productRepository = productRepository;
+ this.categoryRepository = categoryRepository;
+ this.storeRepository = storeRepository;
+ this.supplierRepository = supplierRepository;
+ }
+
+ @GetMapping("/pets")
+ public ResponseEntity> getPets() {
+ return ResponseEntity.ok(
+ petRepository.findAll().stream()
+ .map(p -> new DropdownOption(p.getPetId(), p.getPetName()))
+ .collect(Collectors.toList())
+ );
+ }
+
+ @GetMapping("/customers")
+ @PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
+ public ResponseEntity> getCustomers() {
+ return ResponseEntity.ok(
+ customerRepository.findAll().stream()
+ .map(c -> new DropdownOption(c.getCustomerId(), c.getFirstName() + " " + c.getLastName()))
+ .collect(Collectors.toList())
+ );
+ }
+
+ @GetMapping("/services")
+ public ResponseEntity> getServices() {
+ return ResponseEntity.ok(
+ serviceRepository.findAll().stream()
+ .map(s -> new DropdownOption(s.getServiceId(), s.getServiceName()))
+ .collect(Collectors.toList())
+ );
+ }
+
+ @GetMapping("/products")
+ public ResponseEntity> getProducts() {
+ return ResponseEntity.ok(
+ productRepository.findAll().stream()
+ .map(p -> new DropdownOption(p.getProdId(), p.getProdName()))
+ .collect(Collectors.toList())
+ );
+ }
+
+ @GetMapping("/categories")
+ public ResponseEntity> getCategories() {
+ return ResponseEntity.ok(
+ categoryRepository.findAll().stream()
+ .map(c -> new DropdownOption(c.getCategoryId(), c.getCategoryName()))
+ .collect(Collectors.toList())
+ );
+ }
+
+ @GetMapping("/stores")
+ public ResponseEntity> getStores() {
+ return ResponseEntity.ok(
+ storeRepository.findAll().stream()
+ .map(s -> new DropdownOption(s.getStoreId(), s.getStoreName()))
+ .collect(Collectors.toList())
+ );
+ }
+
+ @GetMapping("/suppliers")
+ @PreAuthorize("hasRole('ADMIN')")
+ public ResponseEntity> getSuppliers() {
+ return ResponseEntity.ok(
+ supplierRepository.findAll().stream()
+ .map(s -> new DropdownOption(s.getSupId(), s.getSupCompany()))
+ .collect(Collectors.toList())
+ );
+ }
+}
diff --git a/backend/src/main/java/com/petshop/backend/controller/EmployeeController.java b/backend/src/main/java/com/petshop/backend/controller/EmployeeController.java
new file mode 100644
index 00000000..1c567623
--- /dev/null
+++ b/backend/src/main/java/com/petshop/backend/controller/EmployeeController.java
@@ -0,0 +1,49 @@
+package com.petshop.backend.controller;
+
+import com.petshop.backend.dto.employee.EmployeeRequest;
+import com.petshop.backend.dto.employee.EmployeeResponse;
+import com.petshop.backend.service.EmployeeService;
+import jakarta.validation.Valid;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/v1/employees")
+@PreAuthorize("hasRole('ADMIN')")
+public class EmployeeController {
+ private final EmployeeService employeeService;
+
+ public EmployeeController(EmployeeService employeeService) {
+ this.employeeService = employeeService;
+ }
+
+ @GetMapping
+ public ResponseEntity> getAllEmployees(@RequestParam(required = false) String q, Pageable pageable) {
+ return ResponseEntity.ok(employeeService.getAllEmployees(q, pageable));
+ }
+
+ @GetMapping("/{id}")
+ public ResponseEntity getEmployeeById(@PathVariable Long id) {
+ return ResponseEntity.ok(employeeService.getEmployeeById(id));
+ }
+
+ @PostMapping
+ public ResponseEntity createEmployee(@Valid @RequestBody EmployeeRequest request) {
+ return ResponseEntity.status(HttpStatus.CREATED).body(employeeService.createEmployee(request));
+ }
+
+ @PutMapping("/{id}")
+ public ResponseEntity updateEmployee(@PathVariable Long id, @Valid @RequestBody EmployeeRequest request) {
+ return ResponseEntity.ok(employeeService.updateEmployee(id, request));
+ }
+
+ @DeleteMapping("/{id}")
+ public ResponseEntity deleteEmployee(@PathVariable Long id) {
+ employeeService.deleteEmployee(id);
+ return ResponseEntity.noContent().build();
+ }
+}
diff --git a/backend/src/main/java/com/petshop/backend/controller/HealthController.java b/backend/src/main/java/com/petshop/backend/controller/HealthController.java
new file mode 100644
index 00000000..8ee609c3
--- /dev/null
+++ b/backend/src/main/java/com/petshop/backend/controller/HealthController.java
@@ -0,0 +1,18 @@
+package com.petshop.backend.controller;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/v1/health")
+public class HealthController {
+
+ @GetMapping
+ public ResponseEntity