Usuwanie kontaktow, blokowanie i usuwanie kont
Deploy PROD / deploy (push) Successful in 26s

KONTAKTY
Usuwanie istnialo w API, ale nie mialo przycisku na liscie. Doszedl, wraz
z endpointem `GET /admin/contacts/{id}/deletion-impact`: panel pyta
o potwierdzenie DOPIERO Z LICZBAMI, tak samo jak przy usuwaniu webinaru.

Jest to konieczne, bo usuniecie kontaktu kasuje KASKADOWO zapisy na kursy
i zgody. Przy zwyklym wpisie marketingowym odpowiedz jest zupelnie inna niz
przy kursancie z oplaconym kursem, ktoremu usuniecie odbiera dostep do
materialow. Pytanie mowi to wprost i dodaje, ze zamowienia zostaja: sa
dokumentem finansowym i maja `ON DELETE SET NULL`.

Gdy pobranie skutkow sie nie uda, potwierdzenie NIE JEST pokazywane wcale.
Pytanie o zgode na cos, czego skutkow nie udalo sie ustalic, byloby gorsze
niz brak pytania.

UZYTKOWNICY
Byla sama dezaktywacja. Doszly:

- odblokowanie (`POST /{id}/activate`), bo dezaktywacja jest odwracalna
  z zalozenia: konto zablokowane na czas urlopu ma dac sie wlaczyc bez
  zakladania nowego i bez tracenia sladu w dzienniku,
- usuniecie trwale (`DELETE /{id}`) dla kont zalozonych przez pomylke.

APLIKACJA NIE MOZE ZOSTAC BEZ ADMINISTRATORA
Usuwanie ma te same dwie bariery co dezaktywacja i uzywa TEJ SAMEJ metody
`assertNotLastActiveAdmin`, a nie drugiej kopii reguly: nie mozna usunac
wlasnego konta ani ostatniego aktywnego administratora. Jedno i drugie
konczyloby sie utrata dostepu do panelu bez mozliwosci odzyskania go
z poziomu aplikacji.

PRACA UZYTKOWNIKA NIE PRZEPADA
Wszystkie trzy odwolania do `users` maja `ON DELETE SET NULL`: pliki
w bibliotece, ogloszone zadania i odpowiedzi na pytania zostaja, tracac
jedynie informacje o autorze. Kasowanie kaskadowe zabieraloby tresc szkolen
razem z kontem, ktore ja wprowadzilo.

DEZAKTYWACJA NIE JEST OPERACJA NISZCZACA
Jej potwierdzenie celowo NIE ma czerwonego ostrzezenia: tresc mowi wprost, ze
konto da sie wlaczyc z powrotem, a od teraz jednym klknieciem obok. Czerwien
przeczylaby wlasnemu komunikatowi i rozmydlala sygnal tam, gdzie strata jest
prawdziwa, czyli przy usuwaniu konta i kontaktu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 08:40:16 +02:00
parent 0292b03d5e
commit 305b1e6144
8 changed files with 407 additions and 57 deletions
@@ -7,6 +7,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -60,6 +61,26 @@ public class UserAdminController {
return userService.deactivate(id, principal);
}
@PostMapping("/{id}/activate")
@Operation(summary = "Przywrocenie dostepu zablokowanemu kontu")
public UserView activate(@PathVariable Long id) {
return userService.activate(id);
}
/**
* Trwale usuniecie konta.
*
* Osobne od dezaktywacji, bo to inna decyzja: dezaktywacja odbiera dostep
* i zostawia slad, usuniecie zabiera konto z listy. Praca uzytkownika
* zostaje w obu przypadkach.
*/
@DeleteMapping("/{id}")
@Operation(summary = "Trwale usuniecie konta")
public void delete(@PathVariable Long id,
@AuthenticationPrincipal AuthenticatedUser principal) {
userService.delete(id, principal);
}
@PostMapping("/{id}/reset-password")
@Operation(summary = "Reset hasla. Nowe haslo zwracane jeden raz.")
public ResetPasswordResponse resetPassword(@PathVariable Long id) {
@@ -116,6 +116,59 @@ public class UserService {
return AuthService.toView(userRepository.save(user));
}
/**
* Przywrocenie dostepu wczesniej zablokowanemu kontu.
*
* Dezaktywacja jest odwracalna wlasnie po to: konto zablokowane na czas
* urlopu albo do wyjasnienia ma dac sie wlaczyc z powrotem, bez zakladania
* nowego i bez tracenia sladu w dzienniku.
*/
@Transactional
public UserView activate(Long id) {
User user = require(id);
user.setActive(true);
log.info("Aktywowano konto {}", user.getEmail());
return AuthService.toView(userRepository.save(user));
}
/**
* Trwale usuniecie konta.
*
* KIEDY ZAMIAST DEZAKTYWACJI
* -----------------------------------------------------------------------
* Dezaktywacja odbiera dostep, ale zostawia konto na liscie i w dzienniku.
* Usuniecie ma sens przy koncie zalozonym przez pomylke albo dla osoby,
* ktora nigdy nie powinna go dostac. Przy zwyklym odejsciu pracownika
* lepsza jest dezaktywacja: zostaje wtedy widoczne, kto co zrobil.
*
* CO SIE DZIEJE Z JEGO PRACA
* -----------------------------------------------------------------------
* Nic nie przepada. Pliki w bibliotece i ogloszone zadania maja
* `ON DELETE SET NULL` na kolumnie autora, wiec zostaja na miejscu,
* tracac jedynie informacje o tym, kto je dodal. Kasowanie kaskadowe
* zabieraloby tresc szkolen razem z kontem, ktore ja wprowadzilo.
*
* TE SAME DWIE BARIERY CO PRZY DEZAKTYWACJI
* -----------------------------------------------------------------------
* Nie mozna usunac wlasnego konta ani ostatniego aktywnego administratora:
* jedno i drugie konczyloby sie utrata dostepu do panelu bez mozliwosci
* odzyskania go z poziomu aplikacji.
*/
@Transactional
public void delete(Long id, AuthenticatedUser actor) {
User user = require(id);
if (user.getId().equals(actor.id())) {
throw new ValidationException("Nie można usunąć własnego konta");
}
if (user.getRole() == UserRole.ADMIN && user.isActive()) {
assertNotLastActiveAdmin(user, "To ostatnie aktywne konto administratora, nie można go usunąć");
}
userRepository.delete(user);
log.info("Usunieto konto {}", user.getEmail());
}
/** Reset hasla przez administratora. Nowe haslo pokazywane jeden raz. */
@Transactional
public ResetPasswordResponse resetPassword(Long id) {
@@ -86,6 +86,19 @@ public class ContactAdminController {
return contacts.update(id, request);
}
/**
* Co przepadnie razem z kontaktem.
*
* Panel pobiera to PRZED pytaniem o potwierdzenie, tak samo jak przy
* usuwaniu webinaru: liczby zamieniaja "czy na pewno?" w decyzje opartą
* na tym, co faktycznie zniknie.
*/
@GetMapping("/{id}/deletion-impact")
@Operation(summary = "Co przepadnie razem z kontaktem")
public ContactService.DeletionImpact deletionImpact(@PathVariable Long id) {
return contacts.deletionImpact(id);
}
@DeleteMapping("/{id}")
@Operation(summary = "Usuniecie kontaktu")
public ResponseEntity<Void> delete(@PathVariable Long id) {
@@ -56,17 +56,20 @@ public class ContactService {
private final ContactGroupMemberRepository memberRepository;
private final ConsentRepository consentRepository;
private final SettingsService settings;
private final com.softspm.webinar.repository.EnrollmentRepository enrollmentRepository;
public ContactService(ContactRepository contactRepository,
ContactGroupRepository groupRepository,
ContactGroupMemberRepository memberRepository,
ConsentRepository consentRepository,
SettingsService settings) {
SettingsService settings,
com.softspm.webinar.repository.EnrollmentRepository enrollmentRepository) {
this.contactRepository = contactRepository;
this.groupRepository = groupRepository;
this.memberRepository = memberRepository;
this.consentRepository = consentRepository;
this.settings = settings;
this.enrollmentRepository = enrollmentRepository;
}
// --- odczyt ---
@@ -171,6 +174,32 @@ public class ContactService {
return get(id);
}
/**
* Co przepadnie razem z kontaktem.
*
* Panel pyta o potwierdzenie DOPIERO Z TYMI LICZBAMI, tak samo jak przy
* usuwaniu webinaru. "Czy na pewno?" bez nich nie daje administratorowi
* niczego, na czym moglby oprzec decyzje: przy zwyklym wpisie
* marketingowym odpowiedz jest zupelnie inna niz przy kursancie
* z oplaconym kursem.
*
* `student` rozstrzyga, czy kontakt ma konto w Akademii. Usuniecie takiego
* kontaktu **odbiera dostep do kursow**, bo zapisy wisza na nim kaskadowo.
* Zamowienia zostaja: sa dokumentem finansowym i maja `ON DELETE SET NULL`.
*/
@Transactional(readOnly = true)
public DeletionImpact deletionImpact(Long id) {
Contact contact = require(id);
return new DeletionImpact(
enrollmentRepository.countByContactId(id),
consentRepository.countByContactId(id),
contact.hasAccount());
}
/** @param student czy kontakt ma konto kursanta, czyli dostep do Akademii */
public record DeletionImpact(long enrollments, long consents, boolean student) {
}
@Transactional
public void delete(Long id) {
Contact contact = require(id);
@@ -12,6 +12,14 @@ public interface ConsentRepository extends JpaRepository<Consent, Long> {
List<Consent> findByRegistrationId(Long registrationId);
/**
* Ile zgod ma kontakt.
*
* Uzywane przy ostrzezeniu przed usunieciem: zgody sa sladem rozliczalnym,
* czyli dowodem na to, na jakiej podstawie wolno bylo cokolwiek wyslac.
*/
long countByContactId(Long contactId);
/** Zgody dla wielu zapisow naraz, do listy i eksportu. */
@Query("select c from Consent c where c.registration.id in :registrationIds")
List<Consent> findByRegistrationIdIn(@Param("registrationIds") Collection<Long> registrationIds);
@@ -43,6 +43,9 @@ public interface EnrollmentRepository extends JpaRepository<Enrollment, Long> {
/** Wszystkie zapisy kursu, bez wzgledu na stan. Do podsumowania skutkow skasowania. */
long countByCourseId(Long courseId);
/** Ile zapisow na kursy ma kontakt. Uzywane przy ostrzezeniu przed usunieciem. */
long countByContactId(Long contactId);
/** Zapisani wraz z kontaktem, do listy w panelu i eksportu CSV. */
@Query("""
select e from Enrollment e