adds HsOfficePartner

This commit is contained in:
Michael Hoennig 2022-10-03 11:09:36 +02:00
parent d3312c4444
commit c3195662dd
35 changed files with 2182 additions and 35 deletions

View File

@ -25,3 +25,4 @@ alias pg-sql-reset='pg-sql-stop; pg-sql-remove; pg-sql-run'
alias pg-sql-backup='docker exec -i hsadmin-ng-postgres /usr/bin/pg_dump --clean --create -U postgres postgres | gzip -9'
alias pg-sql-restore='gunzip --stdout | docker exec -i hsadmin-ng-postgres psql -U postgres -d postgres'
alias fp='grep -r '@Accepts' src | sed -e 's/^.*@/@/g' | sort -u | wc -l'

View File

@ -9,6 +9,7 @@ For architecture consider the files in the `doc` and `adr` folder.
- [PostgreSQL Server](#postgresql-server)
- [Markdown](#markdown)
- [Render Markdown embedded PlantUML](#render-markdown-embedded-plantuml)
- [Render Markdown Embedded Mermaid Diagrams](#render-markdown-embedded-mermaid-diagrams)
- [IDE Specific Settings](#ide-specific-settings)
- [IntelliJ IDEA](#intellij-idea)
- [Other Tools](#other-tools)
@ -27,6 +28,7 @@ For architecture consider the files in the `doc` and `adr` folder.
- [Dependency-License-Compatibility](#dependency-license-compatibility)
- [Dependency Version Upgrade](#dependency-version-upgrade)
- [How To ...](#how-to-...)
- [How to Configure .pgpass for the Default PostgreSQL Database?](#how-to-configure-.pgpass-for-the-default-postgresql-database?)
- [How to Run the Tests Against a Local User-Space Podman Daemon?](#how-to-run-the-tests-against-a-local-user-space-podman-daemon?)
- [Install and Run Podman](#install-and-run-podman)
- [Use the Command Line to Run the Tests Against the Podman Daemon ](#use-the-command-line-to-run-the-tests-against-the-podman-daemon-)
@ -35,7 +37,8 @@ For architecture consider the files in the `doc` and `adr` folder.
- [How to Run the Tests Against a Remote Podman or Docker Daemon?](#how-to-run-the-tests-against-a-remote-podman-or-docker-daemon?)
- [How to Run the Application on a Different Port?](#how-to-run-the-application-on-a-different-port?)
- [How to Use a Persistent Database for Integration Tests?](#how-to-use-a-persistent-database-for-integration-tests?)
- [How to Amend Liquibase SQL Changesets?](#how-to-amend-liquibase-sql-changesets?)
- [How to Amend Liquibase SQL Changesets?](#how-to-amend-liquibase-sql-changesets?)
- [How to Re-Generate Spring-Controller-Interfaces from OpenAPI specs?](#how-to-re-generate-spring-controller-interfaces-from-openapi-specs?)
- [Further Documentation](#further-documentation)
<!-- generated TOC end. -->
@ -190,6 +193,12 @@ Again, given the container is running, to restore the backup from ~/backup, run:
To generate the TOC (Table of Contents), a little bash script from a
[Blog Article](https://medium.com/@acrodriguez/one-liner-to-generate-a-markdown-toc-f5292112fd14) was used.
Given this is in PATH as `md-toc`, use:
```shell
md-toc <README.md 2 4 | sed -e 's/^ //g'
```
To render the Markdown files, especially to watch embedded PlantUML diagrams, you can use one of the following methods:
#### Render Markdown embedded PlantUML
@ -233,6 +242,39 @@ pandoc --filter pandoc-plantuml rbac.md -o rbac.pdf
If you have figured out how it works, please add instructions above this section.
#### Render Markdown Embedded Mermaid Diagrams
The source of RBAC role diagrams are much easier to read with Mermaid than with PlantUML or GraphViz, that's the main reason Mermaid ist used too.
Can you see the following diagram right in your IDE?
I mean a real graphic diagram, not just some markup code.
@startuml
me -> you: Can you see this diagram?
you -> me: Sorry, I don't :-(
me -> you: Install some tooling!
@enduml
```mermaid
graph TD;
A[Can you see this diagram?];
A --> yes;
A --> no;
no --> F[Follow the instructions below!]
F --> yes
yes --> E[Then everything is fine.]
```
If not, you need to install some tooling.
##### for IntelliJ IDEA (or derived products)
You just need the bundled Markdown plugin enabled and install and activate the Mermaid plugin in its [settings](jetbrains://idea/settings?name=Languages+%26+Frameworks--Markdown).
##### for other IDEs / command-line / operating systems
If you have figured out how it works, please add instructions above this section.
### IDE Specific Settings
#### IntelliJ IDEA
@ -643,8 +685,8 @@ If the persistent database and the temporary database show different results, on
2. You might have changes in the database which interfere with the tests,
e.g. from a previous run of tests or manually applied.
It's best to run `pg-sql-reset && gw bootRun` before each test run, to have a clean database.
## How to Amend Liquibase SQL Changesets?
### How to Amend Liquibase SQL Changesets?
Liquibase changesets are meant to be immutable and based on each other.
That means, once a changeset is written, it never changes, not even a whitespace or comment.
@ -668,6 +710,22 @@ gw bootRun
<big>**&#9888;**</big>
Just don't forget switching to the migration mode, once there is a production database!
### How to Re-Generate Spring-Controller-Interfaces from OpenAPI specs?
The API is described as OpenAPI specifications in `src/main/resources/api-definition/`.
Once generated, the interfaces for the Spring-Controllers can be found in `build/generated/sources/openapi`.
These interfaces have to be implemented by subclasses named `*Controller`.
All gradle tasks which need the generated interfaces depend on the Gradle task `processSpring` which controls the code generation.
It can also be executed directly:
```shell
gw processSpring
```
## Further Documentation
- the `doc` directory contains architecture concepts and a glossary

View File

@ -0,0 +1,12 @@
package net.hostsharing.hsadminng.errors;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface DisplayName {
String value() default "";
}

View File

@ -6,15 +6,18 @@ import org.springframework.core.NestedExceptionUtils;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.orm.jpa.JpaObjectRetrievalFailureException;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import javax.persistence.EntityNotFoundException;
import java.time.LocalDateTime;
import java.util.NoSuchElementException;
import java.util.Optional;
import java.util.regex.Pattern;
@ControllerAdvice
public class RestResponseEntityExceptionHandler
@ -42,6 +45,41 @@ public class RestResponseEntityExceptionHandler
return errorResponse(request, HttpStatus.NOT_FOUND, message);
}
@ExceptionHandler({ JpaObjectRetrievalFailureException.class, EntityNotFoundException.class })
protected ResponseEntity<CustomErrorResponse> handleJpaObjectRetrievalFailureException(
final RuntimeException exc, final WebRequest request) {
final var message =
userReadableEntityClassName(
firstLine(NestedExceptionUtils.getMostSpecificCause(exc).getMessage()));
return errorResponse(request, HttpStatus.BAD_REQUEST, message);
}
private String userReadableEntityClassName(final String exceptionMessage) {
final var regex = "(net.hostsharing.hsadminng.[a-z0-9_.]*.[A-Za-z0-9_$]*Entity) ";
final var pattern = Pattern.compile(regex);
final var matcher = pattern.matcher(exceptionMessage);
if (matcher.find()) {
final var entityName = matcher.group(1);
final var entityClass = resolveClassOrNull(entityName);
if (entityClass != null ) {
return (entityClass.isAnnotationPresent(DisplayName.class)
? exceptionMessage.replace(entityName, entityClass.getAnnotation(DisplayName.class).value())
: exceptionMessage.replace(entityName, entityClass.getSimpleName()))
.replace(" with id ", " with uuid ");
}
}
return exceptionMessage;
}
private static Class<?> resolveClassOrNull(final String entityName) {
try {
return ClassLoader.getSystemClassLoader().loadClass(entityName);
} catch (ClassNotFoundException e) {
return null;
}
}
@ExceptionHandler(Throwable.class)
protected ResponseEntity<CustomErrorResponse> handleOtherExceptions(
final Throwable exc, final WebRequest request) {

View File

@ -2,6 +2,7 @@ package net.hostsharing.hsadminng.hs.office.contact;
import lombok.*;
import lombok.experimental.FieldNameConstants;
import net.hostsharing.hsadminng.errors.DisplayName;
import net.hostsharing.hsadminng.Stringify;
import net.hostsharing.hsadminng.Stringifyable;
@ -21,6 +22,7 @@ import static net.hostsharing.hsadminng.Stringify.stringify;
@NoArgsConstructor
@AllArgsConstructor
@FieldNameConstants
@DisplayName("Contact")
public class HsOfficeContactEntity implements Stringifyable {
private static Stringify<HsOfficeContactEntity> toString = stringify(HsOfficeContactEntity.class, "contact")

View File

@ -0,0 +1,150 @@
package net.hostsharing.hsadminng.hs.office.debitor;
import net.hostsharing.hsadminng.Mapper;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRepository;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeDebitorsApi;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.*;
import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity;
import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntityPatcher;
import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorRepository;
import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity;
import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
import javax.persistence.EntityManager;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.UUID;
import java.util.function.BiConsumer;
import static net.hostsharing.hsadminng.Mapper.map;
@RestController
public class HsOfficeDebitorController implements HsOfficeDebitorsApi {
@Autowired
private Context context;
@Autowired
private HsOfficeDebitorRepository debitorRepo;
@Autowired
private HsOfficePartnerRepository partnerRepo;
@Autowired
private HsOfficeContactRepository contactRepo;
@Autowired
private EntityManager em;
@Override
@Transactional(readOnly = true)
public ResponseEntity<List<HsOfficeDebitorResource>> listDebitors(
final String currentUser,
final String assumedRoles,
final String name,
final Integer debitorNumber) {
context.define(currentUser, assumedRoles);
final var entities = debitorNumber != null
? debitorRepo.findDebitorByDebitorNumber(debitorNumber)
: debitorRepo.findDebitorByOptionalNameLike(name);
final var resources = Mapper.mapList(entities, HsOfficeDebitorResource.class,
DEBITOR_ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.ok(resources);
}
@Override
@Transactional
public ResponseEntity<HsOfficeDebitorResource> addDebitor(
final String currentUser,
final String assumedRoles,
final HsOfficeDebitorInsertResource body) {
context.define(currentUser, assumedRoles);
final var entityToSave = map(body, HsOfficeDebitorEntity.class, DEBITOR_RESOURCE_TO_ENTITY_POSTMAPPER);
entityToSave.setUuid(UUID.randomUUID());
final var saved = debitorRepo.save(entityToSave);
final var uri =
MvcUriComponentsBuilder.fromController(getClass())
.path("/api/hs/office/debitors/{id}")
.buildAndExpand(entityToSave.getUuid())
.toUri();
final var mapped = map(saved, HsOfficeDebitorResource.class,
DEBITOR_ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.created(uri).body(mapped);
}
@Override
@Transactional(readOnly = true)
public ResponseEntity<HsOfficeDebitorResource> getDebitorByUuid(
final String currentUser,
final String assumedRoles,
final UUID debitorUuid) {
context.define(currentUser, assumedRoles);
final var result = debitorRepo.findByUuid(debitorUuid);
if (result.isEmpty()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(map(result.get(), HsOfficeDebitorResource.class, DEBITOR_ENTITY_TO_RESOURCE_POSTMAPPER));
}
@Override
@Transactional
public ResponseEntity<Void> deleteDebitorByUuid(
final String currentUser,
final String assumedRoles,
final UUID debitorUuid) {
context.define(currentUser, assumedRoles);
final var result = debitorRepo.deleteByUuid(debitorUuid);
if (result == 0) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.noContent().build();
}
@Override
@Transactional
public ResponseEntity<HsOfficeDebitorResource> patchDebitor(
final String currentUser,
final String assumedRoles,
final UUID debitorUuid,
final HsOfficeDebitorPatchResource body) {
context.define(currentUser, assumedRoles);
final var current = debitorRepo.findByUuid(debitorUuid).orElseThrow();
new HsOfficeDebitorEntityPatcher(em, current).apply(body);
final var saved = debitorRepo.save(current);
final var mapped = map(saved, HsOfficeDebitorResource.class);
return ResponseEntity.ok(mapped);
}
final BiConsumer<HsOfficeDebitorEntity, HsOfficeDebitorResource> DEBITOR_ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> {
resource.setPartner(map(entity.getPartner(), HsOfficePartnerResource.class));
resource.setBillingContact(map(entity.getBillingContact(), HsOfficeContactResource.class));
};
final BiConsumer<HsOfficeDebitorInsertResource, HsOfficeDebitorEntity> DEBITOR_RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> {
entity.setPartner(em.getReference(HsOfficePartnerEntity.class, resource.getPartnerUuid()));
entity.setBillingContact(em.getReference(HsOfficeContactEntity.class, resource.getBillingContactUuid()));
};
}

View File

@ -0,0 +1,57 @@
package net.hostsharing.hsadminng.hs.office.debitor;
import lombok.*;
import net.hostsharing.hsadminng.errors.DisplayName;
import net.hostsharing.hsadminng.Stringify;
import net.hostsharing.hsadminng.Stringifyable;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity;
import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity;
import javax.persistence.*;
import java.util.UUID;
import static net.hostsharing.hsadminng.Stringify.stringify;
@Entity
@Table(name = "hs_office_debitor_rv")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@DisplayName("Debitor")
public class HsOfficeDebitorEntity implements Stringifyable {
private static Stringify<HsOfficeDebitorEntity> stringify =
stringify(HsOfficeDebitorEntity.class, "debitor")
.withProp(HsOfficeDebitorEntity::getDebitorNumber)
.withProp(HsOfficeDebitorEntity::getPartner)
.withSeparator(": ")
.quotedValues(false);
private @Id UUID uuid;
@ManyToOne
@JoinColumn(name = "partneruuid")
private HsOfficePartnerEntity partner;
private @Column(name = "debitornumber") Integer debitorNumber;
@ManyToOne
@JoinColumn(name = "billingcontactuuid")
private HsOfficeContactEntity billingContact;
private @Column(name = "vatid") String vatId;
private @Column(name = "vatcountrycode") String vatCountryCode;
private @Column(name = "vatbusiness") boolean vatBusiness;
@Override
public String toString() {
return stringify.apply(this);
}
@Override
public String toShortString() {
return debitorNumber.toString();
}
}

View File

@ -0,0 +1,41 @@
package net.hostsharing.hsadminng.hs.office.debitor;
import net.hostsharing.hsadminng.EntityPatcher;
import net.hostsharing.hsadminng.OptionalFromJson;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeDebitorPatchResource;
import javax.persistence.EntityManager;
class HsOfficeDebitorEntityPatcher implements EntityPatcher<HsOfficeDebitorPatchResource> {
private final EntityManager em;
private final HsOfficeDebitorEntity entity;
HsOfficeDebitorEntityPatcher(
final EntityManager em,
final HsOfficeDebitorEntity entity) {
this.em = em;
this.entity = entity;
}
@Override
public void apply(final HsOfficeDebitorPatchResource resource) {
OptionalFromJson.of(resource.getBillingContactUuid()).ifPresent(newValue -> {
verifyNotNull(newValue, "billingContact");
entity.setBillingContact(em.getReference(HsOfficeContactEntity.class, newValue));
});
OptionalFromJson.of(resource.getVatId()).ifPresent(entity::setVatId);
OptionalFromJson.of(resource.getVatCountryCode()).ifPresent(entity::setVatCountryCode);
OptionalFromJson.of(resource.getVatBusiness()).ifPresent(newValue -> {
verifyNotNull(newValue, "vatBusiness");
entity.setVatBusiness(newValue);
});
}
private void verifyNotNull(final Object newValue, final String propertyName) {
if (newValue == null) {
throw new IllegalArgumentException("property '" + propertyName + "' must not be null");
}
}
}

View File

@ -0,0 +1,39 @@
package net.hostsharing.hsadminng.hs.office.debitor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface HsOfficeDebitorRepository extends Repository<HsOfficeDebitorEntity, UUID> {
Optional<HsOfficeDebitorEntity> findByUuid(UUID id);
@Query("""
SELECT debitor FROM HsOfficeDebitorEntity debitor
WHERE debitor.debitorNumber = :debitorNumber
""")
List<HsOfficeDebitorEntity> findDebitorByDebitorNumber(int debitorNumber);
@Query("""
SELECT debitor FROM HsOfficeDebitorEntity debitor
JOIN HsOfficePartnerEntity partner ON partner.uuid = debitor.partner
JOIN HsOfficePersonEntity person ON person.uuid = partner.person
JOIN HsOfficeContactEntity contact ON contact.uuid = debitor.billingContact
WHERE :name is null
OR partner.birthName like concat(:name, '%')
OR person.tradeName like concat(:name, '%')
OR person.familyName like concat(:name, '%')
OR person.givenName like concat(:name, '%')
OR contact.label like concat(:name, '%')
""")
List<HsOfficeDebitorEntity> findDebitorByOptionalNameLike(String name);
HsOfficeDebitorEntity save(final HsOfficeDebitorEntity entity);
long count();
int deleteByUuid(UUID uuid);
}

View File

@ -65,6 +65,7 @@ public class HsOfficePartnerController implements HsOfficePartnersApi {
final var entityToSave = mapToHsOfficePartnerEntity(body);
entityToSave.setUuid(UUID.randomUUID());
// TODO.impl: use getReference
entityToSave.setContact(contactRepo.findByUuid(body.getContactUuid()).orElseThrow(
() -> new NoSuchElementException("cannot find contact uuid " + body.getContactUuid())
));
@ -141,6 +142,7 @@ public class HsOfficePartnerController implements HsOfficePartnersApi {
resource.setContact(map(entity.getContact(), HsOfficeContactResource.class));
};
// TODO.impl: user postmapper + getReference
private HsOfficePartnerEntity mapToHsOfficePartnerEntity(final HsOfficePartnerInsertResource resource) {
final var entity = new HsOfficePartnerEntity();
entity.setBirthday(resource.getBirthday());

View File

@ -1,6 +1,7 @@
package net.hostsharing.hsadminng.hs.office.partner;
import lombok.*;
import net.hostsharing.hsadminng.errors.DisplayName;
import net.hostsharing.hsadminng.Stringify;
import net.hostsharing.hsadminng.Stringifyable;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity;
@ -19,6 +20,7 @@ import static net.hostsharing.hsadminng.Stringify.stringify;
@Builder
@NoArgsConstructor
@AllArgsConstructor
@DisplayName("Partner")
public class HsOfficePartnerEntity implements Stringifyable {
private static Stringify<HsOfficePartnerEntity> stringify = stringify(HsOfficePartnerEntity.class, "partner")

View File

@ -3,6 +3,7 @@ package net.hostsharing.hsadminng.hs.office.person;
import com.vladmihalcea.hibernate.type.basic.PostgreSQLEnumType;
import lombok.*;
import lombok.experimental.FieldNameConstants;
import net.hostsharing.hsadminng.errors.DisplayName;
import net.hostsharing.hsadminng.Stringify;
import net.hostsharing.hsadminng.Stringifyable;
import org.apache.commons.lang3.StringUtils;
@ -26,6 +27,7 @@ import static net.hostsharing.hsadminng.Stringify.stringify;
@NoArgsConstructor
@AllArgsConstructor
@FieldNameConstants
@DisplayName("Person")
public class HsOfficePersonEntity implements Stringifyable {
private static Stringify<HsOfficePersonEntity> toString = stringify(HsOfficePersonEntity.class, "person")
@ -50,10 +52,6 @@ public class HsOfficePersonEntity implements Stringifyable {
@Column(name = "givenname")
private String givenName;
public String getDisplayName() {
return toShortString();
}
@Override
public String toString() {
return toString.apply(this);

View File

@ -20,3 +20,5 @@ map:
null: org.openapitools.jackson.nullable.JsonNullable
/api/hs/office/relationships/{relationshipUUID}:
null: org.openapitools.jackson.nullable.JsonNullable
/api/hs/office/debitors/{debitorUUID}:
null: org.openapitools.jackson.nullable.JsonNullable

View File

@ -0,0 +1,70 @@
components:
schemas:
HsOfficeDebitor:
type: object
properties:
uuid:
type: string
format: uuid
debitorNumber:
type: integer
format: int32
minimum: 10000
maximum: 99999
partner:
$ref: './hs-office-partner-schemas.yaml#/components/schemas/HsOfficePartner'
billingContact:
$ref: './hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContact'
vatId:
type: string
vatCountryCode:
type: string
pattern: '^[A_Z][A-Z]$'
vatBusiness:
type: boolean
HsOfficeDebitorPatch:
type: object
properties:
billingContactUuid:
type: string
format: uuid
nullable: true
vatId:
type: string
nullable: true
vatCountryCode:
type: string
pattern: '^[A_Z][A-Z]$'
nullable: true
vatBusiness:
type: boolean
nullable: true
HsOfficeDebitorInsert:
type: object
properties:
partnerUuid:
type: string
format: uuid
billingContactUuid:
type: string
format: uuid
debitorNumber:
type: integer
format: int32
minimum: 10000
maximum: 99999
vatId:
type: string
vatCountryCode:
type: string
pattern: '^[A_Z][A-Z]$'
vatBusiness:
type: boolean
required:
- partnerUuid
- billingContactUuid

View File

@ -0,0 +1,83 @@
get:
tags:
- hs-office-debitors
description: 'Fetch a single debitor by its uuid, if visible for the current subject.'
operationId: getDebitorByUuid
parameters:
- $ref: './auth.yaml#/components/parameters/currentUser'
- $ref: './auth.yaml#/components/parameters/assumedRoles'
- name: debitorUUID
in: path
required: true
schema:
type: string
format: uuid
description: UUID of the debitor to fetch.
responses:
"200":
description: OK
content:
'application/json':
schema:
$ref: './hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitor'
"401":
$ref: './error-responses.yaml#/components/responses/Unauthorized'
"403":
$ref: './error-responses.yaml#/components/responses/Forbidden'
patch:
tags:
- hs-office-debitors
description: 'Updates a single debitor by its uuid, if permitted for the current subject.'
operationId: patchDebitor
parameters:
- $ref: './auth.yaml#/components/parameters/currentUser'
- $ref: './auth.yaml#/components/parameters/assumedRoles'
- name: debitorUUID
in: path
required: true
schema:
type: string
format: uuid
requestBody:
content:
'application/json':
schema:
$ref: './hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitorPatch'
responses:
"200":
description: OK
content:
'application/json':
schema:
$ref: './hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitor'
"401":
$ref: './error-responses.yaml#/components/responses/Unauthorized'
"403":
$ref: './error-responses.yaml#/components/responses/Forbidden'
delete:
tags:
- hs-office-debitors
description: 'Delete a single debitor by its uuid, if permitted for the current subject.'
operationId: deleteDebitorByUuid
parameters:
- $ref: './auth.yaml#/components/parameters/currentUser'
- $ref: './auth.yaml#/components/parameters/assumedRoles'
- name: debitorUUID
in: path
required: true
schema:
type: string
format: uuid
description: UUID of the debitor to delete.
responses:
"204":
description: No Content
"401":
$ref: './error-responses.yaml#/components/responses/Unauthorized'
"403":
$ref: './error-responses.yaml#/components/responses/Forbidden'
"404":
$ref: './error-responses.yaml#/components/responses/NotFound'

View File

@ -0,0 +1,62 @@
get:
summary: Returns a list of (optionally filtered) debitors.
description: Returns the list of (optionally filtered) debitors which are visible to the current user or any of it's assumed roles.
tags:
- hs-office-debitors
operationId: listDebitors
parameters:
- $ref: './auth.yaml#/components/parameters/currentUser'
- $ref: './auth.yaml#/components/parameters/assumedRoles'
- name: name
in: query
required: false
schema:
type: string
description: Prefix of name properties from person or contact to filter the results.
- name: debitorNumber
in: query
required: false
schema:
type: integer
description: Debitor number of the requested debitor.
responses:
"200":
description: OK
content:
'application/json':
schema:
type: array
items:
$ref: './hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitor'
"401":
$ref: './error-responses.yaml#/components/responses/Unauthorized'
"403":
$ref: './error-responses.yaml#/components/responses/Forbidden'
post:
summary: Adds a new debitor.
tags:
- hs-office-debitors
operationId: addDebitor
parameters:
- $ref: './auth.yaml#/components/parameters/currentUser'
- $ref: './auth.yaml#/components/parameters/assumedRoles'
requestBody:
content:
'application/json':
schema:
$ref: './hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitorInsert'
required: true
responses:
"201":
description: Created
content:
'application/json':
schema:
$ref: './hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitor'
"401":
$ref: './error-responses.yaml#/components/responses/Unauthorized'
"403":
$ref: './error-responses.yaml#/components/responses/Forbidden'
"409":
$ref: './error-responses.yaml#/components/responses/Conflict'

View File

@ -35,7 +35,6 @@ paths:
$ref: "./hs-office-persons-with-uuid.yaml"
# Relationships
/api/hs/office/relationships:
@ -44,3 +43,11 @@ paths:
/api/hs/office/relationships/{relationshipUUID}:
$ref: "./hs-office-relationships-with-uuid.yaml"
# Debitors
/api/hs/office/debitors:
$ref: "./hs-office-debitors.yaml"
/api/hs/office/debitors/{debitorUUID}:
$ref: "./hs-office-debitors-with-uuid.yaml"

View File

@ -63,6 +63,7 @@ do language plpgsql $$
call createHsOfficePersonTestData('NATURAL', null, 'Smith', 'Peter');
call createHsOfficePersonTestData('LEGAL', 'Second e.K.', 'Sandra', 'Miller');
call createHsOfficePersonTestData('SOLE_REPRESENTATION', 'Third OHG');
call createHsOfficePersonTestData('SOLE_REPRESENTATION', 'Fourth e.G.');
call createHsOfficePersonTestData('JOINT_REPRESENTATION', 'Erben Bessler', 'Mel', 'Bessler');
call createHsOfficePersonTestData('NATURAL', null, 'Bessler', 'Anita');
call createHsOfficePersonTestData('NATURAL', null, 'Winkler', 'Paul');

View File

@ -64,10 +64,9 @@ end; $$;
do language plpgsql $$
begin
call createHsOfficePartnerTestData('First GmbH', 'first contact');
call createHsOfficePartnerTestData('Second e.K.', 'second contact');
call createHsOfficePartnerTestData('Third OHG', 'third contact');
call createHsOfficePartnerTestData('Fourth e.G.', 'forth contact');
end;
$$;
--//

View File

@ -0,0 +1,18 @@
--liquibase formatted sql
-- ============================================================================
--changeset hs-office-debitor-MAIN-TABLE:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
create table hs_office_debitor
(
uuid uuid unique references RbacObject (uuid) initially deferred,
partnerUuid uuid not null references hs_office_partner(uuid),
debitorNumber numeric(5) not null,
billingContactUuid uuid not null references hs_office_contact(uuid),
vatId varchar(24), -- TODO.spec: here or in person?
vatCountryCode varchar(2),
vatBusiness boolean not null -- TODO.spec: more of such?
-- TODO.impl: SEPA-mandate + bank account
);
--//

View File

@ -0,0 +1,24 @@
### hs_office_debitor RBAC Roles
```mermaid
graph TD;
%% role:debitor.owner
role:debitor.owner --> perm:debitor.*;
role:global.admin --> role:debitor.owner;
%% role:debitor.admin
role:debitor.admin --> perm:debitor.edit;
role:debitor.owner --> role:debitor.admin;
%% role:debitor.tenant
role:debitor.tenant --> perm:debitor.view;
%% super-roles
role:debitor.admin --> role:debitor.tenant;
role:partner.admin --> role:debitor.tenant;
role:person.admin --> role:debitor.tenant;
role:contact.admin --> role:debitor.tenant;
%% sub-roles
role:debitor.tenant --> role:partner.tenant;
role:debitor.tenant --> role:person.tenant;
role:debitor.tenant --> role:contact.tenant;
```

View File

@ -0,0 +1,192 @@
--liquibase formatted sql
-- ============================================================================
--changeset hs-office-debitor-rbac-OBJECT:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
call generateRelatedRbacObject('hs_office_debitor');
--//
-- ============================================================================
--changeset hs-office-debitor-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
call generateRbacRoleDescriptors('hsOfficeDebitor', 'hs_office_debitor');
--//
-- ============================================================================
--changeset hs-office-debitor-rbac-ROLES-CREATION:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
/*
Creates and updates the roles and their assignments for debitor entities.
*/
create or replace function hsOfficeDebitorRbacRolesTrigger()
returns trigger
language plpgsql
strict as $$
declare
hsOfficeDebitorTenant RbacRoleDescriptor;
ownerRole uuid;
adminRole uuid;
oldPartner hs_office_partner;
newPartner hs_office_partner;
newPerson hs_office_person;
oldContact hs_office_contact;
newContact hs_office_contact;
begin
hsOfficeDebitorTenant := hsOfficeDebitorTenant(NEW);
select * from hs_office_partner as p where p.uuid = NEW.partnerUuid into newPartner;
select * from hs_office_person as p where p.uuid = newPartner.personUuid into newPerson;
select * from hs_office_contact as c where c.uuid = NEW.billingContactUuid into newContact;
if TG_OP = 'INSERT' then
-- the owner role with full access for the global admins
ownerRole = createRole(
hsOfficeDebitorOwner(NEW),
grantingPermissions(forObjectUuid => NEW.uuid, permitOps => array ['*']),
beneathRole(globalAdmin())
);
-- the admin role with full access for owner
adminRole = createRole(
hsOfficeDebitorAdmin(NEW),
grantingPermissions(forObjectUuid => NEW.uuid, permitOps => array ['edit']),
beneathRole(ownerRole)
);
-- the tenant role for those related users who can view the data
perform createRole(
hsOfficeDebitorTenant,
grantingPermissions(forObjectUuid => NEW.uuid, permitOps => array ['view']),
beneathRoles(array[
hsOfficeDebitorAdmin(NEW),
hsOfficePartnerAdmin(newPartner),
hsOfficePersonAdmin(newPerson),
hsOfficeContactAdmin(newContact)]),
withSubRoles(array[
hsOfficePartnerTenant(newPartner),
hsOfficePersonTenant(newPerson),
hsOfficeContactTenant(newContact)])
);
elsif TG_OP = 'UPDATE' then
if OLD.partnerUuid <> NEW.partnerUuid then
select * from hs_office_partner as p where p.uuid = OLD.partnerUuid into oldPartner;
call revokeRoleFromRole( hsOfficeDebitorTenant, hsOfficePartnerAdmin(oldPartner) );
call grantRoleToRole( hsOfficeDebitorTenant, hsOfficePartnerAdmin(newPartner) );
call revokeRoleFromRole( hsOfficePartnerTenant(oldPartner), hsOfficeDebitorTenant );
call grantRoleToRole( hsOfficePartnerTenant(newPartner), hsOfficeDebitorTenant );
end if;
if OLD.billingContactUuid <> NEW.billingContactUuid then
select * from hs_office_contact as c where c.uuid = OLD.billingContactUuid into oldContact;
call revokeRoleFromRole( hsOfficeDebitorTenant, hsOfficeContactAdmin(oldContact) );
call grantRoleToRole( hsOfficeDebitorTenant, hsOfficeContactAdmin(newContact) );
call revokeRoleFromRole( hsOfficeContactTenant(oldContact), hsOfficeDebitorTenant );
call grantRoleToRole( hsOfficeContactTenant(newContact), hsOfficeDebitorTenant );
end if;
else
raise exception 'invalid usage of TRIGGER';
end if;
return NEW;
end; $$;
/*
An AFTER INSERT TRIGGER which creates the role structure for a new debitor.
*/
create trigger createRbacRolesForHsOfficeDebitor_Trigger
after insert
on hs_office_debitor
for each row
execute procedure hsOfficeDebitorRbacRolesTrigger();
/*
An AFTER UPDATE TRIGGER which updates the role structure of a debitor.
*/
create trigger updateRbacRolesForHsOfficeDebitor_Trigger
after update
on hs_office_debitor
for each row
execute procedure hsOfficeDebitorRbacRolesTrigger();
--//
-- ============================================================================
--changeset hs-office-debitor-rbac-IDENTITY-VIEW:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
call generateRbacIdentityView('hs_office_debitor', $idName$
'#' || debitorNumber || ':' ||
(select idName from hs_office_partner_iv p where p.uuid = target.partnerUuid)
$idName$);
--//
-- ============================================================================
--changeset hs-office-debitor-rbac-RESTRICTED-VIEW:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
call generateRbacRestrictedView('hs_office_debitor',
'target.debitorNumber',
$updates$
billingContactUuid = new.billingContactUuid,
vatId = new.vatId,
vatCountryCode = new.vatCountryCode,
vatBusiness = new.vatBusiness
$updates$);
--//
-- ============================================================================
--changeset hs-office-debitor-rbac-NEW-DEBITOR:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
/*
Creates a global permission for new-debitor and assigns it to the hostsharing admins role.
*/
do language plpgsql $$
declare
addDebitorPermissions uuid[];
globalObjectUuid uuid;
globalAdminRoleUuid uuid ;
begin
call defineContext('granting global new-debitor permission to global admin role', null, null, null);
globalAdminRoleUuid := findRoleId(globalAdmin());
globalObjectUuid := (select uuid from global);
addDebitorPermissions := createPermissions(globalObjectUuid, array ['new-debitor']);
call grantPermissionsToRole(globalAdminRoleUuid, addDebitorPermissions);
end;
$$;
/**
Used by the trigger to prevent the add-debitor to current user respectively assumed roles.
*/
create or replace function addHsOfficeDebitorNotAllowedForCurrentSubjects()
returns trigger
language PLPGSQL
as $$
begin
raise exception '[403] new-debitor not permitted for %',
array_to_string(currentSubjects(), ';', 'null');
end; $$;
/**
Checks if the user or assumed roles are allowed to create a new debitor.
*/
create trigger hs_office_debitor_insert_trigger
before insert
on hs_office_debitor
for each row
-- TODO.spec: who is allowed to create new debitors
when ( not hasAssumedRole() )
execute procedure addHsOfficeDebitorNotAllowedForCurrentSubjects();
--//

View File

@ -0,0 +1,52 @@
--liquibase formatted sql
-- ============================================================================
--changeset hs-office-debitor-TEST-DATA-GENERATOR:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
/*
Creates a single debitor test record.
*/
create or replace procedure createHsOfficeDebitorTestData( partnerTradeName varchar, billingContactLabel varchar )
language plpgsql as $$
declare
currentTask varchar;
idName varchar;
relatedPartner hs_office_partner;
relatedContact hs_office_contact;
newDebitorNumber numeric(6);
begin
idName := cleanIdentifier( partnerTradeName|| '-' || billingContactLabel);
currentTask := 'creating RBAC test debitor ' || idName;
call defineContext(currentTask, null, 'superuser-alex@hostsharing.net', 'global#global.admin');
execute format('set local hsadminng.currentTask to %L', currentTask);
select partner.* from hs_office_partner partner
join hs_office_person person on person.uuid = partner.personUuid
where person.tradeName = partnerTradeName into relatedPartner;
select c.* from hs_office_contact c where c.label = billingContactLabel into relatedContact;
select coalesce(max(debitorNumber)+1, 10001) from hs_office_debitor into newDebitorNumber;
raise notice 'creating test debitor: % (#%)', idName, newDebitorNumber;
raise notice '- using partner (%): %', relatedPartner.uuid, relatedPartner;
raise notice '- using billingContact (%): %', relatedContact.uuid, relatedContact;
insert
into hs_office_debitor (uuid, partneruuid, debitornumber, billingcontactuuid, vatbusiness)
values (uuid_generate_v4(), relatedPartner.uuid, newDebitorNumber, relatedContact.uuid, true);
end; $$;
--//
-- ============================================================================
--changeset hs-office-debitor-TEST-DATA-GENERATION:1 context=dev,tc endDelimiter:--//
-- ----------------------------------------------------------------------------
do language plpgsql $$
begin
call createHsOfficeDebitorTestData('First GmbH', 'first contact');
call createHsOfficeDebitorTestData('Second e.K.', 'second contact');
call createHsOfficeDebitorTestData('Third OHG', 'third contact');
end;
$$;
--//

View File

@ -71,3 +71,9 @@ databaseChangeLog:
file: db/changelog/233-hs-office-relationship-rbac.sql
- include:
file: db/changelog/238-hs-office-relationship-test-data.sql
- include:
file: db/changelog/270-hs-office-debitor.sql
- include:
file: db/changelog/273-hs-office-debitor-rbac.sql
- include:
file: db/changelog/278-hs-office-debitor-test-data.sql

View File

@ -132,7 +132,7 @@ public abstract class PatchUnitTestBase<R, E> {
protected abstract EntityPatcher<R> createPatcher(final E entity);
@SuppressWarnings("rawtypes")
@SuppressWarnings("types")
protected abstract Stream<Property> propertyTestDescriptors();
private Stream<Arguments> propertyTestCases() {

View File

@ -17,10 +17,16 @@ public class ArchitectureTest {
public static final String NET_HOSTSHARING_HSADMINNG = "net.hostsharing.hsadminng";
@ArchTest
@SuppressWarnings("unused")
public static final ArchRule doNotUseJUnit4 = noClasses()
.should().accessClassesThat()
.resideInAPackage("org.junit");
@ArchTest
@SuppressWarnings("unused")
public static final ArchRule dontUseImplSuffix = noClasses()
.should().haveSimpleNameEndingWith("Impl");
@ArchTest
@SuppressWarnings("unused")
public static final ArchRule contextPackageRule = classes()
@ -68,7 +74,7 @@ public class ArchitectureTest {
public static final ArchRule HsOfficePartnerPackageRule = classes()
.that().resideInAPackage("..hs.office.partner..")
.should().onlyBeAccessed().byClassesThat()
.resideInAnyPackage("..hs.office.partner..");
.resideInAnyPackage("..hs.office.partner..", "..hs.office.debitor..");
@ArchTest
@SuppressWarnings("unused")

View File

@ -4,9 +4,14 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.jpa.JpaObjectRetrievalFailureException;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.web.context.request.WebRequest;
import javax.persistence.EntityNotFoundException;
import java.util.NoSuchElementException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
@ -44,6 +49,75 @@ class RestResponseEntityExceptionHandlerUnitTest {
assertThat(errorResponse.getBody().getMessage()).isEqualTo("ERROR: [401] First Line");
}
@Test
void handleJpaObjectRetrievalFailureExceptionWithDisplayName() {
// given
final var givenException = new JpaObjectRetrievalFailureException(
new EntityNotFoundException(
"Unable to find net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity with id 12345-123454")
);
final var givenWebRequest = mock(WebRequest.class);
// when
final var errorResponse = exceptionHandler.handleJpaObjectRetrievalFailureException(givenException, givenWebRequest);
// then
assertThat(errorResponse.getStatusCodeValue()).isEqualTo(400);
assertThat(errorResponse.getBody().getMessage()).isEqualTo("Unable to find Partner with uuid 12345-123454");
}
@Test
void handleJpaObjectRetrievalFailureExceptionIfEntityClassCannotBeDetermined() {
// given
final var givenException = new JpaObjectRetrievalFailureException(
new EntityNotFoundException(
"Unable to find net.hostsharing.hsadminng.WhateverEntity with id 12345-123454")
);
final var givenWebRequest = mock(WebRequest.class);
// when
final var errorResponse = exceptionHandler.handleJpaObjectRetrievalFailureException(givenException, givenWebRequest);
// then
assertThat(errorResponse.getStatusCodeValue()).isEqualTo(400);
assertThat(errorResponse.getBody().getMessage()).isEqualTo(
"Unable to find net.hostsharing.hsadminng.WhateverEntity with id 12345-123454");
}
@Test
void handleJpaObjectRetrievalFailureExceptionIfPatternDoesNotMatch() {
// given
final var givenException = new JpaObjectRetrievalFailureException(
new EntityNotFoundException("whatever error message")
);
final var givenWebRequest = mock(WebRequest.class);
// when
final var errorResponse = exceptionHandler.handleJpaObjectRetrievalFailureException(givenException, givenWebRequest);
// then
assertThat(errorResponse.getStatusCodeValue()).isEqualTo(400);
assertThat(errorResponse.getBody().getMessage()).isEqualTo("whatever error message");
}
@Test
void handleJpaObjectRetrievalFailureExceptionWithEntityName() {
// given
final var givenException = new JpaObjectRetrievalFailureException(
new EntityNotFoundException("Unable to find "
+ NoDisplayNameEntity.class.getTypeName()
+ " with id 12345-123454")
);
final var givenWebRequest = mock(WebRequest.class);
// when
final var errorResponse = exceptionHandler.handleJpaObjectRetrievalFailureException(givenException, givenWebRequest);
// then
assertThat(errorResponse.getStatusCodeValue()).isEqualTo(400);
assertThat(errorResponse.getBody().getMessage()).isEqualTo("Unable to find NoDisplayNameEntity with uuid 12345-123454");
}
@Test
void jpaExceptionWithUnknownErrorCode() {
// given
@ -59,6 +133,20 @@ class RestResponseEntityExceptionHandlerUnitTest {
assertThat(errorResponse.getBody().getMessage()).isEqualTo("ERROR: [999] First Line");
}
@Test
void handleNoSuchElementException() {
// given
final var givenException = new NoSuchElementException("some error message");
final var givenWebRequest = mock(WebRequest.class);
// when
final var errorResponse = exceptionHandler.handleNoSuchElementException(givenException, givenWebRequest);
// then
assertThat(errorResponse.getStatusCodeValue()).isEqualTo(404);
assertThat(errorResponse.getBody().getMessage()).isEqualTo("some error message");
}
@Test
void handleOtherExceptionsWithoutErrorCode() {
// given
@ -87,4 +175,8 @@ class RestResponseEntityExceptionHandlerUnitTest {
assertThat(errorResponse.getBody().getMessage()).isEqualTo("ERROR: [418] First Line");
}
public static class NoDisplayNameEntity {
}
}