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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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) {

}
Original file line number Diff line number Diff line change
@@ -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());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep visit summaries within the database column

When a visit description is between 242 and 255 characters, saving the visit succeeds in H2/MySQL because visits.description is VARCHAR(255), but adding the Visit booked: prefix makes this audit value exceed owner_changes.summary VARCHAR(255). The subsequent insert then fails, returning a 500 after the visit has already been committed and leaving no history entry; widen the summary column or truncate/validate the generated summary.

Useful? React with 👍 / 👎.

}

public List<OwnerChange> 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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -157,6 +161,7 @@ public String processUpdateOwnerForm(@Valid Owner owner, BindingResult result, @

owner.setId(ownerId);
this.owners.save(owner);
this.changeTracker.ownerUpdated(owner);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid recording no-op edits as owner changes

When staff submit the owner edit form without changing any values, this unconditional call still appends an OWNER_UPDATED entry claiming that contact details changed. The pet edit path behaves the same way, so routine no-op submissions make the newly introduced history inaccurate; compare the persisted values with the submitted values and record an event only when a tracked field actually changes.

Useful? React with 👍 / 👎.

redirectAttributes.addFlashAttribute("message", "Owner Values Updated");
return "redirect:/owners/{ownerId}";
}
Expand All @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -42,6 +43,7 @@ public interface OwnerRepository extends JpaRepository<Owner, Integer> {
* @return a Collection of matching {@link Owner}s (or an empty Collection if none
* found)
*/
@Cacheable("ownerSearch")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent stale searches from repopulating after invalidation

When a cache-miss search overlaps an owner mutation, the search can read the pre-mutation data, the mutation can commit and clear ownerSearch, and then the still-running @Cacheable invocation can publish its stale page after that clear. Because this cache has no expiry, subsequent searches can remain stale until another mutation happens; coordinate population with invalidation or configure an expiration strategy.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bound the owner-search cache

Every distinct user-controlled last-name and page combination passed to /owners now creates a permanent cache entry, including empty result pages, while neither the Caffeine configuration nor this JCache configuration sets a maximum size or expiration. Repeated requests with unique search strings can therefore grow ownerSearch without bound until the process exhausts memory or an owner mutation happens to clear the entire cache; configure a capacity/TTL or avoid caching unrestricted search keys.

Useful? React with 👍 / 👎.

Page<Owner> findByLastNameStartingWith(String lastName, Pageable pageable);

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -42,7 +41,6 @@ public interface VetRepository extends Repository<Vet, Integer> {
* @return a <code>Collection</code> of <code>Vet</code>s
*/
@Transactional(readOnly = true)
@Cacheable("vets")
Collection<Vet> findAll() throws DataAccessException;

/**
Expand All @@ -52,7 +50,6 @@ public interface VetRepository extends Repository<Vet, Integer> {
* @throws DataAccessException
*/
@Transactional(readOnly = true)
@Cacheable("vets")
Page<Vet> findAll(Pageable pageable) throws DataAccessException;

}
11 changes: 11 additions & 0 deletions src/main/resources/db/h2/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;


Expand Down Expand Up @@ -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),
Expand Down
10 changes: 10 additions & 0 deletions src/main/resources/db/mysql/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
9 changes: 9 additions & 0 deletions src/main/resources/db/postgres/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/main/resources/messages/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions src/main/resources/messages/messages_de.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions src/main/resources/messages/messages_es.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions src/main/resources/messages/messages_fa.properties
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,7 @@ petsAndVisits=حیوانات و ویزیت‌ها
error.404=صفحه درخواستی پیدا نشد.
error.500=خطای داخلی سرور رخ داد.
error.general=خطای غیرمنتظره‌ای رخ داد.
ownerChangeHistory=Recent Record Activity
changedAt=When
changeType=Change
changeDetails=Details
4 changes: 4 additions & 0 deletions src/main/resources/messages/messages_hi.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions src/main/resources/messages/messages_ja.properties
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,7 @@ petsAndVisits=ペットと診察
error.404=リクエストされたページが見つかりませんでした。
error.500=サーバー内部エラーが発生しました。
error.general=予期しないエラーが発生しました。
ownerChangeHistory=Recent Record Activity
changedAt=When
changeType=Change
changeDetails=Details
4 changes: 4 additions & 0 deletions src/main/resources/messages/messages_ko.properties
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,7 @@ petsAndVisits=반려동물 및 방문
error.404=요청하신 페이지를 찾을 수 없습니다.
error.500=서버 내부 오류가 발생했습니다.
error.general=알 수 없는 오류가 발생했습니다.
ownerChangeHistory=Recent Record Activity
changedAt=When
changeType=Change
changeDetails=Details
Loading
Loading