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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
404 changes: 395 additions & 9 deletions openapi.yaml

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
import com.ibrasoft.lensbridge.dto.board.response.frames.EventView;
import com.ibrasoft.lensbridge.model.minbar.Audience;
import com.ibrasoft.lensbridge.model.minbar.BoardEvent;
import com.ibrasoft.lensbridge.model.minbar.PrayerSpace;
import com.ibrasoft.lensbridge.repository.sql.PrayerSpaceRepository;
import com.ibrasoft.lensbridge.dto.minbar.response.PrayerSpaceView;
import com.ibrasoft.lensbridge.service.BoardService;
import com.ibrasoft.lensbridge.service.PrayerSpaceService;
import com.ibrasoft.tcketmanagebackend.model.ticket.TicketType;
import com.ibrasoft.tcketmanagebackend.repository.TicketTypeRepository;
import io.swagger.v3.oas.annotations.Operation;
Expand All @@ -27,14 +27,14 @@
public class MinbarController {

private final BoardService boardService;
private final PrayerSpaceRepository prayerSpaceRepository;
private final PrayerSpaceService prayerSpaceService;
private final TicketTypeRepository ticketTypeRepository;

MinbarController(BoardService boardService,
PrayerSpaceRepository prayerSpaceRepository,
PrayerSpaceService prayerSpaceService,
ObjectProvider<TicketTypeRepository> ticketTypeRepoProvider) {
this.boardService = boardService;
this.prayerSpaceRepository = prayerSpaceRepository;
this.prayerSpaceService = prayerSpaceService;
this.ticketTypeRepository = ticketTypeRepoProvider.getIfAvailable();
}

Expand Down Expand Up @@ -69,20 +69,25 @@ public ResponseEntity<List<EventView>> getEvents(
return ResponseEntity.ok(events);
}

/**
* Every space carries its full walking directions. The list used to omit them -- the
* endpoint returned the entity and {@code steps} is lazy -- which forced clients into
* a follow-up request per space just to render a directions sheet. There are single
* digits of prayer spaces on a campus; serving them whole is cheaper than the fan-out
* was.
*/
@GetMapping("/prayer-spaces")
@Operation(operationId = "getMinbarPrayerSpaces", summary = "List prayer spaces for an audience")
public ResponseEntity<List<PrayerSpace>> getPrayerSpaces(@RequestParam Audience audience) {
@Operation(operationId = "getMinbarPrayerSpaces", summary = "List prayer spaces for an audience, with directions")
public ResponseEntity<List<PrayerSpaceView>> getPrayerSpaces(@RequestParam Audience audience) {
log.debug("Minbar fetching prayer spaces for {}", audience);
return ResponseEntity.ok(prayerSpaceRepository.findByAudienceOrBoth(audience));
return ResponseEntity.ok(prayerSpaceService.getSpacesForAudience(audience));
}

@GetMapping("/prayer-spaces/{id}")
@Operation(operationId = "getMinbarPrayerSpaceById", summary = "Fetch a single prayer space with directions")
public ResponseEntity<PrayerSpace> getPrayerSpaceById(@PathVariable UUID id) {
public ResponseEntity<PrayerSpaceView> getPrayerSpaceById(@PathVariable UUID id) {
log.debug("Minbar fetching prayer space {}", id);
return prayerSpaceRepository.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
return ResponseEntity.ok(prayerSpaceService.getSpace(id));
}

private Map<UUID, List<TicketType>> loadTicketTypes(List<BoardEvent> boardEvents) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package com.ibrasoft.lensbridge.controller;

import com.ibrasoft.lensbridge.dto.auth.response.MessageResponse;
import com.ibrasoft.lensbridge.dto.minbar.request.CreatePrayerSpaceRequest;
import com.ibrasoft.lensbridge.dto.minbar.request.UpdatePrayerSpaceRequest;
import com.ibrasoft.lensbridge.dto.minbar.response.PrayerSpaceView;
import com.ibrasoft.lensbridge.model.audit.AuditAction;
import com.ibrasoft.lensbridge.model.auth.Permission;
import com.ibrasoft.lensbridge.service.AdminAuditService;
import com.ibrasoft.lensbridge.service.PrayerSpaceService;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.UUID;

/**
* Managing the prayer spaces the app navigates people to.
* <p>
* Reads are gated on {@code board:content:read} — seeing where the musallahs are is not a
* privilege, and the public endpoint under {@code /api/minbar} serves the same data
* unauthenticated. What this adds over that one is the unfiltered list: the public
* endpoint answers "what should this student see", and a console editing a sisters'
* musallah has to be able to load it whoever is signed in.
* <p>
* Writes are gated on {@code board:prayerspace:write}. As elsewhere in this codebase there
* is no class-level {@code @PreAuthorize}; see {@code docs/PERMISSIONS.md}.
*/
@RestController
@RequestMapping("/api/admin/minbar/prayer-spaces")
@RequiredArgsConstructor
@Slf4j
public class PrayerSpaceAdminController {

private final PrayerSpaceService prayerSpaceService;
private final AdminAuditService auditService;

@GetMapping
@PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')")
@Operation(operationId = "listAdminPrayerSpaces",
summary = "List every prayer space, regardless of audience")
public ResponseEntity<List<PrayerSpaceView>> listPrayerSpaces() {
log.debug("Admin fetching all prayer spaces");
return ResponseEntity.ok(prayerSpaceService.getAllSpaces());
}

@GetMapping("/{id}")
@PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_CONTENT_READ + "')")
@Operation(operationId = "getAdminPrayerSpace", summary = "Fetch one prayer space as an administrator")
public ResponseEntity<PrayerSpaceView> getPrayerSpace(@PathVariable UUID id) {
log.debug("Admin fetching prayer space {}", id);
return ResponseEntity.ok(prayerSpaceService.getSpace(id));
}

@PostMapping
@PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_PRAYER_SPACE_WRITE + "')")
@Operation(operationId = "createPrayerSpace", summary = "Create a prayer space")
public ResponseEntity<PrayerSpaceView> createPrayerSpace(
@Valid @RequestBody CreatePrayerSpaceRequest createRequest,
HttpServletRequest request) {

log.info("Admin creating prayer space: {}", createRequest.getName());
PrayerSpaceView created = prayerSpaceService.create(createRequest);

auditService.logAuditEvent(currentUserEmail(), AuditAction.CREATE_PRAYER_SPACE,
"PrayerSpace", created.getId(), request.getRemoteAddr());

return ResponseEntity.status(HttpStatus.CREATED).body(created);
}

@PatchMapping("/{id}")
@PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_PRAYER_SPACE_WRITE + "')")
@Operation(operationId = "updatePrayerSpace",
summary = "Update a prayer space; omitted fields are left unchanged")
public ResponseEntity<PrayerSpaceView> updatePrayerSpace(
@PathVariable UUID id,
@Valid @RequestBody UpdatePrayerSpaceRequest updateRequest,
HttpServletRequest request) {

log.info("Admin updating prayer space: {}", id);
PrayerSpaceView updated = prayerSpaceService.update(id, updateRequest);

auditService.logAuditEvent(currentUserEmail(), AuditAction.UPDATE_PRAYER_SPACE,
"PrayerSpace", id, request.getRemoteAddr());

return ResponseEntity.ok(updated);
}

@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('" + Permission.Authority.BOARD_PRAYER_SPACE_WRITE + "')")
@Operation(operationId = "deletePrayerSpace", summary = "Delete a prayer space and its directions")
public ResponseEntity<MessageResponse> deletePrayerSpace(
@PathVariable UUID id,
HttpServletRequest request) {

log.info("Admin deleting prayer space: {}", id);
prayerSpaceService.delete(id);

auditService.logAuditEvent(currentUserEmail(), AuditAction.DELETE_PRAYER_SPACE,
"PrayerSpace", id, request.getRemoteAddr());

return ResponseEntity.ok(new MessageResponse("Prayer space deleted successfully"));
}

private String currentUserEmail() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return authentication != null ? authentication.getName() : "unknown";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package com.ibrasoft.lensbridge.dto.minbar.request;

import com.ibrasoft.lensbridge.model.minbar.Audience;
import com.ibrasoft.lensbridge.model.minbar.PrayerSpaceType;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import lombok.*;
import org.hibernate.validator.constraints.URL;

import java.util.ArrayList;
import java.util.List;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreatePrayerSpaceRequest {

@NotBlank(message = "Name is required")
@Size(max = 255, message = "Name must be 255 characters or fewer")
private String name;

@NotBlank(message = "Tag is required")
@Size(max = 255, message = "Tag must be 255 characters or fewer")
private String tag;

@NotBlank(message = "Building is required")
@Size(max = 255, message = "Building must be 255 characters or fewer")
private String building;

@Size(max = 255)
private String floor;

@Size(max = 255)
private String roomInfo;

@Positive(message = "Capacity must be positive")
private Integer capacity;

@NotNull(message = "Type is required")
private PrayerSpaceType type;

/**
* Optional. Omitted, it follows from {@code type} — a brothers' musallah is listed
* to brothers, a reflection bay to everyone. Set it only to override that.
*/
private Audience audience;

@DecimalMin(value = "-90.0", message = "Latitude must be between -90 and 90")
@DecimalMax(value = "90.0", message = "Latitude must be between -90 and 90")
private Double latitude;

@DecimalMin(value = "-180.0", message = "Longitude must be between -180 and 180")
@DecimalMax(value = "180.0", message = "Longitude must be between -180 and 180")
private Double longitude;

@URL(message = "Maps URL must be a valid URL")
private String mapsUrl;

@URL(message = "Image URL must be a valid URL")
private String imageUrl;

private String notes;

/** Prose directions, shown when no {@code steps} have been written. */
private String directions;

@Size(max = 255)
private String startingPoint;

@PositiveOrZero(message = "Walk time cannot be negative")
private Integer walkTimeMinutes;

@Size(max = 255)
private String entranceName;

@Size(max = 255)
private String entranceDescription;

@Builder.Default
private List<@NotBlank @Size(max = 255) String> amenities = new ArrayList<>();

@Valid
@Builder.Default
private List<DirectionStepRequest> steps = new ArrayList<>();

@Builder.Default
private List<@NotBlank @Size(max = 255) String> tips = new ArrayList<>();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.ibrasoft.lensbridge.dto.minbar.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.*;

/**
* One walking instruction. Position comes from the list order, not from a field —
* a client that sends steps out of order and numbers them separately can disagree
* with itself, and the server would have no way to tell which it meant.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DirectionStepRequest {

@NotBlank(message = "Each step needs an instruction")
@Size(max = 255, message = "Instruction must be 255 characters or fewer")
private String instruction;

/** Optional landmark confirming the step was followed correctly. */
@Size(max = 255, message = "Landmark must be 255 characters or fewer")
private String subtext;
}
Loading
Loading