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
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ Implemented:
- Service-layer overlap checks for clear API errors
- PostgreSQL exclusion constraints for scheduled shifts and approved leave
- Concurrent PostgreSQL integration test: two overlapping inserts yield exactly one commit and one `23P01` exclusion violation
- Audit events for shift creation, updates, cancellation, leave approval and leave cancellation
- Before/after state snapshots stored as JSONB, with actor and timestamp
- Audit writes participate in the same transaction as the business mutation
- Validation and API error handling
- Unit and integration tests
- GitHub Actions CI
Expand All @@ -34,11 +37,15 @@ Touching shifts are valid. Overlapping windows are rejected.

## API

Create an employee with POST /api/v1/employees.
Create an employee with `POST /api/v1/employees`.

Schedule a shift with POST /api/v1/shifts.
Schedule a shift with `POST /api/v1/shifts`. Update and cancel shifts with `PUT /api/v1/shifts/{id}` and `DELETE /api/v1/shifts/{id}`.

A scheduling conflict returns HTTP 409 with code SHIFT_CONFLICT.
Create or cancel leave with `POST /api/v1/leave` and `DELETE /api/v1/leave/{id}`.

Mutation requests must include an `X-Actor-Id` header. The value is recorded for audit attribution. **It is not authentication**: until authentication and role-based access control are implemented, a caller can supply an arbitrary actor value. Do not expose mutation endpoints to untrusted clients based on this header alone.

A scheduling conflict returns HTTP 409 with code `SHIFT_CONFLICT`; invalid time windows return HTTP 400.

## Run locally

Expand All @@ -58,6 +65,8 @@ The API starts on port 8080.

The service checks provide a useful conflict response, but they are not the concurrency boundary. PostgreSQL enforces the invariant with GiST exclusion constraints using `tstzrange(..., '[)')`; the half-open range allows adjacent shifts while rejecting overlaps. Migration `V3__schedule_consistency.sql` applies these constraints to scheduled shifts and approved leave. `ConcurrentShiftConflictIntegrationTest` synchronizes two transactions and asserts exactly one insert commits.

Migration `V4__audit_events.sql` adds the audit event table. Audit writes use the same transaction as each shift or leave mutation, so a failed mutation or failed audit write rolls back the whole operation. Integration tests verify that behaviour.

## Roadmap

### Milestone 2 — Scheduling Engine
Expand All @@ -74,7 +83,6 @@ The service checks provide a useful conflict response, but they are not the conc
- approval workflows
- overtime tracking
- workload calculations
- audit log

### Milestone 4 — Distributed Processing
- Redis
Expand Down
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.flywaydb:flyway-core")
runtimeOnly("org.flywaydb:flyway-database-postgresql")
runtimeOnly("org.postgresql:postgresql")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
Expand Down
11 changes: 11 additions & 0 deletions src/main/kotlin/dev/whrite/opsgrid/OpsGridApplication.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package dev.whrite.opsgrid

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

@SpringBootApplication
class OpsGridApplication

fun main(args: Array<String>) {
runApplication<OpsGridApplication>(*args)
}
50 changes: 50 additions & 0 deletions src/main/kotlin/dev/whrite/opsgrid/audit/AuditLogService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package dev.whrite.opsgrid.audit

import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import java.util.UUID

@Service
class AuditLogService(
private val jdbcTemplate: JdbcTemplate,
private val objectMapper: ObjectMapper
) {
@Transactional(propagation = Propagation.MANDATORY)
fun record(
actorId: String,
action: String,
entityType: String,
entityId: UUID,
beforeState: Any?,
afterState: Any?
) {
val actor = actorId.trim()
require(actor.isNotEmpty()) { "X-Actor-Id must not be blank" }
require(actor.length <= MAX_ACTOR_ID_LENGTH) {
"X-Actor-Id must be at most $MAX_ACTOR_ID_LENGTH characters"
}

jdbcTemplate.update(
"""
INSERT INTO audit_events (
id, actor_id, action, entity_type, entity_id,
occurred_at, before_state, after_state
) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?::jsonb, ?::jsonb)
""".trimIndent(),
UUID.randomUUID(),
actor,
action,
entityType,
entityId,
beforeState?.let(objectMapper::writeValueAsString),
afterState?.let(objectMapper::writeValueAsString)
)
}

private companion object {
const val MAX_ACTOR_ID_LENGTH = 120
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,44 @@ import org.springframework.web.bind.annotation.*
import java.time.OffsetDateTime
import java.util.UUID

data class CreateLeaveRequest(@field:NotNull val employeeId: UUID?, @field:NotNull val startsAt: OffsetDateTime?, @field:NotNull val endsAt: OffsetDateTime?, @field:NotBlank val reason: String)
data class LeaveResponse(val id: UUID, val employeeId: UUID, val startsAt: OffsetDateTime, val endsAt: OffsetDateTime, val reason: String, val status: LeaveStatus)
data class CreateLeaveRequest(
@field:NotNull val employeeId: UUID?,
@field:NotNull val startsAt: OffsetDateTime?,
@field:NotNull val endsAt: OffsetDateTime?,
@field:NotBlank val reason: String
)

data class LeaveResponse(
val id: UUID,
val employeeId: UUID,
val startsAt: OffsetDateTime,
val endsAt: OffsetDateTime,
val reason: String,
val status: LeaveStatus
)

@RestController
@RequestMapping("/api/v1/leave")
class EmployeeLeaveController(private val service: EmployeeLeaveService) {
@PostMapping @ResponseStatus(HttpStatus.CREATED)
fun create(@Valid @RequestBody request: CreateLeaveRequest) = service.approve(CreateLeaveCommand(request.employeeId!!, request.startsAt!!, request.endsAt!!, request.reason)).toResponse()
@GetMapping("/{id}") fun get(@PathVariable id: UUID) = service.find(id).toResponse()
@DeleteMapping("/{id}") fun cancel(@PathVariable id: UUID) = service.cancel(id).toResponse()
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun create(
@RequestHeader("X-Actor-Id") actorId: String,
@Valid @RequestBody request: CreateLeaveRequest
) = service.approve(
CreateLeaveCommand(request.employeeId!!, request.startsAt!!, request.endsAt!!, request.reason),
actorId
).toResponse()

@GetMapping("/{id}")
fun get(@PathVariable id: UUID) = service.find(id).toResponse()

@DeleteMapping("/{id}")
fun cancel(
@PathVariable id: UUID,
@RequestHeader("X-Actor-Id") actorId: String
) = service.cancel(id, actorId).toResponse()
}

private fun EmployeeLeave.toResponse() = LeaveResponse(id, employee.id, startsAt, endsAt, reason, status)
private fun EmployeeLeave.toResponse() =
LeaveResponse(id, employee.id, startsAt, endsAt, reason, status)
64 changes: 53 additions & 11 deletions src/main/kotlin/dev/whrite/opsgrid/leave/EmployeeLeaveService.kt
Original file line number Diff line number Diff line change
@@ -1,33 +1,75 @@
package dev.whrite.opsgrid.leave

import dev.whrite.opsgrid.audit.AuditLogService
import dev.whrite.opsgrid.employee.EmployeeRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.OffsetDateTime
import java.util.UUID

data class CreateLeaveCommand(val employeeId: UUID, val startsAt: OffsetDateTime, val endsAt: OffsetDateTime, val reason: String)
data class CreateLeaveCommand(
val employeeId: UUID,
val startsAt: OffsetDateTime,
val endsAt: OffsetDateTime,
val reason: String
)
class LeaveConflictException(message: String) : RuntimeException(message)
class LeaveNotFoundException(message: String) : RuntimeException(message)

@Service
class EmployeeLeaveService(private val employees: EmployeeRepository, private val leaves: EmployeeLeaveRepository) {
class EmployeeLeaveService(
private val employees: EmployeeRepository,
private val leaves: EmployeeLeaveRepository,
private val auditLog: AuditLogService
) {
@Transactional
fun approve(command: CreateLeaveCommand): EmployeeLeave {
fun approve(command: CreateLeaveCommand, actorId: String): EmployeeLeave {
validateWindow(command.startsAt, command.endsAt)
require(command.reason.isNotBlank()) { "Leave reason is required" }
val employee = employees.findById(command.employeeId).orElseThrow { RuntimeException("Employee "+command.employeeId+" was not found") }
val employee = employees.findById(command.employeeId)
.orElseThrow { RuntimeException("Employee ${command.employeeId} was not found") }
check(employee.active) { "Inactive employees cannot receive leave" }
if (leaves.hasConflict(employee.id, command.startsAt, command.endsAt)) throw LeaveConflictException("Employee already has approved leave overlapping this time window")
return leaves.save(EmployeeLeave(UUID.randomUUID(), employee, command.startsAt, command.endsAt, command.reason.trim()))
if (leaves.hasConflict(employee.id, command.startsAt, command.endsAt)) {
throw LeaveConflictException("Employee already has approved leave overlapping this time window")
}

val leave = leaves.save(
EmployeeLeave(
UUID.randomUUID(),
employee,
command.startsAt,
command.endsAt,
command.reason.trim()
)
)
auditLog.record(actorId, "LEAVE_APPROVED", "EMPLOYEE_LEAVE", leave.id, null, snapshot(leave))
return leave
}

@Transactional(readOnly = true)
fun find(id: UUID) = leaves.findById(id).orElseThrow { LeaveNotFoundException("Leave "+id+" was not found") }
fun find(id: UUID) = leaves.findById(id)
.orElseThrow { LeaveNotFoundException("Leave $id was not found") }

@Transactional
fun cancel(id: UUID): EmployeeLeave {
val leave = find(id)
fun cancel(id: UUID, actorId: String): EmployeeLeave {
val leave = leaves.findById(id)
.orElseThrow { LeaveNotFoundException("Leave $id was not found") }
val before = snapshot(leave)
leave.status = LeaveStatus.CANCELLED
return leaves.save(leave)
val cancelled = leaves.save(leave)
auditLog.record(actorId, "LEAVE_CANCELLED", "EMPLOYEE_LEAVE", cancelled.id, before, snapshot(cancelled))
return cancelled
}

private fun snapshot(leave: EmployeeLeave) = mapOf(
"employeeId" to leave.employee.id,
"startsAt" to leave.startsAt,
"endsAt" to leave.endsAt,
"reason" to leave.reason,
"status" to leave.status.name
)

private fun validateWindow(startsAt: OffsetDateTime, endsAt: OffsetDateTime) {
require(endsAt.isAfter(startsAt)) { "Leave end time must be after start time" }
}
private fun validateWindow(startsAt: OffsetDateTime, endsAt: OffsetDateTime) { require(endsAt.isAfter(startsAt)) { "Leave end time must be after start time" } }
}
62 changes: 53 additions & 9 deletions src/main/kotlin/dev/whrite/opsgrid/shift/ShiftController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,64 @@ import org.springframework.web.bind.annotation.*
import java.time.OffsetDateTime
import java.util.UUID

data class CreateShiftRequest(@field:NotNull val employeeId: UUID?, @field:NotNull val startsAt: OffsetDateTime?, @field:NotNull val endsAt: OffsetDateTime?)
data class UpdateShiftRequest(@field:NotNull val startsAt: OffsetDateTime?, @field:NotNull val endsAt: OffsetDateTime?)
data class ShiftResponse(val id: UUID, val employeeId: UUID, val startsAt: OffsetDateTime, val endsAt: OffsetDateTime, val status: ShiftStatus)
data class CreateShiftRequest(
@field:NotNull val employeeId: UUID?,
@field:NotNull val startsAt: OffsetDateTime?,
@field:NotNull val endsAt: OffsetDateTime?
)

data class UpdateShiftRequest(
@field:NotNull val startsAt: OffsetDateTime?,
@field:NotNull val endsAt: OffsetDateTime?
)

data class ShiftResponse(
val id: UUID,
val employeeId: UUID,
val startsAt: OffsetDateTime,
val endsAt: OffsetDateTime,
val status: ShiftStatus
)

@RestController
@RequestMapping("/api/v1/shifts")
class ShiftController(private val service: ShiftService) {
@PostMapping @ResponseStatus(HttpStatus.CREATED)
fun create(@Valid @RequestBody request: CreateShiftRequest) = service.schedule(CreateShiftCommand(request.employeeId!!, request.startsAt!!, request.endsAt!!)).toResponse()
@GetMapping("/{id}") fun get(@PathVariable id: UUID) = service.find(id).toResponse()
@PutMapping("/{id}") fun update(@PathVariable id: UUID, @Valid @RequestBody request: UpdateShiftRequest) = service.update(id, UpdateShiftCommand(request.startsAt!!, request.endsAt!!)).toResponse()
@DeleteMapping("/{id}") fun cancel(@PathVariable id: UUID) = service.cancel(id).toResponse()
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun create(
@RequestHeader("X-Actor-Id") actorId: String,
@Valid @RequestBody request: CreateShiftRequest
) = service.schedule(
CreateShiftCommand(request.employeeId!!, request.startsAt!!, request.endsAt!!),
actorId
).toResponse()

@GetMapping("/{id}")
fun get(@PathVariable id: UUID) = service.find(id).toResponse()

@PutMapping("/{id}")
fun update(
@PathVariable id: UUID,
@RequestHeader("X-Actor-Id") actorId: String,
@Valid @RequestBody request: UpdateShiftRequest
) = service.update(
id,
UpdateShiftCommand(request.startsAt!!, request.endsAt!!),
actorId
).toResponse()

@DeleteMapping("/{id}")
fun cancel(
@PathVariable id: UUID,
@RequestHeader("X-Actor-Id") actorId: String
) = service.cancel(id, actorId).toResponse()

@GetMapping("/employee/{employeeId}")
fun schedule(@PathVariable employeeId: UUID, @RequestParam startsAt: OffsetDateTime, @RequestParam endsAt: OffsetDateTime) = service.findEmployeeSchedule(employeeId, startsAt, endsAt).map { it.toResponse() }
fun schedule(
@PathVariable employeeId: UUID,
@RequestParam startsAt: OffsetDateTime,
@RequestParam endsAt: OffsetDateTime
) = service.findEmployeeSchedule(employeeId, startsAt, endsAt).map { it.toResponse() }
}

private fun Shift.toResponse() = ShiftResponse(id, employee.id, startsAt, endsAt, status)
Loading
Loading