diff --git a/backend/src/main/java/backend/fullstack/alcohol/api/AgeVerificationController.java b/backend/src/main/java/backend/fullstack/alcohol/api/AgeVerificationController.java new file mode 100644 index 0000000..74c820d --- /dev/null +++ b/backend/src/main/java/backend/fullstack/alcohol/api/AgeVerificationController.java @@ -0,0 +1,73 @@ +package backend.fullstack.alcohol.api; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import backend.fullstack.alcohol.api.dto.AgeVerificationRequest; +import backend.fullstack.alcohol.api.dto.AgeVerificationResponse; +import backend.fullstack.alcohol.application.AgeVerificationService; +import backend.fullstack.config.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +/** + * REST endpoints for age verification logging under IK-Alkohol. + */ +@RestController +@RequestMapping("/api/alcohol/age-verifications") +@Tag(name = "Age Verifications", description = "Log and query age verification checks for alcohol service") +@SecurityRequirement(name = "Bearer Auth") +public class AgeVerificationController { + + private final AgeVerificationService verificationService; + + public AgeVerificationController(AgeVerificationService verificationService) { + this.verificationService = verificationService; + } + + @PostMapping + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')") + @Operation(summary = "Log a new age verification check") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "Verification logged"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Forbidden") + }) + public ResponseEntity> create( + @Valid @RequestBody AgeVerificationRequest request + ) { + return ResponseEntity.status(201) + .body(ApiResponse.success("Age verification logged", verificationService.create(request))); + } + + @GetMapping + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','SUPERVISOR')") + @Operation(summary = "List age verification logs with optional filters") + public ApiResponse> list( + @RequestParam(required = false) Long locationId, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to + ) { + return ApiResponse.success("Verifications fetched", verificationService.list(locationId, from, to)); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','SUPERVISOR')") + @Operation(summary = "Get age verification log by id") + public ApiResponse getById(@PathVariable Long id) { + return ApiResponse.success("Verification fetched", verificationService.getById(id)); + } +} diff --git a/backend/src/main/java/backend/fullstack/alcohol/api/AlcoholIncidentController.java b/backend/src/main/java/backend/fullstack/alcohol/api/AlcoholIncidentController.java new file mode 100644 index 0000000..d7e433d --- /dev/null +++ b/backend/src/main/java/backend/fullstack/alcohol/api/AlcoholIncidentController.java @@ -0,0 +1,99 @@ +package backend.fullstack.alcohol.api; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import backend.fullstack.alcohol.api.dto.AlcoholIncidentRequest; +import backend.fullstack.alcohol.api.dto.AlcoholIncidentResponse; +import backend.fullstack.alcohol.api.dto.ResolveIncidentRequest; +import backend.fullstack.alcohol.application.AlcoholIncidentService; +import backend.fullstack.alcohol.domain.IncidentStatus; +import backend.fullstack.alcohol.domain.IncidentType; +import backend.fullstack.config.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +/** + * REST endpoints for managing alcohol-related serving incidents. + */ +@RestController +@RequestMapping("/api/alcohol/incidents") +@Tag(name = "Alcohol Incidents", description = "Report and manage alcohol serving incidents") +@SecurityRequirement(name = "Bearer Auth") +public class AlcoholIncidentController { + + private final AlcoholIncidentService incidentService; + + public AlcoholIncidentController(AlcoholIncidentService incidentService) { + this.incidentService = incidentService; + } + + @PostMapping + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')") + @Operation(summary = "Report a new alcohol serving incident") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "Incident reported"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Forbidden") + }) + public ResponseEntity> create( + @Valid @RequestBody AlcoholIncidentRequest request + ) { + return ResponseEntity.status(201) + .body(ApiResponse.success("Incident reported", incidentService.create(request))); + } + + @GetMapping + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','SUPERVISOR')") + @Operation(summary = "List alcohol incidents with optional filters") + public ApiResponse> list( + @RequestParam(required = false) Long locationId, + @RequestParam(required = false) IncidentStatus status, + @RequestParam(required = false) IncidentType incidentType, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to + ) { + return ApiResponse.success("Incidents fetched", incidentService.list(locationId, status, incidentType, from, to)); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','SUPERVISOR')") + @Operation(summary = "Get alcohol incident by id") + public ApiResponse getById(@PathVariable Long id) { + return ApiResponse.success("Incident fetched", incidentService.getById(id)); + } + + @PatchMapping("/{id}/resolve") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','SUPERVISOR')") + @Operation(summary = "Resolve an alcohol incident with corrective action") + public ApiResponse resolve( + @PathVariable Long id, + @Valid @RequestBody ResolveIncidentRequest request + ) { + return ApiResponse.success("Incident resolved", incidentService.resolve(id, request)); + } + + @PatchMapping("/{id}/status") + @PreAuthorize("hasAnyRole('ADMIN','SUPERVISOR')") + @Operation(summary = "Update incident status (e.g. close an incident)") + public ApiResponse updateStatus( + @PathVariable Long id, + @RequestParam IncidentStatus status + ) { + return ApiResponse.success("Incident status updated", incidentService.updateStatus(id, status)); + } +} diff --git a/backend/src/main/java/backend/fullstack/alcohol/api/AlcoholLicenseController.java b/backend/src/main/java/backend/fullstack/alcohol/api/AlcoholLicenseController.java new file mode 100644 index 0000000..8472cb5 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/alcohol/api/AlcoholLicenseController.java @@ -0,0 +1,86 @@ +package backend.fullstack.alcohol.api; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +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; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import backend.fullstack.alcohol.api.dto.AlcoholLicenseRequest; +import backend.fullstack.alcohol.api.dto.AlcoholLicenseResponse; +import backend.fullstack.alcohol.application.AlcoholLicenseService; +import backend.fullstack.config.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +/** + * REST endpoints for managing alcohol licenses (bevillinger). + */ +@RestController +@RequestMapping("/api/alcohol/licenses") +@Tag(name = "Alcohol Licenses", description = "Manage organization alcohol licenses (bevillinger)") +@SecurityRequirement(name = "Bearer Auth") +public class AlcoholLicenseController { + + private final AlcoholLicenseService licenseService; + + public AlcoholLicenseController(AlcoholLicenseService licenseService) { + this.licenseService = licenseService; + } + + @GetMapping + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','SUPERVISOR')") + @Operation(summary = "List all alcohol licenses for the organization") + public ApiResponse> list() { + return ApiResponse.success("Licenses fetched", licenseService.listLicenses()); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','SUPERVISOR')") + @Operation(summary = "Get alcohol license by id") + public ApiResponse getById(@PathVariable Long id) { + return ApiResponse.success("License fetched", licenseService.getById(id)); + } + + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Register a new alcohol license") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "License created"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Forbidden") + }) + public ResponseEntity> create( + @Valid @RequestBody AlcoholLicenseRequest request + ) { + return ResponseEntity.status(201) + .body(ApiResponse.success("License created", licenseService.create(request))); + } + + @PutMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Update an alcohol license") + public ApiResponse update( + @PathVariable Long id, + @Valid @RequestBody AlcoholLicenseRequest request + ) { + return ApiResponse.success("License updated", licenseService.update(id, request)); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Delete an alcohol license") + public ApiResponse delete(@PathVariable Long id) { + licenseService.delete(id); + return ApiResponse.success("License deleted", null); + } +} diff --git a/backend/src/main/java/backend/fullstack/alcohol/api/dto/AgeVerificationRequest.java b/backend/src/main/java/backend/fullstack/alcohol/api/dto/AgeVerificationRequest.java new file mode 100644 index 0000000..37ee051 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/alcohol/api/dto/AgeVerificationRequest.java @@ -0,0 +1,38 @@ +package backend.fullstack.alcohol.api.dto; + +import java.time.LocalDateTime; + +import backend.fullstack.alcohol.domain.VerificationMethod; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Schema(description = "Request to log an age verification check") +public class AgeVerificationRequest { + + @NotNull(message = "Location id is required") + @Schema(description = "Location where verification was performed", example = "1") + private Long locationId; + + @NotNull(message = "Verification method is required") + @Schema(description = "How the age was verified", example = "ID_CHECKED") + private VerificationMethod verificationMethod; + + @Schema(description = "Whether the guest appeared to be underage", example = "true") + private boolean guestAppearedUnderage = true; + + @Schema(description = "Whether the ID was valid (null if no ID checked)") + private Boolean idWasValid; + + @Schema(description = "Whether service was refused", example = "false") + private boolean wasRefused = false; + + @Schema(description = "Additional notes about the verification") + private String note; + + @Schema(description = "When the verification occurred (defaults to now)") + private LocalDateTime verifiedAt; +} diff --git a/backend/src/main/java/backend/fullstack/alcohol/api/dto/AgeVerificationResponse.java b/backend/src/main/java/backend/fullstack/alcohol/api/dto/AgeVerificationResponse.java new file mode 100644 index 0000000..a083978 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/alcohol/api/dto/AgeVerificationResponse.java @@ -0,0 +1,53 @@ +package backend.fullstack.alcohol.api.dto; + +import java.time.LocalDateTime; + +import backend.fullstack.alcohol.domain.VerificationMethod; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "Age verification log response") +public class AgeVerificationResponse { + + @Schema(example = "1") + private Long id; + + @Schema(example = "100") + private Long organizationId; + + @Schema(example = "1") + private Long locationId; + + @Schema(example = "Bar") + private String locationName; + + @Schema(example = "5") + private Long verifiedByUserId; + + @Schema(example = "Ola Nordmann") + private String verifiedByName; + + @Schema(example = "ID_CHECKED") + private VerificationMethod verificationMethod; + + private boolean guestAppearedUnderage; + + private Boolean idWasValid; + + private boolean wasRefused; + + private String note; + + private LocalDateTime verifiedAt; + + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/backend/fullstack/alcohol/api/dto/AlcoholIncidentRequest.java b/backend/src/main/java/backend/fullstack/alcohol/api/dto/AlcoholIncidentRequest.java new file mode 100644 index 0000000..14c2719 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/alcohol/api/dto/AlcoholIncidentRequest.java @@ -0,0 +1,36 @@ +package backend.fullstack.alcohol.api.dto; + +import java.time.LocalDateTime; + +import backend.fullstack.alcohol.domain.IncidentSeverity; +import backend.fullstack.alcohol.domain.IncidentType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Schema(description = "Request to report an alcohol serving incident") +public class AlcoholIncidentRequest { + + @NotNull(message = "Location id is required") + @Schema(description = "Location where the incident occurred", example = "1") + private Long locationId; + + @NotNull(message = "Incident type is required") + @Schema(description = "Type of incident", example = "REFUSED_SERVICE") + private IncidentType incidentType; + + @NotNull(message = "Severity is required") + @Schema(description = "Severity of the incident", example = "MEDIUM") + private IncidentSeverity severity; + + @NotBlank(message = "Description is required") + @Schema(description = "Description of what happened", example = "Guest appeared intoxicated and was refused further service") + private String description; + + @Schema(description = "When the incident occurred (defaults to now)") + private LocalDateTime occurredAt; +} diff --git a/backend/src/main/java/backend/fullstack/alcohol/api/dto/AlcoholIncidentResponse.java b/backend/src/main/java/backend/fullstack/alcohol/api/dto/AlcoholIncidentResponse.java new file mode 100644 index 0000000..1e2ff59 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/alcohol/api/dto/AlcoholIncidentResponse.java @@ -0,0 +1,67 @@ +package backend.fullstack.alcohol.api.dto; + +import java.time.LocalDateTime; + +import backend.fullstack.alcohol.domain.IncidentSeverity; +import backend.fullstack.alcohol.domain.IncidentStatus; +import backend.fullstack.alcohol.domain.IncidentType; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "Alcohol serving incident response") +public class AlcoholIncidentResponse { + + @Schema(example = "1") + private Long id; + + @Schema(example = "100") + private Long organizationId; + + @Schema(example = "1") + private Long locationId; + + @Schema(example = "Bar") + private String locationName; + + @Schema(example = "5") + private Long reportedByUserId; + + @Schema(example = "Ola Nordmann") + private String reportedByName; + + @Schema(example = "8") + private Long resolvedByUserId; + + @Schema(example = "Kari Nordmann") + private String resolvedByName; + + @Schema(example = "REFUSED_SERVICE") + private IncidentType incidentType; + + @Schema(example = "MEDIUM") + private IncidentSeverity severity; + + @Schema(example = "OPEN") + private IncidentStatus status; + + private String description; + + private String correctiveAction; + + private LocalDateTime occurredAt; + + private LocalDateTime resolvedAt; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/backend/fullstack/alcohol/api/dto/AlcoholLicenseRequest.java b/backend/src/main/java/backend/fullstack/alcohol/api/dto/AlcoholLicenseRequest.java new file mode 100644 index 0000000..abad894 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/alcohol/api/dto/AlcoholLicenseRequest.java @@ -0,0 +1,34 @@ +package backend.fullstack.alcohol.api.dto; + +import java.time.LocalDate; + +import backend.fullstack.alcohol.domain.LicenseType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Schema(description = "Request to create or update an alcohol license") +public class AlcoholLicenseRequest { + + @NotNull(message = "License type is required") + @Schema(description = "Type of alcohol license", example = "FULL_LICENSE") + private LicenseType licenseType; + + @Schema(description = "License number from the municipality", example = "OSL-2026-1234") + private String licenseNumber; + + @Schema(description = "Date the license was issued", example = "2026-01-15") + private LocalDate issuedAt; + + @Schema(description = "Date the license expires", example = "2030-01-15") + private LocalDate expiresAt; + + @Schema(description = "Authority that issued the license", example = "Oslo kommune") + private String issuingAuthority; + + @Schema(description = "Additional notes about the license") + private String notes; +} diff --git a/backend/src/main/java/backend/fullstack/alcohol/api/dto/AlcoholLicenseResponse.java b/backend/src/main/java/backend/fullstack/alcohol/api/dto/AlcoholLicenseResponse.java new file mode 100644 index 0000000..c74dd84 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/alcohol/api/dto/AlcoholLicenseResponse.java @@ -0,0 +1,51 @@ +package backend.fullstack.alcohol.api.dto; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import backend.fullstack.alcohol.domain.LicenseType; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "Alcohol license response") +public class AlcoholLicenseResponse { + + @Schema(example = "1") + private Long id; + + @Schema(example = "100") + private Long organizationId; + + @Schema(example = "FULL_LICENSE") + private LicenseType licenseType; + + @Schema(example = "OSL-2026-1234") + private String licenseNumber; + + @Schema(example = "2026-01-15") + private LocalDate issuedAt; + + @Schema(example = "2030-01-15") + private LocalDate expiresAt; + + @Schema(example = "Oslo kommune") + private String issuingAuthority; + + private String notes; + + @Schema(description = "Whether the license has expired") + private boolean expired; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/backend/fullstack/alcohol/api/dto/ResolveIncidentRequest.java b/backend/src/main/java/backend/fullstack/alcohol/api/dto/ResolveIncidentRequest.java new file mode 100644 index 0000000..082d1e4 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/alcohol/api/dto/ResolveIncidentRequest.java @@ -0,0 +1,17 @@ +package backend.fullstack.alcohol.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Schema(description = "Request to resolve an alcohol serving incident") +public class ResolveIncidentRequest { + + @NotBlank(message = "Corrective action is required") + @Schema(description = "Corrective action taken to resolve the incident", + example = "Staff member received additional training on responsible serving") + private String correctiveAction; +} diff --git a/backend/src/main/java/backend/fullstack/alcohol/application/AgeVerificationService.java b/backend/src/main/java/backend/fullstack/alcohol/application/AgeVerificationService.java new file mode 100644 index 0000000..0fa890d --- /dev/null +++ b/backend/src/main/java/backend/fullstack/alcohol/application/AgeVerificationService.java @@ -0,0 +1,116 @@ +package backend.fullstack.alcohol.application; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import backend.fullstack.alcohol.api.dto.AgeVerificationRequest; +import backend.fullstack.alcohol.api.dto.AgeVerificationResponse; +import backend.fullstack.alcohol.domain.AgeVerificationLog; +import backend.fullstack.alcohol.infrastructure.AgeVerificationRepository; +import backend.fullstack.exceptions.ResourceNotFoundException; +import backend.fullstack.location.Location; +import backend.fullstack.location.LocationRepository; +import backend.fullstack.organization.Organization; +import backend.fullstack.organization.OrganizationRepository; +import backend.fullstack.user.AccessContextService; +import backend.fullstack.user.User; + +/** + * Service for logging and querying age verification checks + * as required by Norwegian alcohol law (Alkoholloven). + */ +@Service +public class AgeVerificationService { + + private final AgeVerificationRepository verificationRepository; + private final OrganizationRepository organizationRepository; + private final LocationRepository locationRepository; + private final AccessContextService accessContext; + + public AgeVerificationService( + AgeVerificationRepository verificationRepository, + OrganizationRepository organizationRepository, + LocationRepository locationRepository, + AccessContextService accessContext + ) { + this.verificationRepository = verificationRepository; + this.organizationRepository = organizationRepository; + this.locationRepository = locationRepository; + this.accessContext = accessContext; + } + + @Transactional + public AgeVerificationResponse create(AgeVerificationRequest request) { + Long orgId = accessContext.getCurrentOrganizationId(); + User currentUser = accessContext.getCurrentUser(); + + accessContext.assertCanAccess(request.getLocationId()); + + Organization organization = organizationRepository.findById(orgId) + .orElseThrow(() -> new ResourceNotFoundException("Organization not found")); + Location location = findLocationInOrganization(request.getLocationId(), orgId); + + AgeVerificationLog log = AgeVerificationLog.builder() + .organization(organization) + .location(location) + .verifiedBy(currentUser) + .verificationMethod(request.getVerificationMethod()) + .guestAppearedUnderage(request.isGuestAppearedUnderage()) + .idWasValid(request.getIdWasValid()) + .wasRefused(request.isWasRefused()) + .note(request.getNote()) + .verifiedAt(request.getVerifiedAt() != null ? request.getVerifiedAt() : LocalDateTime.now()) + .build(); + + return toResponse(verificationRepository.save(log)); + } + + @Transactional(readOnly = true) + public List list(Long locationId, LocalDateTime from, LocalDateTime to) { + Long orgId = accessContext.getCurrentOrganizationId(); + return verificationRepository.findFiltered(orgId, locationId, from, to) + .stream() + .map(this::toResponse) + .toList(); + } + + @Transactional(readOnly = true) + public AgeVerificationResponse getById(Long id) { + return toResponse(findInCurrentOrganization(id)); + } + + private AgeVerificationLog findInCurrentOrganization(Long id) { + Long orgId = accessContext.getCurrentOrganizationId(); + return verificationRepository.findByIdAndOrganization_Id(id, orgId) + .orElseThrow(() -> new ResourceNotFoundException("Age verification log not found")); + } + + private Location findLocationInOrganization(Long locationId, Long orgId) { + return locationRepository.findById(locationId) + .filter(loc -> orgId.equals(loc.getOrganizationId())) + .orElseThrow(() -> new ResourceNotFoundException("Location not found")); + } + + private AgeVerificationResponse toResponse(AgeVerificationLog log) { + User verifier = log.getVerifiedBy(); + Location location = log.getLocation(); + return AgeVerificationResponse.builder() + .id(log.getId()) + .organizationId(log.getOrganizationId()) + .locationId(log.getLocationId()) + .locationName(location != null ? location.getName() : null) + .verifiedByUserId(log.getVerifiedByUserId()) + .verifiedByName(verifier != null ? verifier.getFirstName() + " " + verifier.getLastName() : null) + .verificationMethod(log.getVerificationMethod()) + .guestAppearedUnderage(log.isGuestAppearedUnderage()) + .idWasValid(log.getIdWasValid()) + .wasRefused(log.isWasRefused()) + .note(log.getNote()) + .verifiedAt(log.getVerifiedAt()) + .createdAt(log.getCreatedAt()) + .build(); + } +} diff --git a/backend/src/main/java/backend/fullstack/alcohol/application/AlcoholIncidentService.java b/backend/src/main/java/backend/fullstack/alcohol/application/AlcoholIncidentService.java new file mode 100644 index 0000000..eed53b1 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/alcohol/application/AlcoholIncidentService.java @@ -0,0 +1,160 @@ +package backend.fullstack.alcohol.application; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import backend.fullstack.alcohol.api.dto.AlcoholIncidentRequest; +import backend.fullstack.alcohol.api.dto.AlcoholIncidentResponse; +import backend.fullstack.alcohol.api.dto.ResolveIncidentRequest; +import backend.fullstack.alcohol.domain.AlcoholServingIncident; +import backend.fullstack.alcohol.domain.IncidentStatus; +import backend.fullstack.alcohol.domain.IncidentType; +import backend.fullstack.alcohol.infrastructure.AlcoholIncidentRepository; +import backend.fullstack.exceptions.ResourceNotFoundException; +import backend.fullstack.location.Location; +import backend.fullstack.location.LocationRepository; +import backend.fullstack.organization.Organization; +import backend.fullstack.organization.OrganizationRepository; +import backend.fullstack.user.AccessContextService; +import backend.fullstack.user.User; + +/** + * Service for managing alcohol-related serving incidents under IK-Alkohol compliance. + */ +@Service +public class AlcoholIncidentService { + + private final AlcoholIncidentRepository incidentRepository; + private final OrganizationRepository organizationRepository; + private final LocationRepository locationRepository; + private final AccessContextService accessContext; + + public AlcoholIncidentService( + AlcoholIncidentRepository incidentRepository, + OrganizationRepository organizationRepository, + LocationRepository locationRepository, + AccessContextService accessContext + ) { + this.incidentRepository = incidentRepository; + this.organizationRepository = organizationRepository; + this.locationRepository = locationRepository; + this.accessContext = accessContext; + } + + @Transactional + public AlcoholIncidentResponse create(AlcoholIncidentRequest request) { + Long orgId = accessContext.getCurrentOrganizationId(); + User currentUser = accessContext.getCurrentUser(); + + accessContext.assertCanAccess(request.getLocationId()); + + Organization organization = organizationRepository.findById(orgId) + .orElseThrow(() -> new ResourceNotFoundException("Organization not found")); + Location location = findLocationInOrganization(request.getLocationId(), orgId); + + AlcoholServingIncident incident = AlcoholServingIncident.builder() + .organization(organization) + .location(location) + .reportedBy(currentUser) + .incidentType(request.getIncidentType()) + .severity(request.getSeverity()) + .status(IncidentStatus.OPEN) + .description(request.getDescription()) + .occurredAt(request.getOccurredAt() != null ? request.getOccurredAt() : LocalDateTime.now()) + .build(); + + return toResponse(incidentRepository.save(incident)); + } + + @Transactional(readOnly = true) + public List list( + Long locationId, + IncidentStatus status, + IncidentType incidentType, + LocalDateTime from, + LocalDateTime to + ) { + Long orgId = accessContext.getCurrentOrganizationId(); + return incidentRepository.findFiltered(orgId, locationId, status, incidentType, from, to) + .stream() + .map(this::toResponse) + .toList(); + } + + @Transactional(readOnly = true) + public AlcoholIncidentResponse getById(Long id) { + return toResponse(findInCurrentOrganization(id)); + } + + @Transactional + public AlcoholIncidentResponse resolve(Long id, ResolveIncidentRequest request) { + AlcoholServingIncident incident = findInCurrentOrganization(id); + User currentUser = accessContext.getCurrentUser(); + + incident.setStatus(IncidentStatus.RESOLVED); + incident.setCorrectiveAction(request.getCorrectiveAction()); + incident.setResolvedBy(currentUser); + incident.setResolvedAt(LocalDateTime.now()); + + return toResponse(incidentRepository.save(incident)); + } + + @Transactional + public AlcoholIncidentResponse updateStatus(Long id, IncidentStatus newStatus) { + AlcoholServingIncident incident = findInCurrentOrganization(id); + incident.setStatus(newStatus); + + if (newStatus == IncidentStatus.CLOSED || newStatus == IncidentStatus.RESOLVED) { + User currentUser = accessContext.getCurrentUser(); + if (incident.getResolvedBy() == null) { + incident.setResolvedBy(currentUser); + } + if (incident.getResolvedAt() == null) { + incident.setResolvedAt(LocalDateTime.now()); + } + } + + return toResponse(incidentRepository.save(incident)); + } + + private AlcoholServingIncident findInCurrentOrganization(Long id) { + Long orgId = accessContext.getCurrentOrganizationId(); + return incidentRepository.findByIdAndOrganization_Id(id, orgId) + .orElseThrow(() -> new ResourceNotFoundException("Alcohol incident not found")); + } + + private Location findLocationInOrganization(Long locationId, Long orgId) { + return locationRepository.findById(locationId) + .filter(loc -> orgId.equals(loc.getOrganizationId())) + .orElseThrow(() -> new ResourceNotFoundException("Location not found")); + } + + private AlcoholIncidentResponse toResponse(AlcoholServingIncident incident) { + User reporter = incident.getReportedBy(); + User resolver = incident.getResolvedBy(); + Location location = incident.getLocation(); + + return AlcoholIncidentResponse.builder() + .id(incident.getId()) + .organizationId(incident.getOrganizationId()) + .locationId(incident.getLocationId()) + .locationName(location != null ? location.getName() : null) + .reportedByUserId(reporter != null ? reporter.getId() : null) + .reportedByName(reporter != null ? reporter.getFirstName() + " " + reporter.getLastName() : null) + .resolvedByUserId(resolver != null ? resolver.getId() : null) + .resolvedByName(resolver != null ? resolver.getFirstName() + " " + resolver.getLastName() : null) + .incidentType(incident.getIncidentType()) + .severity(incident.getSeverity()) + .status(incident.getStatus()) + .description(incident.getDescription()) + .correctiveAction(incident.getCorrectiveAction()) + .occurredAt(incident.getOccurredAt()) + .resolvedAt(incident.getResolvedAt()) + .createdAt(incident.getCreatedAt()) + .updatedAt(incident.getUpdatedAt()) + .build(); + } +} diff --git a/backend/src/main/java/backend/fullstack/alcohol/application/AlcoholLicenseService.java b/backend/src/main/java/backend/fullstack/alcohol/application/AlcoholLicenseService.java new file mode 100644 index 0000000..09dcb7a --- /dev/null +++ b/backend/src/main/java/backend/fullstack/alcohol/application/AlcoholLicenseService.java @@ -0,0 +1,111 @@ +package backend.fullstack.alcohol.application; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import backend.fullstack.alcohol.api.dto.AlcoholLicenseRequest; +import backend.fullstack.alcohol.api.dto.AlcoholLicenseResponse; +import backend.fullstack.alcohol.domain.AlcoholLicense; +import backend.fullstack.alcohol.infrastructure.AlcoholLicenseRepository; +import backend.fullstack.exceptions.ResourceNotFoundException; +import backend.fullstack.organization.Organization; +import backend.fullstack.organization.OrganizationRepository; +import backend.fullstack.user.AccessContextService; + +/** + * Service for managing alcohol licenses (bevillinger) for an organization. + */ +@Service +public class AlcoholLicenseService { + + private final AlcoholLicenseRepository licenseRepository; + private final OrganizationRepository organizationRepository; + private final AccessContextService accessContext; + + public AlcoholLicenseService( + AlcoholLicenseRepository licenseRepository, + OrganizationRepository organizationRepository, + AccessContextService accessContext + ) { + this.licenseRepository = licenseRepository; + this.organizationRepository = organizationRepository; + this.accessContext = accessContext; + } + + @Transactional(readOnly = true) + public List listLicenses() { + Long orgId = accessContext.getCurrentOrganizationId(); + return licenseRepository.findByOrganization_IdOrderByExpiresAtDesc(orgId) + .stream() + .map(this::toResponse) + .toList(); + } + + @Transactional(readOnly = true) + public AlcoholLicenseResponse getById(Long id) { + return toResponse(findInCurrentOrganization(id)); + } + + @Transactional + public AlcoholLicenseResponse create(AlcoholLicenseRequest request) { + Long orgId = accessContext.getCurrentOrganizationId(); + Organization organization = organizationRepository.findById(orgId) + .orElseThrow(() -> new ResourceNotFoundException("Organization not found")); + + AlcoholLicense license = AlcoholLicense.builder() + .organization(organization) + .licenseType(request.getLicenseType()) + .licenseNumber(request.getLicenseNumber()) + .issuedAt(request.getIssuedAt()) + .expiresAt(request.getExpiresAt()) + .issuingAuthority(request.getIssuingAuthority()) + .notes(request.getNotes()) + .build(); + + return toResponse(licenseRepository.save(license)); + } + + @Transactional + public AlcoholLicenseResponse update(Long id, AlcoholLicenseRequest request) { + AlcoholLicense license = findInCurrentOrganization(id); + + license.setLicenseType(request.getLicenseType()); + license.setLicenseNumber(request.getLicenseNumber()); + license.setIssuedAt(request.getIssuedAt()); + license.setExpiresAt(request.getExpiresAt()); + license.setIssuingAuthority(request.getIssuingAuthority()); + license.setNotes(request.getNotes()); + + return toResponse(licenseRepository.save(license)); + } + + @Transactional + public void delete(Long id) { + AlcoholLicense license = findInCurrentOrganization(id); + licenseRepository.delete(license); + } + + private AlcoholLicense findInCurrentOrganization(Long id) { + Long orgId = accessContext.getCurrentOrganizationId(); + return licenseRepository.findByIdAndOrganization_Id(id, orgId) + .orElseThrow(() -> new ResourceNotFoundException("Alcohol license not found")); + } + + private AlcoholLicenseResponse toResponse(AlcoholLicense license) { + return AlcoholLicenseResponse.builder() + .id(license.getId()) + .organizationId(license.getOrganizationId()) + .licenseType(license.getLicenseType()) + .licenseNumber(license.getLicenseNumber()) + .issuedAt(license.getIssuedAt()) + .expiresAt(license.getExpiresAt()) + .issuingAuthority(license.getIssuingAuthority()) + .notes(license.getNotes()) + .expired(license.isExpired()) + .createdAt(license.getCreatedAt()) + .updatedAt(license.getUpdatedAt()) + .build(); + } +} diff --git a/backend/src/main/java/backend/fullstack/alcohol/domain/AgeVerificationLog.java b/backend/src/main/java/backend/fullstack/alcohol/domain/AgeVerificationLog.java new file mode 100644 index 0000000..10b9ad0 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/alcohol/domain/AgeVerificationLog.java @@ -0,0 +1,91 @@ +package backend.fullstack.alcohol.domain; + +import java.time.LocalDateTime; + +import backend.fullstack.location.Location; +import backend.fullstack.organization.Organization; +import backend.fullstack.user.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; + +/** + * Records an age verification check performed during alcohol service. + * Required by Norwegian alcohol law (Alkoholloven) for compliance documentation. + */ +@Entity +@Table(name = "age_verification_logs") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class AgeVerificationLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "organization_id", nullable = false) + private Organization organization; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "location_id", nullable = false) + private Location location; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "verified_by_user_id", nullable = false) + private User verifiedBy; + + @Enumerated(EnumType.STRING) + @Column(name = "verification_method", nullable = false, length = 30) + private VerificationMethod verificationMethod; + + @Column(name = "guest_appeared_underage", nullable = false) + @Builder.Default + private boolean guestAppearedUnderage = true; + + @Column(name = "id_was_valid") + private Boolean idWasValid; + + @Column(name = "was_refused", nullable = false) + @Builder.Default + private boolean wasRefused = false; + + @Column(name = "note", columnDefinition = "TEXT") + private String note; + + @Column(name = "verified_at", nullable = false) + private LocalDateTime verifiedAt; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + public Long getOrganizationId() { + return organization != null ? organization.getId() : null; + } + + public Long getLocationId() { + return location != null ? location.getId() : null; + } + + public Long getVerifiedByUserId() { + return verifiedBy != null ? verifiedBy.getId() : null; + } +} diff --git a/backend/src/main/java/backend/fullstack/alcohol/domain/AlcoholLicense.java b/backend/src/main/java/backend/fullstack/alcohol/domain/AlcoholLicense.java new file mode 100644 index 0000000..0e00b86 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/alcohol/domain/AlcoholLicense.java @@ -0,0 +1,80 @@ +package backend.fullstack.alcohol.domain; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import backend.fullstack.organization.Organization; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +/** + * Represents an alcohol license (bevilling) held by an organization. + */ +@Entity +@Table(name = "alcohol_licenses") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class AlcoholLicense { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "organization_id", nullable = false) + private Organization organization; + + @Enumerated(EnumType.STRING) + @Column(name = "license_type", nullable = false, length = 30) + private LicenseType licenseType; + + @Column(name = "license_number", length = 100) + private String licenseNumber; + + @Column(name = "issued_at") + private LocalDate issuedAt; + + @Column(name = "expires_at") + private LocalDate expiresAt; + + @Column(name = "issuing_authority") + private String issuingAuthority; + + @Column(name = "notes", columnDefinition = "TEXT") + private String notes; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public Long getOrganizationId() { + return organization != null ? organization.getId() : null; + } + + public boolean isExpired() { + return expiresAt != null && expiresAt.isBefore(LocalDate.now()); + } +} diff --git a/backend/src/main/java/backend/fullstack/alcohol/domain/AlcoholServingIncident.java b/backend/src/main/java/backend/fullstack/alcohol/domain/AlcoholServingIncident.java new file mode 100644 index 0000000..71ada82 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/alcohol/domain/AlcoholServingIncident.java @@ -0,0 +1,100 @@ +package backend.fullstack.alcohol.domain; + +import java.time.LocalDateTime; + +import backend.fullstack.location.Location; +import backend.fullstack.organization.Organization; +import backend.fullstack.user.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +/** + * Represents an alcohol-related serving incident that must be documented + * for regulatory compliance under IK-Alkohol. + */ +@Entity +@Table(name = "alcohol_serving_incidents") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class AlcoholServingIncident { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "organization_id", nullable = false) + private Organization organization; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "location_id", nullable = false) + private Location location; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "reported_by_user_id", nullable = false) + private User reportedBy; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "resolved_by_user_id") + private User resolvedBy; + + @Enumerated(EnumType.STRING) + @Column(name = "incident_type", nullable = false, length = 40) + private IncidentType incidentType; + + @Enumerated(EnumType.STRING) + @Column(name = "severity", nullable = false, length = 20) + private IncidentSeverity severity; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + @Builder.Default + private IncidentStatus status = IncidentStatus.OPEN; + + @Column(name = "description", nullable = false, columnDefinition = "TEXT") + private String description; + + @Column(name = "corrective_action", columnDefinition = "TEXT") + private String correctiveAction; + + @Column(name = "occurred_at", nullable = false) + private LocalDateTime occurredAt; + + @Column(name = "resolved_at") + private LocalDateTime resolvedAt; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public Long getOrganizationId() { + return organization != null ? organization.getId() : null; + } + + public Long getLocationId() { + return location != null ? location.getId() : null; + } +} diff --git a/backend/src/main/java/backend/fullstack/alcohol/domain/IncidentSeverity.java b/backend/src/main/java/backend/fullstack/alcohol/domain/IncidentSeverity.java new file mode 100644 index 0000000..26a9f6a --- /dev/null +++ b/backend/src/main/java/backend/fullstack/alcohol/domain/IncidentSeverity.java @@ -0,0 +1,11 @@ +package backend.fullstack.alcohol.domain; + +/** + * Severity classification for alcohol-related incidents. + */ +public enum IncidentSeverity { + LOW, + MEDIUM, + HIGH, + CRITICAL +} diff --git a/backend/src/main/java/backend/fullstack/alcohol/domain/IncidentStatus.java b/backend/src/main/java/backend/fullstack/alcohol/domain/IncidentStatus.java new file mode 100644 index 0000000..a0b53b6 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/alcohol/domain/IncidentStatus.java @@ -0,0 +1,11 @@ +package backend.fullstack.alcohol.domain; + +/** + * Lifecycle status of an alcohol-related incident. + */ +public enum IncidentStatus { + OPEN, + UNDER_REVIEW, + RESOLVED, + CLOSED +} diff --git a/backend/src/main/java/backend/fullstack/alcohol/domain/IncidentType.java b/backend/src/main/java/backend/fullstack/alcohol/domain/IncidentType.java new file mode 100644 index 0000000..940c8ab --- /dev/null +++ b/backend/src/main/java/backend/fullstack/alcohol/domain/IncidentType.java @@ -0,0 +1,13 @@ +package backend.fullstack.alcohol.domain; + +/** + * Types of alcohol-related serving incidents. + */ +public enum IncidentType { + REFUSED_SERVICE, + INTOXICATED_PERSON, + UNDERAGE_ATTEMPT, + OVER_SERVING, + DISTURBANCE, + OTHER +} diff --git a/backend/src/main/java/backend/fullstack/alcohol/domain/LicenseType.java b/backend/src/main/java/backend/fullstack/alcohol/domain/LicenseType.java new file mode 100644 index 0000000..4254750 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/alcohol/domain/LicenseType.java @@ -0,0 +1,10 @@ +package backend.fullstack.alcohol.domain; + +/** + * Types of alcohol licenses (bevillinger) as defined by Norwegian alcohol law. + */ +public enum LicenseType { + BEER_WINE, + FULL_LICENSE, + CATERING +} diff --git a/backend/src/main/java/backend/fullstack/alcohol/domain/VerificationMethod.java b/backend/src/main/java/backend/fullstack/alcohol/domain/VerificationMethod.java new file mode 100644 index 0000000..0e95fff --- /dev/null +++ b/backend/src/main/java/backend/fullstack/alcohol/domain/VerificationMethod.java @@ -0,0 +1,12 @@ +package backend.fullstack.alcohol.domain; + +/** + * Method used to verify a guest's age during alcohol service. + */ +public enum VerificationMethod { + ID_CHECKED, + PASSPORT_CHECKED, + DRIVING_LICENSE_CHECKED, + KNOWN_REGULAR, + VISUALLY_OVER_AGE +} diff --git a/backend/src/main/java/backend/fullstack/alcohol/infrastructure/AgeVerificationRepository.java b/backend/src/main/java/backend/fullstack/alcohol/infrastructure/AgeVerificationRepository.java new file mode 100644 index 0000000..3d60471 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/alcohol/infrastructure/AgeVerificationRepository.java @@ -0,0 +1,45 @@ +package backend.fullstack.alcohol.infrastructure; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import backend.fullstack.alcohol.domain.AgeVerificationLog; + +public interface AgeVerificationRepository extends JpaRepository { + + Optional findByIdAndOrganization_Id(Long id, Long organizationId); + + @Query(""" + SELECT a + FROM AgeVerificationLog a + WHERE a.organization.id = :organizationId + AND (:locationId IS NULL OR a.location.id = :locationId) + AND (:from IS NULL OR a.verifiedAt >= :from) + AND (:to IS NULL OR a.verifiedAt <= :to) + ORDER BY a.verifiedAt DESC, a.id DESC + """) + List findFiltered( + @Param("organizationId") Long organizationId, + @Param("locationId") Long locationId, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to + ); + + @Query(""" + SELECT COUNT(a) + FROM AgeVerificationLog a + WHERE a.organization.id = :organizationId + AND a.verifiedAt >= :from + AND a.verifiedAt <= :to + """) + long countByOrganizationAndPeriod( + @Param("organizationId") Long organizationId, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to + ); +} diff --git a/backend/src/main/java/backend/fullstack/alcohol/infrastructure/AlcoholIncidentRepository.java b/backend/src/main/java/backend/fullstack/alcohol/infrastructure/AlcoholIncidentRepository.java new file mode 100644 index 0000000..fe55f1f --- /dev/null +++ b/backend/src/main/java/backend/fullstack/alcohol/infrastructure/AlcoholIncidentRepository.java @@ -0,0 +1,40 @@ +package backend.fullstack.alcohol.infrastructure; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import backend.fullstack.alcohol.domain.AlcoholServingIncident; +import backend.fullstack.alcohol.domain.IncidentStatus; +import backend.fullstack.alcohol.domain.IncidentType; + +public interface AlcoholIncidentRepository extends JpaRepository { + + Optional findByIdAndOrganization_Id(Long id, Long organizationId); + + @Query(""" + SELECT i + FROM AlcoholServingIncident i + WHERE i.organization.id = :organizationId + AND (:locationId IS NULL OR i.location.id = :locationId) + AND (:status IS NULL OR i.status = :status) + AND (:incidentType IS NULL OR i.incidentType = :incidentType) + AND (:from IS NULL OR i.occurredAt >= :from) + AND (:to IS NULL OR i.occurredAt <= :to) + ORDER BY i.occurredAt DESC, i.id DESC + """) + List findFiltered( + @Param("organizationId") Long organizationId, + @Param("locationId") Long locationId, + @Param("status") IncidentStatus status, + @Param("incidentType") IncidentType incidentType, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to + ); + + long countByOrganization_IdAndStatus(Long organizationId, IncidentStatus status); +} diff --git a/backend/src/main/java/backend/fullstack/alcohol/infrastructure/AlcoholLicenseRepository.java b/backend/src/main/java/backend/fullstack/alcohol/infrastructure/AlcoholLicenseRepository.java new file mode 100644 index 0000000..e1b5ffa --- /dev/null +++ b/backend/src/main/java/backend/fullstack/alcohol/infrastructure/AlcoholLicenseRepository.java @@ -0,0 +1,15 @@ +package backend.fullstack.alcohol.infrastructure; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import backend.fullstack.alcohol.domain.AlcoholLicense; + +public interface AlcoholLicenseRepository extends JpaRepository { + + List findByOrganization_IdOrderByExpiresAtDesc(Long organizationId); + + Optional findByIdAndOrganization_Id(Long id, Long organizationId); +} diff --git a/backend/src/main/java/backend/fullstack/permission/PermissionBootstrapSeeder.java b/backend/src/main/java/backend/fullstack/permission/PermissionBootstrapSeeder.java index 99ba3dd..341c18d 100644 --- a/backend/src/main/java/backend/fullstack/permission/PermissionBootstrapSeeder.java +++ b/backend/src/main/java/backend/fullstack/permission/PermissionBootstrapSeeder.java @@ -1,5 +1,6 @@ package backend.fullstack.permission; +import java.util.List; import java.util.Map; import java.util.Set; diff --git a/backend/src/main/java/backend/fullstack/permission/catalog/DefaultRolePermissionMatrix.java b/backend/src/main/java/backend/fullstack/permission/catalog/DefaultRolePermissionMatrix.java index 3bf2b1c..dbba519 100644 --- a/backend/src/main/java/backend/fullstack/permission/catalog/DefaultRolePermissionMatrix.java +++ b/backend/src/main/java/backend/fullstack/permission/catalog/DefaultRolePermissionMatrix.java @@ -38,7 +38,11 @@ public static Map> create() { Permission.DEVIATIONS_READ, Permission.DEVIATIONS_RESOLVE, Permission.REPORTS_READ, - Permission.REPORTS_EXPORT + Permission.REPORTS_EXPORT, + Permission.ALCOHOL_VERIFICATION_READ, + Permission.ALCOHOL_INCIDENTS_READ, + Permission.ALCOHOL_INCIDENTS_RESOLVE, + Permission.ALCOHOL_LICENSE_READ )); mapping.put(Role.MANAGER, EnumSet.of( @@ -56,13 +60,21 @@ public static Map> create() { Permission.DEVIATIONS_CREATE, Permission.DEVIATIONS_RESOLVE, Permission.REPORTS_READ, - Permission.REPORTS_EXPORT + Permission.REPORTS_EXPORT, + Permission.ALCOHOL_VERIFICATION_READ, + Permission.ALCOHOL_VERIFICATION_CREATE, + Permission.ALCOHOL_INCIDENTS_READ, + Permission.ALCOHOL_INCIDENTS_CREATE, + Permission.ALCOHOL_INCIDENTS_RESOLVE, + Permission.ALCOHOL_LICENSE_READ )); mapping.put(Role.STAFF, EnumSet.of( Permission.LOGS_TEMPERATURE_CREATE, Permission.CHECKLISTS_COMPLETE, - Permission.DEVIATIONS_CREATE + Permission.DEVIATIONS_CREATE, + Permission.ALCOHOL_VERIFICATION_CREATE, + Permission.ALCOHOL_INCIDENTS_CREATE )); return Collections.unmodifiableMap(mapping); diff --git a/backend/src/main/java/backend/fullstack/permission/model/Permission.java b/backend/src/main/java/backend/fullstack/permission/model/Permission.java index b5d3722..ae230e6 100644 --- a/backend/src/main/java/backend/fullstack/permission/model/Permission.java +++ b/backend/src/main/java/backend/fullstack/permission/model/Permission.java @@ -43,7 +43,16 @@ public enum Permission { // Reports REPORTS_READ("reports.read"), - REPORTS_EXPORT("reports.export"); + REPORTS_EXPORT("reports.export"), + + // IK-Alkohol + ALCOHOL_VERIFICATION_READ("alcohol.verification.read"), + ALCOHOL_VERIFICATION_CREATE("alcohol.verification.create"), + ALCOHOL_INCIDENTS_READ("alcohol.incidents.read"), + ALCOHOL_INCIDENTS_CREATE("alcohol.incidents.create"), + ALCOHOL_INCIDENTS_RESOLVE("alcohol.incidents.resolve"), + ALCOHOL_LICENSE_READ("alcohol.license.read"), + ALCOHOL_LICENSE_MANAGE("alcohol.license.manage"); private final String key; private static final Map BY_KEY = Arrays.stream(values()) diff --git a/backend/src/main/resources/db/migration/V9__create_alcohol_compliance_tables.sql b/backend/src/main/resources/db/migration/V9__create_alcohol_compliance_tables.sql new file mode 100644 index 0000000..5573091 --- /dev/null +++ b/backend/src/main/resources/db/migration/V9__create_alcohol_compliance_tables.sql @@ -0,0 +1,66 @@ +-- ============================================================= +-- V9: IK-Alkohol – Alcohol compliance tables +-- ============================================================= + +-- Organization alcohol license / bevilling +CREATE TABLE IF NOT EXISTS alcohol_licenses ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + organization_id BIGINT NOT NULL, + license_type VARCHAR(30) NOT NULL, + license_number VARCHAR(100) NULL, + issued_at DATE NULL, + expires_at DATE NULL, + issuing_authority VARCHAR(255) NULL, + notes TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_alcohol_licenses_organization FOREIGN KEY (organization_id) REFERENCES organizations(id) +); +CREATE INDEX idx_alcohol_licenses_org ON alcohol_licenses(organization_id); + +-- Age verification logs +CREATE TABLE IF NOT EXISTS age_verification_logs ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + organization_id BIGINT NOT NULL, + location_id BIGINT NOT NULL, + verified_by_user_id BIGINT NOT NULL, + verification_method VARCHAR(30) NOT NULL, + guest_appeared_underage BOOLEAN NOT NULL DEFAULT TRUE, + id_was_valid BOOLEAN NULL, + was_refused BOOLEAN NOT NULL DEFAULT FALSE, + note TEXT NULL, + verified_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_age_verification_organization FOREIGN KEY (organization_id) REFERENCES organizations(id), + CONSTRAINT fk_age_verification_location FOREIGN KEY (location_id) REFERENCES locations(id), + CONSTRAINT fk_age_verification_user FOREIGN KEY (verified_by_user_id) REFERENCES users(id) +); +CREATE INDEX idx_age_verification_org ON age_verification_logs(organization_id); +CREATE INDEX idx_age_verification_location ON age_verification_logs(location_id); +CREATE INDEX idx_age_verification_date ON age_verification_logs(verified_at); + +-- Alcohol serving incidents +CREATE TABLE IF NOT EXISTS alcohol_serving_incidents ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + organization_id BIGINT NOT NULL, + location_id BIGINT NOT NULL, + reported_by_user_id BIGINT NOT NULL, + resolved_by_user_id BIGINT NULL, + incident_type VARCHAR(40) NOT NULL, + severity VARCHAR(20) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'OPEN', + description TEXT NOT NULL, + corrective_action TEXT NULL, + occurred_at TIMESTAMP NOT NULL, + resolved_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_alcohol_incident_organization FOREIGN KEY (organization_id) REFERENCES organizations(id), + CONSTRAINT fk_alcohol_incident_location FOREIGN KEY (location_id) REFERENCES locations(id), + CONSTRAINT fk_alcohol_incident_reporter FOREIGN KEY (reported_by_user_id) REFERENCES users(id), + CONSTRAINT fk_alcohol_incident_resolver FOREIGN KEY (resolved_by_user_id) REFERENCES users(id) +); +CREATE INDEX idx_alcohol_incident_org ON alcohol_serving_incidents(organization_id); +CREATE INDEX idx_alcohol_incident_location ON alcohol_serving_incidents(location_id); +CREATE INDEX idx_alcohol_incident_status ON alcohol_serving_incidents(status); +CREATE INDEX idx_alcohol_incident_date ON alcohol_serving_incidents(occurred_at); diff --git a/backend/src/test/java/backend/fullstack/alcohol/AgeVerificationServiceTest.java b/backend/src/test/java/backend/fullstack/alcohol/AgeVerificationServiceTest.java new file mode 100644 index 0000000..3beb619 --- /dev/null +++ b/backend/src/test/java/backend/fullstack/alcohol/AgeVerificationServiceTest.java @@ -0,0 +1,184 @@ +package backend.fullstack.alcohol; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import backend.fullstack.alcohol.api.dto.AgeVerificationRequest; +import backend.fullstack.alcohol.api.dto.AgeVerificationResponse; +import backend.fullstack.alcohol.application.AgeVerificationService; +import backend.fullstack.alcohol.domain.AgeVerificationLog; +import backend.fullstack.alcohol.domain.VerificationMethod; +import backend.fullstack.alcohol.infrastructure.AgeVerificationRepository; +import backend.fullstack.exceptions.ResourceNotFoundException; +import backend.fullstack.location.Location; +import backend.fullstack.location.LocationRepository; +import backend.fullstack.organization.Organization; +import backend.fullstack.organization.OrganizationRepository; +import backend.fullstack.user.AccessContextService; +import backend.fullstack.user.User; +import backend.fullstack.user.role.Role; + +@ExtendWith(MockitoExtension.class) +class AgeVerificationServiceTest { + + @Mock + private AgeVerificationRepository verificationRepository; + @Mock + private OrganizationRepository organizationRepository; + @Mock + private LocationRepository locationRepository; + @Mock + private AccessContextService accessContext; + + private AgeVerificationService verificationService; + + private static final Long ORG_ID = 100L; + private static final Long LOCATION_ID = 1L; + + @BeforeEach + void setUp() { + verificationService = new AgeVerificationService( + verificationRepository, organizationRepository, locationRepository, accessContext + ); + } + + @Test + void createVerificationLogSavesCorrectFields() { + Organization org = organization(); + Location location = location(org); + User user = user(5L, org); + AgeVerificationRequest request = verificationRequest(); + + when(accessContext.getCurrentOrganizationId()).thenReturn(ORG_ID); + when(accessContext.getCurrentUser()).thenReturn(user); + when(organizationRepository.findById(ORG_ID)).thenReturn(Optional.of(org)); + when(locationRepository.findById(LOCATION_ID)).thenReturn(Optional.of(location)); + when(verificationRepository.save(any(AgeVerificationLog.class))).thenAnswer(invocation -> { + AgeVerificationLog log = invocation.getArgument(0); + log.setId(1L); + return log; + }); + + AgeVerificationResponse response = verificationService.create(request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AgeVerificationLog.class); + verify(verificationRepository).save(captor.capture()); + assertEquals(VerificationMethod.ID_CHECKED, captor.getValue().getVerificationMethod()); + assertTrue(captor.getValue().isGuestAppearedUnderage()); + assertEquals(1L, response.getId()); + assertEquals("Test User", response.getVerifiedByName()); + } + + @Test + void createVerificationDefaultsVerifiedAtToNow() { + Organization org = organization(); + Location location = location(org); + User user = user(5L, org); + AgeVerificationRequest request = verificationRequest(); + request.setVerifiedAt(null); + + when(accessContext.getCurrentOrganizationId()).thenReturn(ORG_ID); + when(accessContext.getCurrentUser()).thenReturn(user); + when(organizationRepository.findById(ORG_ID)).thenReturn(Optional.of(org)); + when(locationRepository.findById(LOCATION_ID)).thenReturn(Optional.of(location)); + when(verificationRepository.save(any(AgeVerificationLog.class))).thenAnswer(invocation -> { + AgeVerificationLog log = invocation.getArgument(0); + log.setId(2L); + return log; + }); + + verificationService.create(request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AgeVerificationLog.class); + verify(verificationRepository).save(captor.capture()); + assertNotNull(captor.getValue().getVerifiedAt()); + } + + @Test + void listReturnsFilteredResults() { + Organization org = organization(); + Location location = location(org); + User user = user(5L, org); + AgeVerificationLog log = AgeVerificationLog.builder() + .id(1L) + .organization(org) + .location(location) + .verifiedBy(user) + .verificationMethod(VerificationMethod.ID_CHECKED) + .guestAppearedUnderage(true) + .wasRefused(false) + .verifiedAt(LocalDateTime.of(2026, 4, 1, 12, 0)) + .build(); + + when(accessContext.getCurrentOrganizationId()).thenReturn(ORG_ID); + when(verificationRepository.findFiltered(ORG_ID, null, null, null)).thenReturn(List.of(log)); + + List result = verificationService.list(null, null, null); + + assertEquals(1, result.size()); + assertEquals(VerificationMethod.ID_CHECKED, result.get(0).getVerificationMethod()); + } + + @Test + void getByIdThrowsWhenNotFound() { + when(accessContext.getCurrentOrganizationId()).thenReturn(ORG_ID); + when(verificationRepository.findByIdAndOrganization_Id(999L, ORG_ID)).thenReturn(Optional.empty()); + + assertThrows(ResourceNotFoundException.class, () -> verificationService.getById(999L)); + } + + private static AgeVerificationRequest verificationRequest() { + AgeVerificationRequest request = new AgeVerificationRequest(); + request.setLocationId(LOCATION_ID); + request.setVerificationMethod(VerificationMethod.ID_CHECKED); + request.setGuestAppearedUnderage(true); + request.setIdWasValid(true); + request.setWasRefused(false); + request.setVerifiedAt(LocalDateTime.of(2026, 4, 1, 20, 30)); + return request; + } + + private static Organization organization() { + return Organization.builder() + .id(ORG_ID) + .name("Everest Sushi & Fusion") + .organizationNumber("937219997") + .build(); + } + + private static Location location(Organization org) { + return Location.builder() + .id(LOCATION_ID) + .organization(org) + .name("Bar") + .build(); + } + + private static User user(Long id, Organization org) { + return User.builder() + .id(id) + .organization(org) + .email("staff@everest.no") + .firstName("Test") + .lastName("User") + .passwordHash("hash") + .role(Role.STAFF) + .build(); + } +} diff --git a/backend/src/test/java/backend/fullstack/alcohol/AlcoholIncidentServiceTest.java b/backend/src/test/java/backend/fullstack/alcohol/AlcoholIncidentServiceTest.java new file mode 100644 index 0000000..cef1518 --- /dev/null +++ b/backend/src/test/java/backend/fullstack/alcohol/AlcoholIncidentServiceTest.java @@ -0,0 +1,228 @@ +package backend.fullstack.alcohol; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import backend.fullstack.alcohol.api.dto.AlcoholIncidentRequest; +import backend.fullstack.alcohol.api.dto.AlcoholIncidentResponse; +import backend.fullstack.alcohol.api.dto.ResolveIncidentRequest; +import backend.fullstack.alcohol.application.AlcoholIncidentService; +import backend.fullstack.alcohol.domain.AlcoholServingIncident; +import backend.fullstack.alcohol.domain.IncidentSeverity; +import backend.fullstack.alcohol.domain.IncidentStatus; +import backend.fullstack.alcohol.domain.IncidentType; +import backend.fullstack.alcohol.infrastructure.AlcoholIncidentRepository; +import backend.fullstack.exceptions.ResourceNotFoundException; +import backend.fullstack.location.Location; +import backend.fullstack.location.LocationRepository; +import backend.fullstack.organization.Organization; +import backend.fullstack.organization.OrganizationRepository; +import backend.fullstack.user.AccessContextService; +import backend.fullstack.user.User; +import backend.fullstack.user.role.Role; + +@ExtendWith(MockitoExtension.class) +class AlcoholIncidentServiceTest { + + @Mock + private AlcoholIncidentRepository incidentRepository; + @Mock + private OrganizationRepository organizationRepository; + @Mock + private LocationRepository locationRepository; + @Mock + private AccessContextService accessContext; + + private AlcoholIncidentService incidentService; + + private static final Long ORG_ID = 100L; + private static final Long LOCATION_ID = 1L; + + @BeforeEach + void setUp() { + incidentService = new AlcoholIncidentService( + incidentRepository, organizationRepository, locationRepository, accessContext + ); + } + + @Test + void createIncidentSavesWithOpenStatus() { + Organization org = organization(); + Location location = location(org); + User reporter = user(5L, org, Role.STAFF); + AlcoholIncidentRequest request = incidentRequest(); + + when(accessContext.getCurrentOrganizationId()).thenReturn(ORG_ID); + when(accessContext.getCurrentUser()).thenReturn(reporter); + when(organizationRepository.findById(ORG_ID)).thenReturn(Optional.of(org)); + when(locationRepository.findById(LOCATION_ID)).thenReturn(Optional.of(location)); + when(incidentRepository.save(any(AlcoholServingIncident.class))).thenAnswer(invocation -> { + AlcoholServingIncident incident = invocation.getArgument(0); + incident.setId(1L); + return incident; + }); + + AlcoholIncidentResponse response = incidentService.create(request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AlcoholServingIncident.class); + verify(incidentRepository).save(captor.capture()); + assertEquals(IncidentStatus.OPEN, captor.getValue().getStatus()); + assertEquals(IncidentType.REFUSED_SERVICE, captor.getValue().getIncidentType()); + assertEquals(1L, response.getId()); + } + + @Test + void resolveIncidentSetsResolverAndTimestamp() { + Organization org = organization(); + Location location = location(org); + User reporter = user(5L, org, Role.STAFF); + User resolver = user(8L, org, Role.MANAGER); + + AlcoholServingIncident existing = AlcoholServingIncident.builder() + .id(1L) + .organization(org) + .location(location) + .reportedBy(reporter) + .incidentType(IncidentType.REFUSED_SERVICE) + .severity(IncidentSeverity.MEDIUM) + .status(IncidentStatus.OPEN) + .description("Guest was intoxicated") + .occurredAt(LocalDateTime.of(2026, 4, 1, 22, 0)) + .build(); + + ResolveIncidentRequest request = new ResolveIncidentRequest(); + request.setCorrectiveAction("Staff received additional training"); + + when(accessContext.getCurrentOrganizationId()).thenReturn(ORG_ID); + when(accessContext.getCurrentUser()).thenReturn(resolver); + when(incidentRepository.findByIdAndOrganization_Id(1L, ORG_ID)).thenReturn(Optional.of(existing)); + when(incidentRepository.save(any(AlcoholServingIncident.class))).thenAnswer(i -> i.getArgument(0)); + + AlcoholIncidentResponse response = incidentService.resolve(1L, request); + + assertEquals(IncidentStatus.RESOLVED, response.getStatus()); + assertEquals("Staff received additional training", response.getCorrectiveAction()); + assertNotNull(response.getResolvedAt()); + assertEquals(resolver.getId(), response.getResolvedByUserId()); + } + + @Test + void listIncidentsReturnsFilteredResults() { + Organization org = organization(); + Location location = location(org); + User reporter = user(5L, org, Role.STAFF); + + AlcoholServingIncident incident = AlcoholServingIncident.builder() + .id(1L) + .organization(org) + .location(location) + .reportedBy(reporter) + .incidentType(IncidentType.INTOXICATED_PERSON) + .severity(IncidentSeverity.HIGH) + .status(IncidentStatus.OPEN) + .description("Heavily intoxicated guest") + .occurredAt(LocalDateTime.of(2026, 4, 1, 23, 0)) + .build(); + + when(accessContext.getCurrentOrganizationId()).thenReturn(ORG_ID); + when(incidentRepository.findFiltered(ORG_ID, null, IncidentStatus.OPEN, null, null, null)) + .thenReturn(List.of(incident)); + + List result = incidentService.list(null, IncidentStatus.OPEN, null, null, null); + + assertEquals(1, result.size()); + assertEquals(IncidentType.INTOXICATED_PERSON, result.get(0).getIncidentType()); + } + + @Test + void getByIdThrowsWhenNotFound() { + when(accessContext.getCurrentOrganizationId()).thenReturn(ORG_ID); + when(incidentRepository.findByIdAndOrganization_Id(999L, ORG_ID)).thenReturn(Optional.empty()); + + assertThrows(ResourceNotFoundException.class, () -> incidentService.getById(999L)); + } + + @Test + void updateStatusToClosedSetsResolverIfMissing() { + Organization org = organization(); + Location location = location(org); + User reporter = user(5L, org, Role.STAFF); + User admin = user(1L, org, Role.ADMIN); + + AlcoholServingIncident existing = AlcoholServingIncident.builder() + .id(1L) + .organization(org) + .location(location) + .reportedBy(reporter) + .incidentType(IncidentType.DISTURBANCE) + .severity(IncidentSeverity.LOW) + .status(IncidentStatus.OPEN) + .description("Minor disturbance") + .occurredAt(LocalDateTime.of(2026, 4, 2, 21, 0)) + .build(); + + when(accessContext.getCurrentOrganizationId()).thenReturn(ORG_ID); + when(accessContext.getCurrentUser()).thenReturn(admin); + when(incidentRepository.findByIdAndOrganization_Id(1L, ORG_ID)).thenReturn(Optional.of(existing)); + when(incidentRepository.save(any(AlcoholServingIncident.class))).thenAnswer(i -> i.getArgument(0)); + + AlcoholIncidentResponse response = incidentService.updateStatus(1L, IncidentStatus.CLOSED); + + assertEquals(IncidentStatus.CLOSED, response.getStatus()); + assertNotNull(response.getResolvedAt()); + assertEquals(admin.getId(), response.getResolvedByUserId()); + } + + private static AlcoholIncidentRequest incidentRequest() { + AlcoholIncidentRequest request = new AlcoholIncidentRequest(); + request.setLocationId(LOCATION_ID); + request.setIncidentType(IncidentType.REFUSED_SERVICE); + request.setSeverity(IncidentSeverity.MEDIUM); + request.setDescription("Guest appeared intoxicated, refused further service"); + request.setOccurredAt(LocalDateTime.of(2026, 4, 1, 22, 0)); + return request; + } + + private static Organization organization() { + return Organization.builder() + .id(ORG_ID) + .name("Everest Sushi & Fusion") + .organizationNumber("937219997") + .build(); + } + + private static Location location(Organization org) { + return Location.builder() + .id(LOCATION_ID) + .organization(org) + .name("Bar") + .build(); + } + + private static User user(Long id, Organization org, Role role) { + return User.builder() + .id(id) + .organization(org) + .email("user" + id + "@everest.no") + .firstName("Test") + .lastName("User") + .passwordHash("hash") + .role(role) + .build(); + } +} diff --git a/backend/src/test/java/backend/fullstack/alcohol/AlcoholLicenseServiceTest.java b/backend/src/test/java/backend/fullstack/alcohol/AlcoholLicenseServiceTest.java new file mode 100644 index 0000000..7393dfc --- /dev/null +++ b/backend/src/test/java/backend/fullstack/alcohol/AlcoholLicenseServiceTest.java @@ -0,0 +1,168 @@ +package backend.fullstack.alcohol; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import backend.fullstack.alcohol.api.dto.AlcoholLicenseRequest; +import backend.fullstack.alcohol.api.dto.AlcoholLicenseResponse; +import backend.fullstack.alcohol.application.AlcoholLicenseService; +import backend.fullstack.alcohol.domain.AlcoholLicense; +import backend.fullstack.alcohol.domain.LicenseType; +import backend.fullstack.alcohol.infrastructure.AlcoholLicenseRepository; +import backend.fullstack.exceptions.ResourceNotFoundException; +import backend.fullstack.organization.Organization; +import backend.fullstack.organization.OrganizationRepository; +import backend.fullstack.user.AccessContextService; + +@ExtendWith(MockitoExtension.class) +class AlcoholLicenseServiceTest { + + @Mock + private AlcoholLicenseRepository licenseRepository; + @Mock + private OrganizationRepository organizationRepository; + @Mock + private AccessContextService accessContext; + + private AlcoholLicenseService licenseService; + + private static final Long ORG_ID = 100L; + + @BeforeEach + void setUp() { + licenseService = new AlcoholLicenseService(licenseRepository, organizationRepository, accessContext); + } + + @Test + void createLicenseSavesWithCorrectFields() { + Organization org = organization(); + AlcoholLicenseRequest request = licenseRequest(); + + when(accessContext.getCurrentOrganizationId()).thenReturn(ORG_ID); + when(organizationRepository.findById(ORG_ID)).thenReturn(Optional.of(org)); + when(licenseRepository.save(any(AlcoholLicense.class))).thenAnswer(invocation -> { + AlcoholLicense license = invocation.getArgument(0); + license.setId(1L); + return license; + }); + + AlcoholLicenseResponse response = licenseService.create(request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AlcoholLicense.class); + verify(licenseRepository).save(captor.capture()); + assertEquals(LicenseType.FULL_LICENSE, captor.getValue().getLicenseType()); + assertEquals("OSL-2026-1234", captor.getValue().getLicenseNumber()); + assertEquals(1L, response.getId()); + } + + @Test + void listLicensesReturnsAllForOrganization() { + AlcoholLicense license = AlcoholLicense.builder() + .id(1L) + .organization(organization()) + .licenseType(LicenseType.FULL_LICENSE) + .licenseNumber("OSL-2026-1234") + .expiresAt(LocalDate.of(2030, 1, 15)) + .build(); + + when(accessContext.getCurrentOrganizationId()).thenReturn(ORG_ID); + when(licenseRepository.findByOrganization_IdOrderByExpiresAtDesc(ORG_ID)).thenReturn(List.of(license)); + + List result = licenseService.listLicenses(); + + assertEquals(1, result.size()); + assertEquals("OSL-2026-1234", result.get(0).getLicenseNumber()); + } + + @Test + void getByIdThrowsWhenNotFound() { + when(accessContext.getCurrentOrganizationId()).thenReturn(ORG_ID); + when(licenseRepository.findByIdAndOrganization_Id(999L, ORG_ID)).thenReturn(Optional.empty()); + + assertThrows(ResourceNotFoundException.class, () -> licenseService.getById(999L)); + } + + @Test + void updateLicenseModifiesFields() { + AlcoholLicense existing = AlcoholLicense.builder() + .id(1L) + .organization(organization()) + .licenseType(LicenseType.BEER_WINE) + .build(); + AlcoholLicenseRequest request = licenseRequest(); + + when(accessContext.getCurrentOrganizationId()).thenReturn(ORG_ID); + when(licenseRepository.findByIdAndOrganization_Id(1L, ORG_ID)).thenReturn(Optional.of(existing)); + when(licenseRepository.save(any(AlcoholLicense.class))).thenAnswer(i -> i.getArgument(0)); + + AlcoholLicenseResponse response = licenseService.update(1L, request); + + assertEquals(LicenseType.FULL_LICENSE, response.getLicenseType()); + } + + @Test + void deleteLicenseRemovesEntity() { + AlcoholLicense existing = AlcoholLicense.builder() + .id(1L) + .organization(organization()) + .licenseType(LicenseType.FULL_LICENSE) + .build(); + + when(accessContext.getCurrentOrganizationId()).thenReturn(ORG_ID); + when(licenseRepository.findByIdAndOrganization_Id(1L, ORG_ID)).thenReturn(Optional.of(existing)); + + licenseService.delete(1L); + + verify(licenseRepository).delete(existing); + } + + @Test + void expiredLicenseIsMarkedAsExpired() { + AlcoholLicense license = AlcoholLicense.builder() + .id(1L) + .organization(organization()) + .licenseType(LicenseType.FULL_LICENSE) + .expiresAt(LocalDate.of(2020, 1, 1)) + .build(); + + when(accessContext.getCurrentOrganizationId()).thenReturn(ORG_ID); + when(licenseRepository.findByIdAndOrganization_Id(1L, ORG_ID)).thenReturn(Optional.of(license)); + + AlcoholLicenseResponse response = licenseService.getById(1L); + + assertTrue(response.isExpired()); + } + + private static AlcoholLicenseRequest licenseRequest() { + AlcoholLicenseRequest request = new AlcoholLicenseRequest(); + request.setLicenseType(LicenseType.FULL_LICENSE); + request.setLicenseNumber("OSL-2026-1234"); + request.setIssuedAt(LocalDate.of(2026, 1, 15)); + request.setExpiresAt(LocalDate.of(2030, 1, 15)); + request.setIssuingAuthority("Oslo kommune"); + return request; + } + + private static Organization organization() { + return Organization.builder() + .id(ORG_ID) + .name("Everest Sushi & Fusion") + .organizationNumber("937219997") + .build(); + } +}