1
0

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

@@ -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