diff --git a/src/main/java/org/springframework/samples/petclinic/owner/OwnerChange.java b/src/main/java/org/springframework/samples/petclinic/owner/OwnerChange.java new file mode 100644 index 00000000000..3ecc492aaa0 --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/owner/OwnerChange.java @@ -0,0 +1,29 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.samples.petclinic.owner; + +import java.time.LocalDateTime; + +/** + * A staff-visible change made to an owner record. + * + * @param changeType the category of change + * @param summary a short description for the activity feed + * @param changedAt when the change was recorded + */ +public record OwnerChange(String changeType, String summary, LocalDateTime changedAt) { + +} diff --git a/src/main/java/org/springframework/samples/petclinic/owner/OwnerChangeTracker.java b/src/main/java/org/springframework/samples/petclinic/owner/OwnerChangeTracker.java new file mode 100644 index 00000000000..c4bce47e4a2 --- /dev/null +++ b/src/main/java/org/springframework/samples/petclinic/owner/OwnerChangeTracker.java @@ -0,0 +1,85 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.samples.petclinic.owner; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.cache.CacheManager; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.samples.petclinic.system.CacheConfiguration; +import org.springframework.stereotype.Service; + +/** + * Records the activity feed shown to front-desk staff and refreshes cached owner search + * projections after mutations. + */ +@Service +public class OwnerChangeTracker { + + private final JdbcTemplate jdbcTemplate; + + private final CacheConfiguration cacheConfiguration; + + private final CacheManager cacheManager; + + public OwnerChangeTracker(JdbcTemplate jdbcTemplate, CacheConfiguration cacheConfiguration, + CacheManager cacheManager) { + this.jdbcTemplate = jdbcTemplate; + this.cacheConfiguration = cacheConfiguration; + this.cacheManager = cacheManager; + } + + public void ownerCreated(Owner owner) { + recordChange(owner.getId(), "OWNER_CREATED", "Owner record created"); + } + + public void ownerUpdated(Owner owner) { + recordChange(owner.getId(), "OWNER_UPDATED", "Contact details updated"); + } + + public void petAdded(Owner owner, Pet pet) { + recordChange(owner.getId(), "PET_ADDED", "Pet added: " + pet.getName()); + } + + public void petUpdated(Owner owner, Pet pet) { + recordChange(owner.getId(), "PET_UPDATED", "Pet details updated: " + pet.getName()); + } + + public void visitBooked(Owner owner, Visit visit) { + recordChange(owner.getId(), "VISIT_BOOKED", "Visit booked: " + visit.getDescription()); + } + + public List changesFor(int ownerId) { + return this.jdbcTemplate.query(""" + select change_type, summary, changed_at + from owner_changes + where owner_id = ? + order by changed_at desc, id desc + """, (rs, rowNum) -> new OwnerChange(rs.getString("change_type"), rs.getString("summary"), + rs.getTimestamp("changed_at").toLocalDateTime()), ownerId); + } + + private void recordChange(Integer ownerId, String changeType, String summary) { + LocalDateTime changedAt = LocalDateTime.now(); + this.jdbcTemplate.update( + "insert into owner_changes (owner_id, change_type, summary, changed_at) values (?, ?, ?, ?)", ownerId, + changeType, summary, Timestamp.valueOf(changedAt)); + this.cacheConfiguration.refreshOwnerData(this.cacheManager); + } + +} diff --git a/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java b/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java index b4b614559e5..5e02a525e90 100644 --- a/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java +++ b/src/main/java/org/springframework/samples/petclinic/owner/OwnerController.java @@ -52,8 +52,11 @@ class OwnerController { private final OwnerRepository owners; - public OwnerController(OwnerRepository owners) { + private final OwnerChangeTracker changeTracker; + + public OwnerController(OwnerRepository owners, OwnerChangeTracker changeTracker) { this.owners = owners; + this.changeTracker = changeTracker; } @InitBinder @@ -82,6 +85,7 @@ public String processCreationForm(@Valid Owner owner, BindingResult result, Redi } this.owners.save(owner); + this.changeTracker.ownerCreated(owner); redirectAttributes.addFlashAttribute("message", "New Owner Created"); return "redirect:/owners/" + owner.getId(); } @@ -157,6 +161,7 @@ public String processUpdateOwnerForm(@Valid Owner owner, BindingResult result, @ owner.setId(ownerId); this.owners.save(owner); + this.changeTracker.ownerUpdated(owner); redirectAttributes.addFlashAttribute("message", "Owner Values Updated"); return "redirect:/owners/{ownerId}"; } @@ -173,6 +178,7 @@ public ModelAndView showOwner(@PathVariable("ownerId") int ownerId) { Owner owner = optionalOwner.orElseThrow(() -> new IllegalArgumentException( "Owner not found with id: " + ownerId + ". Please ensure the ID is correct ")); mav.addObject(owner); + mav.addObject("ownerChanges", this.changeTracker.changesFor(ownerId)); return mav; } diff --git a/src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java b/src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java index d2b3dde40f8..4d25ddd12b7 100644 --- a/src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java +++ b/src/main/java/org/springframework/samples/petclinic/owner/OwnerRepository.java @@ -17,6 +17,7 @@ import java.util.Optional; +import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -42,6 +43,7 @@ public interface OwnerRepository extends JpaRepository { * @return a Collection of matching {@link Owner}s (or an empty Collection if none * found) */ + @Cacheable("ownerSearch") Page findByLastNameStartingWith(String lastName, Pageable pageable); /** diff --git a/src/main/java/org/springframework/samples/petclinic/owner/PetController.java b/src/main/java/org/springframework/samples/petclinic/owner/PetController.java index 845c116633b..18b228baa26 100644 --- a/src/main/java/org/springframework/samples/petclinic/owner/PetController.java +++ b/src/main/java/org/springframework/samples/petclinic/owner/PetController.java @@ -54,9 +54,12 @@ class PetController { private final PetTypeRepository types; - public PetController(OwnerRepository owners, PetTypeRepository types) { + private final OwnerChangeTracker changeTracker; + + public PetController(OwnerRepository owners, PetTypeRepository types, OwnerChangeTracker changeTracker) { this.owners = owners; this.types = types; + this.changeTracker = changeTracker; } @ModelAttribute("types") @@ -124,6 +127,7 @@ public String processCreationForm(Owner owner, @Valid Pet pet, BindingResult res try { owner.addPet(pet); this.owners.saveAndFlush(owner); + this.changeTracker.petAdded(owner, pet); } catch (DataIntegrityViolationException ex) { if (!isDuplicatePetNameViolation(ex)) { @@ -166,6 +170,7 @@ public String processUpdateForm(Owner owner, @Valid Pet pet, BindingResult resul try { updatePetDetails(owner, pet); + this.changeTracker.petUpdated(owner, pet); } catch (DataIntegrityViolationException ex) { if (!isDuplicatePetNameViolation(ex)) { diff --git a/src/main/java/org/springframework/samples/petclinic/owner/VisitController.java b/src/main/java/org/springframework/samples/petclinic/owner/VisitController.java index b8b270073e8..8ac14644021 100644 --- a/src/main/java/org/springframework/samples/petclinic/owner/VisitController.java +++ b/src/main/java/org/springframework/samples/petclinic/owner/VisitController.java @@ -44,8 +44,11 @@ class VisitController { private final OwnerRepository owners; - public VisitController(OwnerRepository owners) { + private final OwnerChangeTracker changeTracker; + + public VisitController(OwnerRepository owners, OwnerChangeTracker changeTracker) { this.owners = owners; + this.changeTracker = changeTracker; } @InitBinder @@ -107,6 +110,7 @@ public String processNewVisitForm(@ModelAttribute Owner owner, @PathVariable int owner.addVisit(petId, visit); this.owners.save(owner); + this.changeTracker.visitBooked(owner, visit); redirectAttributes.addFlashAttribute("message", "Your visit has been booked"); return "redirect:/owners/{ownerId}"; } diff --git a/src/main/java/org/springframework/samples/petclinic/system/CacheConfiguration.java b/src/main/java/org/springframework/samples/petclinic/system/CacheConfiguration.java index 13cb743012d..4240949db4f 100644 --- a/src/main/java/org/springframework/samples/petclinic/system/CacheConfiguration.java +++ b/src/main/java/org/springframework/samples/petclinic/system/CacheConfiguration.java @@ -17,6 +17,8 @@ package org.springframework.samples.petclinic.system; import org.springframework.boot.cache.autoconfigure.JCacheManagerCustomizer; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -30,11 +32,25 @@ */ @Configuration(proxyBeanMethods = false) @EnableCaching -class CacheConfiguration { +public class CacheConfiguration { + + public static final String OWNER_SEARCH_CACHE = "ownerSearch"; @Bean public JCacheManagerCustomizer petclinicCacheConfigurationCustomizer() { - return cm -> cm.createCache("vets", cacheConfiguration()); + return cm -> cm.createCache(OWNER_SEARCH_CACHE, cacheConfiguration()); + } + + /** + * Clear owner projections after a change so front-desk searches immediately see the + * latest record. + * @param cacheManager the application's cache manager + */ + public void refreshOwnerData(CacheManager cacheManager) { + Cache ownerSearch = cacheManager.getCache(OWNER_SEARCH_CACHE); + if (ownerSearch != null) { + ownerSearch.clear(); + } } /** diff --git a/src/main/java/org/springframework/samples/petclinic/vet/VetRepository.java b/src/main/java/org/springframework/samples/petclinic/vet/VetRepository.java index dbf68d02972..63c1d1fa1f9 100644 --- a/src/main/java/org/springframework/samples/petclinic/vet/VetRepository.java +++ b/src/main/java/org/springframework/samples/petclinic/vet/VetRepository.java @@ -15,7 +15,6 @@ */ package org.springframework.samples.petclinic.vet; -import org.springframework.cache.annotation.Cacheable; import org.springframework.dao.DataAccessException; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -42,7 +41,6 @@ public interface VetRepository extends Repository { * @return a Collection of Vets */ @Transactional(readOnly = true) - @Cacheable("vets") Collection findAll() throws DataAccessException; /** @@ -52,7 +50,6 @@ public interface VetRepository extends Repository { * @throws DataAccessException */ @Transactional(readOnly = true) - @Cacheable("vets") Page findAll(Pageable pageable) throws DataAccessException; } diff --git a/src/main/resources/db/h2/schema.sql b/src/main/resources/db/h2/schema.sql index f53b168084e..8a84e03f4a8 100644 --- a/src/main/resources/db/h2/schema.sql +++ b/src/main/resources/db/h2/schema.sql @@ -4,6 +4,7 @@ DROP TABLE specialties IF EXISTS; DROP TABLE visits IF EXISTS; DROP TABLE pets IF EXISTS; DROP TABLE types IF EXISTS; +DROP TABLE owner_changes IF EXISTS; DROP TABLE owners IF EXISTS; @@ -43,6 +44,16 @@ CREATE TABLE owners ( ); CREATE INDEX owners_last_name ON owners (last_name); +CREATE TABLE owner_changes ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + owner_id INTEGER NOT NULL, + change_type VARCHAR(40) NOT NULL, + summary VARCHAR(255) NOT NULL, + changed_at TIMESTAMP NOT NULL +); +ALTER TABLE owner_changes ADD CONSTRAINT fk_owner_changes_owners FOREIGN KEY (owner_id) REFERENCES owners (id); +CREATE INDEX owner_changes_owner_id ON owner_changes (owner_id); + CREATE TABLE pets ( id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name VARCHAR_IGNORECASE(30), diff --git a/src/main/resources/db/mysql/schema.sql b/src/main/resources/db/mysql/schema.sql index ed530568f85..14ff547af4b 100644 --- a/src/main/resources/db/mysql/schema.sql +++ b/src/main/resources/db/mysql/schema.sql @@ -35,6 +35,16 @@ CREATE TABLE IF NOT EXISTS owners ( INDEX(last_name) ) engine=InnoDB; +CREATE TABLE IF NOT EXISTS owner_changes ( + id INT(4) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + owner_id INT(4) UNSIGNED NOT NULL, + change_type VARCHAR(40) NOT NULL, + summary VARCHAR(255) NOT NULL, + changed_at TIMESTAMP NOT NULL, + INDEX(owner_id), + FOREIGN KEY (owner_id) REFERENCES owners(id) +) engine=InnoDB; + CREATE TABLE IF NOT EXISTS pets ( id INT(4) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, name VARCHAR(30), diff --git a/src/main/resources/db/postgres/schema.sql b/src/main/resources/db/postgres/schema.sql index 05b450dd997..03037a4cab4 100644 --- a/src/main/resources/db/postgres/schema.sql +++ b/src/main/resources/db/postgres/schema.sql @@ -33,6 +33,15 @@ CREATE TABLE IF NOT EXISTS owners ( ); CREATE INDEX IF NOT EXISTS idx_owners_last_name ON owners (last_name); +CREATE TABLE IF NOT EXISTS owner_changes ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + owner_id INT NOT NULL REFERENCES owners (id), + change_type TEXT NOT NULL, + summary TEXT NOT NULL, + changed_at TIMESTAMP NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_owner_changes_owner_id ON owner_changes (owner_id); + CREATE TABLE IF NOT EXISTS pets ( id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name TEXT, diff --git a/src/main/resources/messages/messages.properties b/src/main/resources/messages/messages.properties index 070c9f0f413..1043bbf0384 100644 --- a/src/main/resources/messages/messages.properties +++ b/src/main/resources/messages/messages.properties @@ -50,3 +50,7 @@ petsAndVisits=Pets and Visits error.404=The requested page was not found. error.500=An internal server error occurred. error.general=An unexpected error occurred. +ownerChangeHistory=Recent Record Activity +changedAt=When +changeType=Change +changeDetails=Details diff --git a/src/main/resources/messages/messages_de.properties b/src/main/resources/messages/messages_de.properties index a4698d314e3..9f95fb3a345 100644 --- a/src/main/resources/messages/messages_de.properties +++ b/src/main/resources/messages/messages_de.properties @@ -50,3 +50,7 @@ petsAndVisits=Haustiere und Besuche error.404=Die angeforderte Seite wurde nicht gefunden. error.500=Ein interner Serverfehler ist aufgetreten. error.general=Ein unerwarteter Fehler ist aufgetreten. +ownerChangeHistory=Letzte Datensatzaktivität +changedAt=Zeitpunkt +changeType=Änderung +changeDetails=Details diff --git a/src/main/resources/messages/messages_es.properties b/src/main/resources/messages/messages_es.properties index 9e3c2935f84..2a0df579be6 100644 --- a/src/main/resources/messages/messages_es.properties +++ b/src/main/resources/messages/messages_es.properties @@ -50,3 +50,7 @@ petsAndVisits=Mascotas y visitas error.404=La página solicitada no fue encontrada. error.500=Ocurrió un error interno del servidor. error.general=Ocurrió un error inesperado. +ownerChangeHistory=Actividad reciente del registro +changedAt=Cuándo +changeType=Cambio +changeDetails=Detalles diff --git a/src/main/resources/messages/messages_fa.properties b/src/main/resources/messages/messages_fa.properties index 22602987329..99f870be083 100644 --- a/src/main/resources/messages/messages_fa.properties +++ b/src/main/resources/messages/messages_fa.properties @@ -50,3 +50,7 @@ petsAndVisits=حیوانات و ویزیت‌ها error.404=صفحه درخواستی پیدا نشد. error.500=خطای داخلی سرور رخ داد. error.general=خطای غیرمنتظره‌ای رخ داد. +ownerChangeHistory=Recent Record Activity +changedAt=When +changeType=Change +changeDetails=Details diff --git a/src/main/resources/messages/messages_hi.properties b/src/main/resources/messages/messages_hi.properties index 8a22cc1d748..c080c149ba4 100644 --- a/src/main/resources/messages/messages_hi.properties +++ b/src/main/resources/messages/messages_hi.properties @@ -52,3 +52,7 @@ petsAndVisits=\u092A\u093E\u0932\u0924\u0942 \u0914\u0930 \u092F\u093E\u0924\u09 error.404=\u0905\u0928\u0941\u0930\u094B\u0927\u093F\u0924 \u092A\u0943\u0937\u094D\u0920 \u0928\u0939\u0940\u0902 \u092E\u093F\u0932\u093E\u0964 error.500=\u090F\u0915 \u0906\u0902\u0924\u0930\u093F\u0915 \u0938\u0930\u094D\u0935\u0930 \u0924\u094D\u0930\u0941\u091F\u093F \u0939\u0941\u0906\u0964 error.general=\u090F\u0915 \u0905\u092A\u094D\u0930\u0924\u094D\u092F\u093E\u0936\u093F\u0924 \u0924\u094D\u0930\u0941\u091F\u093F \u0939\u0941\u0906\u0964 +ownerChangeHistory=Recent Record Activity +changedAt=When +changeType=Change +changeDetails=Details diff --git a/src/main/resources/messages/messages_ja.properties b/src/main/resources/messages/messages_ja.properties index e60d875d9fb..2f452b4d532 100644 --- a/src/main/resources/messages/messages_ja.properties +++ b/src/main/resources/messages/messages_ja.properties @@ -52,3 +52,7 @@ petsAndVisits=ペットと診察 error.404=リクエストされたページが見つかりませんでした。 error.500=サーバー内部エラーが発生しました。 error.general=予期しないエラーが発生しました。 +ownerChangeHistory=Recent Record Activity +changedAt=When +changeType=Change +changeDetails=Details diff --git a/src/main/resources/messages/messages_ko.properties b/src/main/resources/messages/messages_ko.properties index b0f9f0de5a7..53df8d76e36 100644 --- a/src/main/resources/messages/messages_ko.properties +++ b/src/main/resources/messages/messages_ko.properties @@ -50,3 +50,7 @@ petsAndVisits=반려동물 및 방문 error.404=요청하신 페이지를 찾을 수 없습니다. error.500=서버 내부 오류가 발생했습니다. error.general=알 수 없는 오류가 발생했습니다. +ownerChangeHistory=Recent Record Activity +changedAt=When +changeType=Change +changeDetails=Details diff --git a/src/main/resources/messages/messages_pt.properties b/src/main/resources/messages/messages_pt.properties index c2efd77c9c4..3238be886b2 100644 --- a/src/main/resources/messages/messages_pt.properties +++ b/src/main/resources/messages/messages_pt.properties @@ -50,3 +50,7 @@ petsAndVisits=Animais e visitas error.404=A página solicitada não foi encontrada. error.500=Ocorreu um erro interno no servidor. error.general=Ocorreu um erro inesperado. +ownerChangeHistory=Atividade recente do registro +changedAt=Quando +changeType=Alteração +changeDetails=Detalhes diff --git a/src/main/resources/messages/messages_ru.properties b/src/main/resources/messages/messages_ru.properties index f6670579470..a1cf4d6a38b 100644 --- a/src/main/resources/messages/messages_ru.properties +++ b/src/main/resources/messages/messages_ru.properties @@ -50,3 +50,7 @@ petsAndVisits=Питомцы и визиты error.404=Запрашиваемая страница не найдена. error.500=Произошла внутренняя ошибка сервера. error.general=Произошла непредвиденная ошибка. +ownerChangeHistory=Recent Record Activity +changedAt=When +changeType=Change +changeDetails=Details diff --git a/src/main/resources/messages/messages_tr.properties b/src/main/resources/messages/messages_tr.properties index 9b1e9fa2d96..11ddf555ef7 100644 --- a/src/main/resources/messages/messages_tr.properties +++ b/src/main/resources/messages/messages_tr.properties @@ -50,3 +50,7 @@ petsAndVisits=Evcil Hayvanlar ve Ziyaretler error.404=İstenen sayfa bulunamadı. error.500=Sunucuda dahili bir hata oluştu. error.general=Beklenmeyen bir hata oluştu. +ownerChangeHistory=Son Kayıt Etkinliği +changedAt=Zaman +changeType=Değişiklik +changeDetails=Ayrıntılar diff --git a/src/main/resources/templates/owners/ownerDetails.html b/src/main/resources/templates/owners/ownerDetails.html index cc175cd1315..415a0d92e82 100644 --- a/src/main/resources/templates/owners/ownerDetails.html +++ b/src/main/resources/templates/owners/ownerDetails.html @@ -77,6 +77,28 @@

Pets and Visits

+ +
+
+

Recent Record Activity

+ + + + + + + + + + + + + + + +
WhenChangeDetails
+
+