Коммит a47e9f17 создал по автору Radch-enko's avatar Radch-enko
Просмотр файлов

Feature: Booking service: debugged the creation and deletion of calendar events

владелец 4fee33b1
......@@ -74,4 +74,6 @@ coverage/
*.sqlite3
/backend/app/src/main/resources/.env.properties
/backend/app/src/main/resources/google-credentials.json
\ Нет новой строки в конце файла
/backend/app/src/main/resources/google-credentials.json
/oldBackendExample
/scripts
\ Нет новой строки в конце файла
......@@ -10,7 +10,8 @@ dependencies {
implementation(project(":backend:feature:booking:core"))
implementation(project(":backend:feature:booking:calendar:google"))
implementation(project(":backend:feature:booking:calendar:dummy"))
implementation(project(":backend:feature:workspace:core"))
// implementation(project(":backend:feature:workspace:core"))
implementation(project(":backend:feature:workspace"))
implementation("org.springframework:spring-tx")
implementation(libs.springdoc.openapi.starter.webmvc.ui)
......
......@@ -26,7 +26,10 @@ springdoc:
swagger-ui:
path: /swagger-ui.html
operations-sorter: method
packages-to-scan: band.effective.office.backend.app.controller, band.effective.office.backend.feature.authorization.controller, band.effective.office.backend.feature.booking.core.controller
packages-to-scan: band.effective.office.backend.app.controller,
band.effective.office.backend.feature.authorization.controller,
band.effective.office.backend.feature.booking.core.controller,
band.effective.office.backend.feature.workspace.core.controller
management:
endpoints:
......
......@@ -11,9 +11,9 @@ import java.util.UUID
*/
data class User(
val id: UUID = UUID.randomUUID(),
@field:NotBlank(message = "Username is required")
@field:Size(min = 3, max = 50, message = "Username must be between 3 and 50 characters")
@field:Size(min = 3, max = 255, message = "Username must be between 3 and 50 characters")
val username: String,
@field:NotBlank(message = "Email is required")
......
package band.effective.office.backend.core.domain.model
import java.util.*
import java.util.UUID
/**
* Domain model representing a workspace in the office.
......@@ -12,7 +12,7 @@ import java.util.*
* @property zone The zone where the workspace is located
*/
data class Workspace(
var id: UUID?,
var id: UUID,
var name: String,
var tag: String,
var utilities: List<Utility>,
......
......@@ -16,6 +16,14 @@ interface UserDomainService {
*/
fun findByUsername(username: String): User?
/**
* Find a user by email.
*
* @param email The email to search for.
* @return The user if found, null otherwise.
*/
fun findByEmail(email: String): User?
/**
* Find a user by ID.
*
......
......@@ -26,16 +26,6 @@ interface WorkspaceDomainService {
*/
fun findAllByTag(tag: String): List<Workspace>
/**
* Returns all workspaces with the given tag which are free during the given period
*
* @param tag tag name of requested workspaces
* @param beginTimestamp period start time
* @param endTimestamp period end time
* @return List of [Workspace] with the given [tag]
*/
fun findAllFreeByPeriod(tag: String, beginTimestamp: Instant, endTimestamp: Instant): List<Workspace>
/**
* Returns all workspace zones
*
......
CREATE TABLE users (
id UUID PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
first_name VARCHAR(255) NOT NULL,
last_name VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE
);
-- Add index for common queries
CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_active ON users(active);
-- Add comment to table
COMMENT ON TABLE users IS 'Table storing user information';
\ Нет новой строки в конце файла
CREATE TABLE users
(
id UUID PRIMARY KEY,
username VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
first_name VARCHAR(255) NOT NULL,
last_name VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE
);
-- Add index for common queries
CREATE INDEX idx_users_username ON users (username);
CREATE INDEX idx_users_email ON users (email);
CREATE INDEX idx_users_active ON users (active);
-- Add comment to table
COMMENT
ON TABLE users IS 'Table storing user information';
CREATE TABLE workspaces
(
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
tag VARCHAR(255) NOT NULL UNIQUE,
zone_id UUID REFERENCES workspace_zones (id)
);
CREATE TABLE utilities
(
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
icon_url VARCHAR(255) NOT NULL UNIQUE
);
CREATE TABLE workspace_zones
(
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE
);
CREATE TABLE workspace_utilities
(
workspace_id UUID REFERENCES workspaces (id),
utility_id UUID REFERENCES utilities (id),
PRIMARY KEY (workspace_id, utility_id)
);
\ Нет новой строки в конце файла
......@@ -34,6 +34,7 @@ class SecurityConfig(
.requestMatchers("/swagger-ui.html/**", "/swagger-ui/**", "/api-docs/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/actuator/**").permitAll()
.requestMatchers("/bookings/**").permitAll() // TODO for testing
.requestMatchers("/workspaces/**").permitAll() // TODO for testing
.anyRequest().authenticated()
}
.addFilterBefore(
......
......@@ -5,7 +5,8 @@ plugins {
dependencies {
implementation(project(":backend:core:domain"))
implementation(project(":backend:feature:booking:core"))
implementation(project(":backend:feature:workspace:core"))
// implementation(project(":backend:feature:workspace:core"))
implementation(project(":backend:feature:workspace"))
implementation(libs.jakarta)
implementation(libs.jakarta.servlet.api)
......
package band.effective.office.backend.feature.booking.calendar.google
import band.effective.office.backend.core.domain.model.User
import band.effective.office.backend.core.domain.service.UserDomainService
import band.effective.office.backend.core.domain.service.WorkspaceDomainService
import band.effective.office.backend.feature.booking.core.domain.CalendarProvider
import band.effective.office.backend.feature.booking.core.domain.model.Booking
import band.effective.office.backend.feature.booking.core.domain.model.Workspace
import com.google.api.client.googleapis.json.GoogleJsonResponseException
import com.google.api.client.util.DateTime
import com.google.api.services.calendar.Calendar
......@@ -24,7 +25,9 @@ import org.springframework.stereotype.Component
@ConditionalOnProperty(name = ["calendar.provider"], havingValue = "google")
class GoogleCalendarProvider(
private val calendar: Calendar,
private val calendarIdProvider: CalendarIdProvider
private val calendarIdProvider: CalendarIdProvider,
private val userDomainService: UserDomainService,
private val workspaceDomainService: WorkspaceDomainService
) : CalendarProvider {
private val logger = LoggerFactory.getLogger(GoogleCalendarProvider::class.java)
......@@ -41,7 +44,7 @@ class GoogleCalendarProvider(
logger.debug("event: {}", event)
val savedEvent = runCatching {
calendar.events().insert(defaultCalendar, event).execute()
calendar.events().insert(workspaceCalendarId, event).execute()
}.onFailure {
logger.error("Failed to create event", it)
return@onFailure
......@@ -53,7 +56,7 @@ class GoogleCalendarProvider(
// Check if the event was successfully created in the workspace calendar
if (!checkEventAvailability(savedEvent, workspaceCalendarId)) {
// If not available, delete the event and throw an exception
deleteEventById(savedEvent.id)
deleteEventByBooking(booking)
throw WorkspaceUnavailableException("Workspace ${booking.workspace.name} is unavailable at the requested time")
}
......@@ -86,21 +89,21 @@ class GoogleCalendarProvider(
override fun deleteEvent(booking: Booking) {
logger.debug("Deleting event for booking: {}", booking)
val externalEventId = booking.externalEventId
?: throw IllegalArgumentException("Booking must have an external event ID to be deleted")
booking.externalEventId ?: throw IllegalArgumentException("Booking must have an external event ID to be deleted")
deleteEventById(externalEventId)
deleteEventByBooking(booking)
}
private fun deleteEventById(eventId: String) {
private fun deleteEventByBooking(booking: Booking) {
try {
calendar.events().delete(defaultCalendar, eventId).execute()
val calendarId = getCalendarIdByWorkspace(booking.workspace.id)
calendar.events().delete(calendarId, booking.externalEventId).execute()
} catch (e: GoogleJsonResponseException) {
if (e.statusCode != 404 && e.statusCode != 410) {
throw e
}
// If the event doesn't exist (404) or has been deleted (410), ignore the exception
logger.warn("Event with ID {} not found or already deleted", eventId)
logger.warn("Event with ID {} not found or already deleted", booking.externalEventId)
}
}
......@@ -115,7 +118,7 @@ class GoogleCalendarProvider(
val workspaceCalendarId = getCalendarIdByWorkspace(workspaceId)
val events = listEvents(workspaceCalendarId, from, to)
return events.map { convertToBooking(it) }
return events.map { convertToBooking(it, workspaceCalendarId) }
}
override fun findEventsByUser(userId: UUID, from: Instant, to: Instant?): List<Booking> {
......@@ -126,43 +129,56 @@ class GoogleCalendarProvider(
to ?: "infinity"
)
// In a real implementation, we would need to query the user's email from a user repository
// For simplicity, we'll assume we have a method to get the user's email
// Get the user's email from the user domain service
val userEmail = getUserEmailById(userId)
// Get all calendar IDs
val calendarIds = calendarIdProvider.getAllCalendarIds()
// Query all calendars for events with the user as an attendee or organizer
val allEvents = mutableListOf<Event>()
val bookings = mutableListOf<Booking>()
for (calendarId in calendarIds) {
val events = listEvents(calendarId, from, to, userEmail)
allEvents.addAll(events.filter { event ->
val filteredEvents = events.filter { event ->
event.organizer?.email == userEmail ||
event.attendees?.any { it.email == userEmail } == true
})
}
bookings.addAll(filteredEvents.map { convertToBooking(it, calendarId) })
}
return allEvents.map { convertToBooking(it) }
return bookings
}
override fun findEventById(id: UUID): Booking? {
logger.debug("Finding event with ID {}", id)
// In a real implementation, we would need to map the booking ID to the external event ID
// For simplicity, we'll assume the booking ID is the external event ID
val externalEventId = id.toString()
// Search for events with the booking ID in the description
// We need to search in all calendars because we don't know which calendar the event is in
val calendarIds = calendarIdProvider.getAllCalendarIds()
return try {
val event = calendar.events().get(defaultCalendar, externalEventId).execute()
convertToBooking(event)
} catch (e: GoogleJsonResponseException) {
if (e.statusCode == 404) {
null
} else {
throw e
// Search for events with the booking ID in the description
// We'll search for events in the last year to limit the search
val oneYearAgo = Instant.now().minusSeconds(365 * 24 * 60 * 60) // TODO
for (calendarId in calendarIds) {
try {
// Search for events with the booking ID in the description
val events = listEvents(calendarId, oneYearAgo, null)
// Find the event with the exact booking ID
val event = events.firstOrNull { event ->
event.description?.contains(id.toString()) == true
}
if (event != null) {
return convertToBooking(event, calendarId)
}
} catch (e: Exception) {
logger.warn("Failed to search for events in calendar {}: {}", calendarId, e.message)
}
}
return null
}
// Helper methods
......@@ -189,13 +205,26 @@ class GoogleCalendarProvider(
eventsRequest.q = q
}
return eventsRequest.execute().items ?: emptyList()
return try {
eventsRequest.execute().items ?: emptyList()
} catch (e: GoogleJsonResponseException) {
logger.error("Failed to list events from Google Calendar: {}", e.details)
if (e.statusCode == 404) {
logger.warn("Calendar with ID {} not found", calendarId)
} else if (e.statusCode == 403) {
logger.warn("Permission denied for calendar with ID {}", calendarId)
}
emptyList()
} catch (e: Exception) {
logger.error("Unexpected error when listing events from Google Calendar", e)
emptyList()
}
}
private fun convertToGoogleEvent(booking: Booking): Event {
val event = Event()
.setSummary("Booking: ${booking.workspace.name}")
.setDescription("Booking created by ${booking.owner.firstName} ${booking.owner.lastName}")
.setSummary("Booking: ${booking.workspace.tag} - ${booking.owner.firstName} ${booking.owner.lastName}")
.setDescription("Booking created by ${booking.owner.firstName} ${booking.owner.lastName}\nBooking ID: ${booking.id}")
.setStart(EventDateTime().setDateTime(DateTime(booking.beginBooking.toEpochMilli())))
.setEnd(EventDateTime().setDateTime(DateTime(booking.endBooking.toEpochMilli())))
......@@ -211,21 +240,38 @@ class GoogleCalendarProvider(
return event
}
private fun convertToBooking(event: Event): Booking {
// In a real implementation, we would need to map the Google Calendar event to a Booking
// This would involve looking up users and workspaces by their IDs or emails
// For simplicity, we'll create dummy objects
private fun convertToBooking(event: Event, calendarId: String? = null): Booking {
// Get the organizer's email and find the corresponding user
logger.debug("event.organizer?.email: ${event.organizer?.email}")
val organizerEmail = event.organizer.email ?: "unknown@example.com"
val owner = findOrCreateUserByEmail(organizerEmail)
val owner = createDummyUser(event.organizer?.email ?: "unknown@example.com")
val participants = event.attendees?.map { attendee ->
createDummyUser(attendee.email)
// Get the attendees' emails and find the corresponding users
val participants = event.attendees?.mapNotNull { attendee ->
findOrCreateUserByEmail(attendee.email)
} ?: emptyList()
val workspace = createDummyWorkspace(event.summary ?: "Unknown Workspace")
// Use the calendar ID (which is the workspace name) to find the workspace
// If calendarId is not provided, fall back to extracting from event summary
val workspaceName = calendarId ?: event.summary?.substringAfter("Booking: ") ?: "Unknown Workspace"
// Try to find a workspace with this name
// In a real implementation, we might have a more robust way to map events to workspaces
val workspace = workspaceDomainService.findAllByTag("meeting")
.firstOrNull { it.name == workspaceName }
?: throw IllegalStateException("Workspace with name $workspaceName not found")
// Extract booking ID from event description or use a random UUID if not found
val bookingIdStr = event.description?.let {
val regex = "Booking ID: ([0-9a-f-]+)".toRegex()
val matchResult = regex.find(it)
matchResult?.groupValues?.get(1)
}
val bookingId = bookingIdStr?.let { UUID.fromString(it) } ?: UUID.randomUUID()
return Booking(
id = UUID.randomUUID(), // In a real implementation, we would map this to a persistent ID
id = bookingId,
owner = owner,
participants = participants,
workspace = workspace,
......@@ -235,34 +281,73 @@ class GoogleCalendarProvider(
)
}
private fun createDummyUser(email: String): User {
return User(
/**
* Finds a user by email or creates a new one if not found.
*
* @param email The email of the user to find or create
* @return The found or created user
*/
private fun findOrCreateUserByEmail(email: String): User {
// Try to find a user with this email
val user = userDomainService.findByEmail(email)
if (user != null) {
return user
}
// If not found, create a new user
val newUser = User(
id = UUID.randomUUID(),
username = email.substringBefore("@"),
email = email,
firstName = "Dummy",
firstName = "Unknown",
lastName = "User"
)
}
private fun createDummyWorkspace(name: String): Workspace {
return Workspace(
id = UUID.randomUUID(),
name = name,
tag = "meeting"
)
// Save the new user
return userDomainService.createUser(newUser)
}
private fun getUserEmailById(userId: UUID): String {
// In a real implementation, we would look up the user's email in a repository
// For simplicity, we'll return a dummy email
return "stanislav.radchenko@effective.band"
// Look up the user's email in the user domain service
val user = userDomainService.findById(userId)
return user?.email ?: throw IllegalArgumentException("User with ID $userId not found")
}
/**
* Checks if a workspace is available at the requested time.
*
* @param event The event to check availability for
* @param workspaceCalendarId The calendar ID of the workspace
* @return True if the workspace is available, false otherwise
*/
private fun checkEventAvailability(event: Event, workspaceCalendarId: String): Boolean {
// In a real implementation, we would check if the workspace is available at the requested time
// For simplicity, we'll assume it's always available
return true
// Get the start and end time of the event
val startTime = Instant.ofEpochMilli(event.start.dateTime.value)
val endTime = Instant.ofEpochMilli(event.end.dateTime.value)
// Get all events in the workspace calendar during this time period
val events = listEvents(workspaceCalendarId, startTime, endTime)
// Check if there are any overlapping events
// Exclude the event itself if it's already in the calendar
val overlappingEvents = events.filter { existingEvent ->
// Skip the event itself
if (existingEvent.id == event.id) {
return@filter false
}
// Check if the events overlap
val existingStartTime = Instant.ofEpochMilli(existingEvent.start.dateTime.value)
val existingEndTime = Instant.ofEpochMilli(existingEvent.end.dateTime.value)
// Events overlap if one starts before the other ends and ends after the other starts
(startTime.isBefore(existingEndTime) && endTime.isAfter(existingStartTime))
}
// The workspace is available if there are no overlapping events
return overlappingEvents.isEmpty()
}
// Exception class for workspace unavailability
......
......@@ -17,7 +17,7 @@ import org.springframework.stereotype.Component
@Component
@Primary
class WorkspaceCalendarIdProvider(
private val workspaceService: WorkspaceDomainService
private val workspaceDomainService: WorkspaceDomainService,
) : CalendarIdProvider {
private val logger = LoggerFactory.getLogger(this::class.java)
......@@ -44,10 +44,8 @@ class WorkspaceCalendarIdProvider(
// Try to get the workspace from the WorkspaceService
try {
val workspace = workspaceService.findById(workspaceId)
val workspace = workspaceDomainService.findById(workspaceId)
if (workspace != null) {
// In a real implementation, we would get the calendar ID from the workspace
// For now, we'll use the workspace name as the calendar ID
val calendarId = workspace.name
// Cache the calendar ID for future use
......@@ -71,7 +69,7 @@ class WorkspaceCalendarIdProvider(
override fun getAllCalendarIds(): List<String> {
// Get all workspaces with the "meeting" tag
val meetingWorkspaces: List<Workspace> = try {
workspaceService.findAllByTag("meeting")
workspaceDomainService.findAllByTag("meeting")
} catch (e: Exception) {
logger.warn("Failed to get meeting workspaces: {}", e.message)
emptyList()
......@@ -80,7 +78,7 @@ class WorkspaceCalendarIdProvider(
// Add calendar IDs for all meeting workspaces
meetingWorkspaces.forEach { workspace ->
val workspaceId = workspace.id
if (workspaceId != null && !calendarIds.containsKey(workspaceId)) {
if (!calendarIds.containsKey(workspaceId)) {
calendarIds[workspaceId] = workspace.name
}
}
......
package band.effective.office.backend.feature.booking.core.controller
import band.effective.office.backend.core.domain.service.UserDomainService
import band.effective.office.backend.feature.booking.core.domain.model.Workspace
import band.effective.office.backend.core.domain.service.WorkspaceDomainService
import band.effective.office.backend.feature.booking.core.dto.BookingDto
import band.effective.office.backend.feature.booking.core.dto.CreateBookingDto
import band.effective.office.backend.feature.booking.core.dto.UpdateBookingDto
......@@ -33,9 +33,8 @@ import org.springframework.web.bind.annotation.RestController
@Tag(name = "Bookings", description = "API for managing bookings")
class BookingController(
private val bookingService: BookingService,
private val userService: UserDomainService
// In a real implementation, we would need a service for retrieving Workspace objects
// private val workspaceService: WorkspaceService
private val userService: UserDomainService,
private val workspaceService: WorkspaceDomainService,
) {
/**
......@@ -105,7 +104,7 @@ class BookingController(
}
else -> {
// In a real implementation, we would need a method to get all bookings
// TODO In a real implementation, we would need a method to get all bookings
// For now, we'll return an empty list
emptyList()
}
......@@ -142,16 +141,8 @@ class BookingController(
userService.findById(userId)
}
// In a real implementation, we would get the workspace from a service
// val workspace = workspaceService.getWorkspaceById(createBookingDto.workspaceId)
// ?: return ResponseEntity.notFound().build()
// For now, create a stub workspace
val workspace = Workspace(
id = createBookingDto.workspaceId,
name = createBookingDto.workspaceId.toString(),
tag = "meeting"
)
val workspace = workspaceService.findById(createBookingDto.workspaceId)
?: return ResponseEntity.notFound().build()
// Convert DTO to a domain model and create the booking
val booking = createBookingDto.toDomain(owner, participants, workspace)
......
package band.effective.office.backend.feature.booking.core.domain.model
import band.effective.office.backend.core.domain.model.User
import band.effective.office.backend.core.domain.model.Workspace
import java.time.Instant
import java.util.UUID
......
package band.effective.office.backend.feature.booking.core.domain.model
import java.util.UUID
/**
* Represents a utility available in a workspace.
*/
data class Utility(
val id: UUID = UUID.randomUUID(),
val name: String,
val iconUrl: String,
val count: Int
)
\ Нет новой строки в конце файла
package band.effective.office.backend.feature.booking.core.domain.model
import java.util.UUID
/**
* Represents a workspace in the office that can be booked.
*/
data class Workspace(
val id: UUID = UUID.randomUUID(),
val name: String,
val tag: String,
val utilities: List<Utility> = emptyList(),
val zone: WorkspaceZone? = null
)
\ Нет новой строки в конце файла
package band.effective.office.backend.feature.booking.core.domain.model
import java.util.UUID
/**
* Represents a zone in the office where workspaces are located.
*/
data class WorkspaceZone(
val id: UUID = UUID.randomUUID(),
val name: String
)
\ Нет новой строки в конце файла
package band.effective.office.backend.feature.booking.core.dto
import band.effective.office.backend.core.domain.model.User
import band.effective.office.backend.core.domain.model.Utility
import band.effective.office.backend.core.domain.model.Workspace
import band.effective.office.backend.core.domain.model.WorkspaceZone
import band.effective.office.backend.feature.booking.core.domain.model.Booking
import band.effective.office.backend.feature.booking.core.domain.model.RecurrenceModel
import band.effective.office.backend.feature.booking.core.domain.model.Workspace
import com.fasterxml.jackson.annotation.JsonFormat
import io.swagger.v3.oas.annotations.media.Schema
import java.time.Instant
......@@ -152,7 +154,7 @@ data class UtilityDto(
/**
* Converts a domain model to a DTO.
*/
fun fromDomain(utility: band.effective.office.backend.feature.booking.core.domain.model.Utility): UtilityDto {
fun fromDomain(utility: Utility): UtilityDto {
return UtilityDto(
id = utility.id,
name = utility.name,
......@@ -178,7 +180,7 @@ data class WorkspaceZoneDto(
/**
* Converts a domain model to a DTO.
*/
fun fromDomain(zone: band.effective.office.backend.feature.booking.core.domain.model.WorkspaceZone): WorkspaceZoneDto {
fun fromDomain(zone: WorkspaceZone): WorkspaceZoneDto {
return WorkspaceZoneDto(
id = zone.id,
name = zone.name
......
package band.effective.office.backend.feature.booking.core.dto
import band.effective.office.backend.core.domain.model.User
import band.effective.office.backend.core.domain.model.Workspace
import band.effective.office.backend.feature.booking.core.domain.model.Booking
import band.effective.office.backend.feature.booking.core.domain.model.Workspace
import com.fasterxml.jackson.annotation.JsonFormat
import io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.Valid
......
Поддерживает Markdown
0% или .
You are about to add 0 people to the discussion. Proceed with caution.
Сначала завершите редактирование этого сообщения!
Пожалуйста, зарегистрируйтесь или чтобы прокомментировать