diff --git a/README.md b/README.md index 481d5c43b70ef57424e0c63deb8fb74b29649c06..5f5abb8216dca75aa67c2166c77d4e96726c6fea 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,37 @@ # Effective Office -## Overview -Effective Office is a comprehensive office management system designed to streamline workplace operations, resource management, and employee interactions. The project consists of a backend server and tablet clients that work together to create an efficient office environment. -## Features -- Resource booking and management -- Employee scheduling and coordination -- Office space optimization -- Cross-platform support (android, iOS) -- Secure authentication and authorization -- Real-time updates and notifications +## Goal :dart: + +The main goal of the project is the automation of various processes in the office and providing +interesting statistics for employees. + +## Technical goal: :wrench: + +The main technical task of the project is to create a multi-module application on Kotlin, +trying to focus on the most modern and relevant solutions in this language. Throughout the project, +we tried to use other languages and +technologies as little as possible. + +## 📺 Features: (Meeting Room Tablet App) + + + + +### 🔧 Features Overview + +| Feature | Description | +|----------------------------|--------------------------------------------------------------| +| Real-time Availability | Displays up-to-date status of meeting rooms | +| Quick Booking | Instantly reserve an available room with a single tap | +| Time-Specific Reservations| Book rooms for specific time slots | +| Booking Cancellation | Cancel existing reservations with ease | +| Early Room Release | Free up the room before the end of the reservation | +| Google Calendar Integration| Syncs all bookings with Google Calendar | ## Architecture + The project follows a client-server architecture: + - **Backend**: Spring Boot application with PostgreSQL database - **Clients**: Client applications and iOS tablet app - **Deployment**: Docker-based containerization for easy deployment @@ -20,15 +40,17 @@ The project follows a client-server architecture: ## Getting Started ### Prerequisites + - Git - Docker and Docker Compose - JDK 17 or higher - Gitleaks (for development) ### Installation + 1. Clone the repository: ``` - git clone https://github.com/your-organization/effective-office.git + git clone https://github.com/effective-dev-opensource/Effective-Office cd effective-office ``` @@ -51,13 +73,14 @@ The project follows a client-server architecture: ``` ## Project Structure + ``` effective-office/ ├── backend/ # Server-side application │ └── README.md # Detailed backend documentation ├── clients/ # Client applications │ └── README.md # Detailed client documentation -├── iosApp/ # iOS mobile application +├── iosApp/ # iOS tablet application ├── deploy/ # Deployment configurations │ ├── dev/ # Development environment │ └── prod/ # Production environment @@ -68,37 +91,39 @@ effective-office/ ``` For detailed documentation: + - [Backend Documentation](./backend/README.md) - [Client Documentation](./clients/README.md) - [Build Logic Documentation](./build-logic/README.md) - [Calendar Integration Documentation](docs/CALENDAR_INTEGRATION.md) ## Development Tools + - **Build System**: Gradle with Kotlin DSL - **Containerization**: Docker and Docker Compose - **Security Scanning**: Gitleaks for secret detection -- **CI/CD**: Automated build and deployment pipelines - **Version Control**: Git with pre-commit hooks ## Code Style & Conventions + - Follow Kotlin coding conventions for backend development - Use consistent naming patterns across the codebase - Document public APIs and complex logic - Run the pre-commit hook to ensure no secrets are committed -## Contributing -1. Ensure you've run the installation script (`./scripts/install.sh`) -2. Follow our [Git Flow](docs/GIT_FLOW.md) for branching and commit conventions -3. Create a feature branch (`git checkout -b feature/amazing-feature`) -4. Commit your changes (`git commit -m 'Add some amazing feature'`) -5. Push to the branch (`git push origin feature/amazing-feature`) -6. Open a Pull Request +## Contributing :raised_hands: + +Our project is open-source, so we welcome quality contributions! To make your contribution to the +project efficient and easy to check out, you can familiarize yourself with the project's [git flow +and commit rules](docs/GIT_FLOW.md). If you want to solve an existing issue in the project, you can read the list in +the issues tab in the repository. ## Roadmap -- TODO +- 📺 A TV application is in development, featuring a corporate news and photo feed, event announcements with registration + from an external service, Duolingo and sports leaderboards, and a tracker for the internal currency. + +## Authors :writing_hand: -## Authors -- TODO +- [Stanislav Radchenko](https://github.com/Radch-enko) +- [Vitaly Smirnov](https://github.com/KrugarValdes) -## License -- TODO diff --git a/backend/app/src/main/resources/application.yml b/backend/app/src/main/resources/application.yml index ddb0b9f538afe53a4231f03a90d41a1c37425a52..7118e605678f1351b82550b176457170c3e18204 100644 --- a/backend/app/src/main/resources/application.yml +++ b/backend/app/src/main/resources/application.yml @@ -55,10 +55,20 @@ management: logging: level: root: ${LOG_LEVEL:INFO} - org.hibernate.SQL: INFO - org.hibernate.type.descriptor.sql: INFO - org.hibernate.type.descriptor.sql.BasicBinder: TRACE band.effective.office.backend: ${LOG_LEVEL:DEBUG} + org.postgresql: WARN + org.hibernate: WARN + org.springframework: WARN + org.springframework.boot.autoconfigure: WARN + org.hibernate.SQL: WARN + org.hibernate.type.descriptor.sql.BasicBinder: WARN + org.hibernate.orm: WARN + com.zaxxer.hikari: INFO + org.apache.coyote.http11: WARN + com.google.api.client.http.HttpTransport: WARN + sun.net.www.protocol.http.HttpURLConnection: WARN + jdk.event.security: WARN + org.springframework.web.filter.CommonsRequestLoggingFilter: DEBUG application: url: ${APPLICATION_URL:http://localhost:8080} @@ -75,7 +85,7 @@ calendar: default-app-email: ${DEFAULT_APP_EMAIL} google-credentials: ${GOOGLE_CREDENTIALS_FILE:classpath:google-credentials.json} application-url: ${APPLICATION_URL} - test-application-url: ${TEST_APPLICATION_URL} + test-application-url: ${TEST_APPLICATION_URL:} calendars: ${CALENDARS} - test-calendars: ${TEST_CALENDARS} + test-calendars: ${TEST_CALENDARS:} firebase-credentials: ${FIREBASE_CREDENTIALS:classpath:firebase-credentials.json} \ No newline at end of file diff --git a/backend/core/repository/src/main/resources/db/migration/V2__add_count_to_workspace_utilities.sql b/backend/core/repository/src/main/resources/db/migration/V2__add_count_to_workspace_utilities.sql new file mode 100644 index 0000000000000000000000000000000000000000..ec454f8146ed42a0ffed4b8e7c98e88331bc99ab --- /dev/null +++ b/backend/core/repository/src/main/resources/db/migration/V2__add_count_to_workspace_utilities.sql @@ -0,0 +1,6 @@ +-- Add count column to workspace_utilities table +ALTER TABLE workspace_utilities +ADD COLUMN count INTEGER NOT NULL DEFAULT 1; + +-- Add comment to count column +COMMENT ON COLUMN workspace_utilities.count IS 'Number of this utility in the workspace'; \ No newline at end of file diff --git a/backend/feature/authorization/src/main/kotlin/band/effective/office/backend/feature/authorization/apikey/ApiKeyAuthorizer.kt b/backend/feature/authorization/src/main/kotlin/band/effective/office/backend/feature/authorization/apikey/ApiKeyAuthorizer.kt index a61417fd97e76bfc7f4ac4d6d4cb131c93f4bc7a..9db68a0ecb75c2565234af617a899983c1f13f1a 100644 --- a/backend/feature/authorization/src/main/kotlin/band/effective/office/backend/feature/authorization/apikey/ApiKeyAuthorizer.kt +++ b/backend/feature/authorization/src/main/kotlin/band/effective/office/backend/feature/authorization/apikey/ApiKeyAuthorizer.kt @@ -42,12 +42,10 @@ class ApiKeyAuthorizer( } val token = authHeader.substring(BEARER_PREFIX.length) - logger.debug("Authorization header found: $token") try { // Hash the token and check if it exists in the database val hashedToken = encryptKey(HASH_ALGORITHM, token) - logger.debug("Hashed token: $hashedToken") val apiKey = apiKeyRepository.findByKeyValue(hashedToken.lowercase()) if (apiKey == null) { diff --git a/backend/feature/authorization/src/main/kotlin/band/effective/office/backend/feature/authorization/config/PublicEndpoints.kt b/backend/feature/authorization/src/main/kotlin/band/effective/office/backend/feature/authorization/config/PublicEndpoints.kt new file mode 100644 index 0000000000000000000000000000000000000000..bd9ed158ad9fdc5bdbee7b6eb01285dfdf53bd66 --- /dev/null +++ b/backend/feature/authorization/src/main/kotlin/band/effective/office/backend/feature/authorization/config/PublicEndpoints.kt @@ -0,0 +1,43 @@ +package band.effective.office.backend.feature.authorization.config + +/** + * Centralized configuration for public endpoints that don't require authentication. + * This class provides a single source of truth for paths that should be accessible without authentication. + */ +object PublicEndpoints { + /** + * List of ANT patterns for public endpoints. + */ + val PATTERNS = listOf( + "/auth/**", + "/swagger-ui.html/**", + "/swagger-ui/**", + "/api-docs/**", + "/v3/api-docs/**", + "/api/swagger-ui.html/**", + "/api/swagger-ui/**", + "/api/api-docs/**", + "/api/v3/api-docs/**", + "/api/actuator/**", + "/api/notifications/**", + "/notifications", + ) + + /** + * Checks if the given URI matches any of the public endpoint patterns. + * + * @param uri The URI to check + * @return True if the URI matches any public endpoint pattern, false otherwise + */ + fun matches(uri: String): Boolean { + return PATTERNS.any { pattern -> + // Convert ANT pattern to regex pattern + val regexPattern = pattern + .replace("/**", "(/.*)?") // /** matches zero or more path segments + .replace("/*", "(/[^/]*)?") // /* matches zero or one path segment + .replace("*", "[^/]*") // * matches zero or more characters within a path segment + + uri.matches(Regex("^$regexPattern$")) + } + } +} \ No newline at end of file diff --git a/backend/feature/authorization/src/main/kotlin/band/effective/office/backend/feature/authorization/config/SecurityConfig.kt b/backend/feature/authorization/src/main/kotlin/band/effective/office/backend/feature/authorization/config/SecurityConfig.kt index 88fe66a2083392db7090a46f6eff23c906249f14..6de37ecf59a61532153bab0b12d1e72389439fa6 100644 --- a/backend/feature/authorization/src/main/kotlin/band/effective/office/backend/feature/authorization/config/SecurityConfig.kt +++ b/backend/feature/authorization/src/main/kotlin/band/effective/office/backend/feature/authorization/config/SecurityConfig.kt @@ -1,5 +1,6 @@ package band.effective.office.backend.feature.authorization.config +import band.effective.office.backend.feature.authorization.config.PublicEndpoints import band.effective.office.backend.feature.authorization.security.JwtAuthenticationFilter import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -28,11 +29,7 @@ class SecurityConfig( .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } .authorizeHttpRequests { authorize -> authorize - .requestMatchers("/auth/**").permitAll() - .requestMatchers("/swagger-ui.html/**", "/swagger-ui/**", "/api-docs/**", "/v3/api-docs/**").permitAll() - .requestMatchers("/api/swagger-ui.html/**", "/api/swagger-ui/**", "/api/api-docs/**", "/api/v3/api-docs/**").permitAll() - .requestMatchers("/actuator/**").permitAll() - .requestMatchers("/notifications/**").permitAll() + .requestMatchers(*PublicEndpoints.PATTERNS.toTypedArray()).permitAll() .anyRequest().authenticated() } .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java) diff --git a/backend/feature/authorization/src/main/kotlin/band/effective/office/backend/feature/authorization/security/JwtAuthenticationFilter.kt b/backend/feature/authorization/src/main/kotlin/band/effective/office/backend/feature/authorization/security/JwtAuthenticationFilter.kt index 5d05dc98a74b3c3e1e2e20ae02f407d297c9d521..f5e5a9d3aa4584d20e31e4d1daddc782cdcea033 100644 --- a/backend/feature/authorization/src/main/kotlin/band/effective/office/backend/feature/authorization/security/JwtAuthenticationFilter.kt +++ b/backend/feature/authorization/src/main/kotlin/band/effective/office/backend/feature/authorization/security/JwtAuthenticationFilter.kt @@ -1,6 +1,7 @@ package band.effective.office.backend.feature.authorization.security import band.effective.office.backend.core.data.ErrorDto +import band.effective.office.backend.feature.authorization.config.PublicEndpoints import band.effective.office.backend.feature.authorization.exception.AuthorizationException import band.effective.office.backend.feature.authorization.exception.AuthorizationErrorCodes import band.effective.office.backend.feature.authorization.service.AuthorizationService @@ -29,17 +30,13 @@ class JwtAuthenticationFilter( private val logger = LoggerFactory.getLogger(this::class.java) /** - * Checks if the request is for Swagger UI or API docs. - * - * @param requestURI The request URI to check - * @return True if the request is for Swagger UI or API docs, false otherwise + * Determines whether the filter should not be applied to this request. + * This method is called by the OncePerRequestFilter before doFilterInternal. + * + * @param request The HTTP request + * @return True if the filter should not be applied, false otherwise */ - private fun isSwaggerUIRequest(requestURI: String): Boolean { // TODO fix this hacky code - return requestURI.contains("/swagger-ui") || - requestURI.contains("/api-docs") || - requestURI.contains("/v3/api-docs") || - requestURI.contains("/notifications") - } + override fun shouldNotFilter(request: HttpServletRequest): Boolean = PublicEndpoints.matches(request.requestURI) /** * Filters incoming requests and attempts to authenticate them. @@ -53,13 +50,6 @@ class JwtAuthenticationFilter( response: HttpServletResponse, filterChain: FilterChain ) { - // Check if the request is for Swagger UI or API docs - val requestURI = request.requestURI - if (isSwaggerUIRequest(requestURI)) { - logger.debug("Skipping authorization for Swagger UI request: {}", requestURI) - filterChain.doFilter(request, response) - return - } try { // Attempt to authorize the request diff --git a/backend/feature/booking/calendar/google/src/main/kotlin/band/effective/office/backend/feature/booking/calendar/google/GoogleCalendarProvider.kt b/backend/feature/booking/calendar/google/src/main/kotlin/band/effective/office/backend/feature/booking/calendar/google/GoogleCalendarProvider.kt index 6d3343fb0cbae93daba0404fd80db458783a1813..39b38216327ae7412788f12d516841d57392be4c 100644 --- a/backend/feature/booking/calendar/google/src/main/kotlin/band/effective/office/backend/feature/booking/calendar/google/GoogleCalendarProvider.kt +++ b/backend/feature/booking/calendar/google/src/main/kotlin/band/effective/office/backend/feature/booking/calendar/google/GoogleCalendarProvider.kt @@ -90,11 +90,7 @@ class GoogleCalendarProvider( calendar.events().delete(defaultCalendar, eventId).execute() } catch (e: GoogleJsonResponseException) { logger.error("Failed to delete event: {}", e.details) - 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) + throw e } } @@ -243,10 +239,11 @@ class GoogleCalendarProvider( } private fun convertToGoogleEvent(booking: Booking, workspaceCalendarId: String? = null): Event { + val ownerEmail = booking.owner?.email ?: defaultCalendar val event = Event() .setSummary("Meet${booking.owner?.let { " ${it.firstName} ${it.lastName}" }.orEmpty()}") .setDescription( - "${booking.owner?.email} - почта организатора" + "$ownerEmail - почта организатора" ) .setStart(createEventDateTime(booking.beginBooking.toEpochMilli())) .setEnd(createEventDateTime(booking.endBooking.toEpochMilli())) @@ -262,7 +259,7 @@ class GoogleCalendarProvider( }.toMutableList() // Add the owner as the organizer - booking.owner?.email?.let { event.organizer = Event.Organizer().setEmail(it) } + event.organizer = Event.Organizer().setEmail(ownerEmail) // Add workspace as an attendee if workspaceCalendarId is provided workspaceCalendarId?.let { @@ -343,6 +340,11 @@ class GoogleCalendarProvider( matchResult?.groupValues?.get(1) } + // Determine if the booking is editable based on the organizer + // If the organizer is the defaultCalendar, then the booking is editable + // Otherwise, it's not editable (created from Google Calendar) + val isEditable = organizer == defaultCalendar + return Booking( id = event.id, owner = owner, @@ -351,7 +353,8 @@ class GoogleCalendarProvider( beginBooking = Instant.ofEpochMilli(event.start.dateTime.value), endBooking = Instant.ofEpochMilli(event.end.dateTime.value), recurrence = RecurrenceRuleConverter.fromGoogleRecurrenceRule(event.recurrence), - recurringBookingId = recurringBookingIdStr + recurringBookingId = recurringBookingIdStr, + isEditable = isEditable ) } diff --git a/backend/feature/booking/core/src/main/kotlin/band/effective/office/backend/feature/booking/core/domain/model/Booking.kt b/backend/feature/booking/core/src/main/kotlin/band/effective/office/backend/feature/booking/core/domain/model/Booking.kt index bf418f04f235e56f1b243d5c706a372009a81539..210bc745d8efec40a9f6d7e74f641ec6a4853867 100644 --- a/backend/feature/booking/core/src/main/kotlin/band/effective/office/backend/feature/booking/core/domain/model/Booking.kt +++ b/backend/feature/booking/core/src/main/kotlin/band/effective/office/backend/feature/booking/core/domain/model/Booking.kt @@ -23,5 +23,6 @@ data class Booking( val beginBooking: Instant, val endBooking: Instant, val recurrence: RecurrenceModel? = null, - val recurringBookingId: String? = null // ID of the recurring booking this booking belongs to + val recurringBookingId: String? = null, // ID of the recurring booking this booking belongs to + val isEditable: Boolean = true, // Flag indicating if booking can be edited/deleted from tablet client ) diff --git a/backend/feature/booking/core/src/main/kotlin/band/effective/office/backend/feature/booking/core/dto/BookingDto.kt b/backend/feature/booking/core/src/main/kotlin/band/effective/office/backend/feature/booking/core/dto/BookingDto.kt index 04467a087438c080897449e1fe34ab8df2038ab2..ecfe3b5dbb59324ae661c9a24749a4e32b83c15a 100644 --- a/backend/feature/booking/core/src/main/kotlin/band/effective/office/backend/feature/booking/core/dto/BookingDto.kt +++ b/backend/feature/booking/core/src/main/kotlin/band/effective/office/backend/feature/booking/core/dto/BookingDto.kt @@ -41,7 +41,10 @@ data class BookingDto( val recurrence: RecurrenceDto? = null, @Schema(description = "ID of the recurring booking this booking belongs to") - val recurringBookingId: String? = null + val recurringBookingId: String? = null, + + @Schema(description = "Flag indicating if booking can be edited/deleted from tablet client", example = "true") + val isEditable: Boolean = true ) { companion object { /** @@ -56,7 +59,8 @@ data class BookingDto( beginBooking = booking.beginBooking.toEpochMilli(), endBooking = booking.endBooking.toEpochMilli(), recurrence = booking.recurrence?.let { RecurrenceDto.fromDomain(it) }, - recurringBookingId = booking.recurringBookingId + recurringBookingId = booking.recurringBookingId, + isEditable = booking.isEditable ) } } diff --git a/backend/feature/calendar-subscription/src/main/kotlin/band/effective/office/backend/feature/calendar/subscription/repository/ChannelRepository.kt b/backend/feature/calendar-subscription/src/main/kotlin/band/effective/office/backend/feature/calendar/subscription/repository/ChannelRepository.kt index da544daa161587a7a1cba9e590a9f4a5b931868d..c36f2bd41fb5ec59a12a0a22340b4f81a443a9ba 100644 --- a/backend/feature/calendar-subscription/src/main/kotlin/band/effective/office/backend/feature/calendar/subscription/repository/ChannelRepository.kt +++ b/backend/feature/calendar-subscription/src/main/kotlin/band/effective/office/backend/feature/calendar/subscription/repository/ChannelRepository.kt @@ -16,4 +16,12 @@ interface ChannelRepository : JpaRepository { * @return the channel entity if found, null otherwise */ fun findByCalendarId(calendarId: String): ChannelEntity? -} \ No newline at end of file + + /** + * Find a channel by channel ID. + * + * @param channelId the channel ID to search for + * @return the channel entity if found, null otherwise + */ + fun findByChannelId(channelId: String): ChannelEntity? +} diff --git a/backend/feature/calendar-subscription/src/main/kotlin/band/effective/office/backend/feature/calendar/subscription/scheduler/CalendarSubscriptionScheduler.kt b/backend/feature/calendar-subscription/src/main/kotlin/band/effective/office/backend/feature/calendar/subscription/scheduler/CalendarSubscriptionScheduler.kt index 9a17d8b9df1271768bc2debefd71d7aab1797093..07f23a85a96d55f17e1b8a69cbe75157fd6b05e1 100644 --- a/backend/feature/calendar-subscription/src/main/kotlin/band/effective/office/backend/feature/calendar/subscription/scheduler/CalendarSubscriptionScheduler.kt +++ b/backend/feature/calendar-subscription/src/main/kotlin/band/effective/office/backend/feature/calendar/subscription/scheduler/CalendarSubscriptionScheduler.kt @@ -30,8 +30,6 @@ class CalendarSubscriptionScheduler( // Subscribe to production calendars val productionCalendars = config.getCalendars() - logger.debug("productionCalendars: {}", productionCalendars) - logger.debug("applicationUrl: {}", config.applicationUrl) if (config.applicationUrl.isNotBlank() && productionCalendars.isNotEmpty()) { googleCalendarService.subscribeToCalendarNotifications(config.applicationUrl, productionCalendars) } else { @@ -40,8 +38,6 @@ class CalendarSubscriptionScheduler( // Subscribe to test calendars val testCalendars = config.getTestCalendars() - logger.debug("testCalendars: {}", testCalendars) - logger.debug("testApplicationUrl: {}", config.testApplicationUrl) if (config.testApplicationUrl.isNotBlank() && testCalendars.isNotEmpty()) { googleCalendarService.subscribeToCalendarNotifications(config.testApplicationUrl, testCalendars) } else { diff --git a/backend/feature/notifications/build.gradle.kts b/backend/feature/notifications/build.gradle.kts index a443e198c0df4a68e22bd1f5d764ef903b24cc92..5e0a05bd5b86059a26809a6d1731ff019465ed48 100644 --- a/backend/feature/notifications/build.gradle.kts +++ b/backend/feature/notifications/build.gradle.kts @@ -14,6 +14,9 @@ dependencies { implementation(libs.jackson.module.kotlin) implementation(libs.jackson.datatype.jsr310) + implementation("com.google.apis:google-api-services-calendar:v3-rev411-1.25.0") + // Project dependencies implementation(project(":backend:feature:booking:core")) + implementation(project(":backend:feature:calendar-subscription")) } diff --git a/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/controller/CalendarNotificationsController.kt b/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/controller/CalendarNotificationsController.kt index c67206c1a9cdf6396e374735dd41c4c4ed301a43..028b7e080d5ebfefa6b6c164dd8f98f46e8371c5 100644 --- a/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/controller/CalendarNotificationsController.kt +++ b/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/controller/CalendarNotificationsController.kt @@ -1,15 +1,21 @@ package band.effective.office.backend.feature.notifications.controller -import org.springframework.web.bind.annotation.RestController -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.http.ResponseEntity +import band.effective.office.backend.feature.calendar.subscription.repository.ChannelRepository +import band.effective.office.backend.feature.calendar.subscription.service.GoogleCalendarService import band.effective.office.backend.feature.notifications.service.INotificationSender -import org.slf4j.LoggerFactory +import com.google.api.client.util.DateTime import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import jakarta.servlet.http.HttpServletRequest +import java.time.OffsetDateTime +import java.time.ZoneOffset +import kotlin.time.ExperimentalTime +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseEntity +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.RestController /** * Controller for Google calendar push notifications @@ -18,7 +24,10 @@ import jakarta.servlet.http.HttpServletRequest @RequestMapping("/notifications") @Tag(name = "Notifications", description = "API for handling notifications") class CalendarNotificationsController( - private val notificationSender: INotificationSender + private val notificationSender: INotificationSender, + private val deduplicator: NotificationDeduplicator, + private val channelRepository: ChannelRepository, + private val googleCalendarService: GoogleCalendarService, ) { private val logger = LoggerFactory.getLogger(CalendarNotificationsController::class.java) @@ -28,6 +37,7 @@ class CalendarNotificationsController( /** * Endpoint for receiving Google calendar push notifications */ + @OptIn(ExperimentalTime::class) @PostMapping @Operation( summary = "Receive Google calendar push notification", @@ -37,46 +47,43 @@ class CalendarNotificationsController( @RequestBody(required = false) payload: String?, request: HttpServletRequest ): ResponseEntity { - // Extract headers for deduplication - val messageNumber = request.getHeader("X-Goog-Message-Number") - val resourceState = request.getHeader("X-Goog-Resource-State") + val channelId = request.getHeader("X-Goog-Channel-ID") ?: return ResponseEntity.ok().build() + val resourceState = request.getHeader("X-Goog-Resource-State") ?: return ResponseEntity.ok().build() - logger.info("Received push notification: messageNumber={}, resourceState={}, payload=\n{}", - messageNumber, resourceState, payload) + if (resourceState != "exists") return ResponseEntity.ok().build() - // Check if this is a duplicate notification - if (messageNumber != null) { - if (processedMessageNumbers.contains(messageNumber)) { - logger.info("Skipping duplicate notification with message number: {}", messageNumber) - return ResponseEntity.ok().build() - } + val calendarId = channelRepository.findByChannelId(channelId)?.calendarId + ?: return ResponseEntity.ok().build() - // Add to processed set for future deduplication - processedMessageNumbers.add(messageNumber) + val updatedEvents = fetchRecentlyUpdatedEvents(calendarId) - // Limit the size of the set to prevent memory leaks - if (processedMessageNumbers.size > 1000) { - // Remove the oldest entries (assuming they're added in order) - val toRemove = processedMessageNumbers.size - 1000 - processedMessageNumbers.toList().take(toRemove).forEach { - processedMessageNumbers.remove(it) - } + for (event in updatedEvents) { + val eventId = event.id + + if (eventId != null && !deduplicator.isDuplicate(eventId)) { + logger.info("Triggering FCM push for event: ${event.summary}") + notificationSender.sendEmptyMessage("effectiveoffice-booking") + } else { + logger.info("Duplicate event ignored: $eventId") } - } else { - logger.warn("Received notification without X-Goog-Message-Number header") } + return ResponseEntity.ok().build() + } - // Process the notification - // Note: For duplicate notifications, we've already returned at line 51, - // so this code is only executed for non-duplicate notifications - - // Only send message if resourceState is "exists" - if (resourceState == "exists") { - notificationSender.sendEmptyMessage("booking") - } else { - logger.info("Skipping notification with resourceState: {}", resourceState) - } + private fun fetchRecentlyUpdatedEvents( + calendarId: String + ): List { + val now = OffsetDateTime.now(ZoneOffset.UTC) + val updatedMin = now.minusMinutes(2) - return ResponseEntity.ok().build() + return googleCalendarService.createCalendarService().events() + .list(calendarId) + .setShowDeleted(false) + .setSingleEvents(true) + .setMaxResults(10) + .setOrderBy("updated") + .setUpdatedMin(DateTime(updatedMin.toInstant().toEpochMilli())) + .execute() + .items } } diff --git a/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/controller/NotificationDeduplicator.kt b/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/controller/NotificationDeduplicator.kt new file mode 100644 index 0000000000000000000000000000000000000000..1ddba93e3c4b91d2f4c405cb803a7d1d6d31610b --- /dev/null +++ b/backend/feature/notifications/src/main/kotlin/band/effective/office/backend/feature/notifications/controller/NotificationDeduplicator.kt @@ -0,0 +1,30 @@ +package band.effective.office.backend.feature.notifications.controller + +import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Clock +import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import org.springframework.stereotype.Component + +@Component +class NotificationDeduplicator { + + private val ttlSeconds = 10L + + @OptIn(ExperimentalTime::class) + private val seenEvents = ConcurrentHashMap() + + @OptIn(ExperimentalTime::class) + fun isDuplicate(eventId: String): Boolean { + val now = Clock.System.now() + + // Очистка устаревших + seenEvents.entries.removeIf { (_, timestamp) -> + timestamp + ttlSeconds.seconds < now + } + + // Добавим, если ещё нет + return seenEvents.putIfAbsent(eventId, now) != null + } +} \ No newline at end of file diff --git a/backend/feature/workspace/README.md b/backend/feature/workspace/README.md index 244173fb9574b5fae0aac41039ce513890f38853..e726db9331c297f15e3698a6d904a46537d56b6e 100644 --- a/backend/feature/workspace/README.md +++ b/backend/feature/workspace/README.md @@ -45,7 +45,7 @@ The module exposes the following REST endpoints: Represents a physical workspace in the office: - ID - Name -- Utilities (equipment and amenities) +- Utilities (equipment and amenities, with count from `workspace_utilities`) - Zone (location within the office) - Tag (type of workspace, e.g., "meeting", "desk") @@ -61,6 +61,12 @@ Represents equipment or amenities available in a workspace: - Icon URL - Count +### WorkspaceUtility +Links workspaces to utilities with quantity: +- Workspace ID +- Utility ID +- Count (number of utility items) + ## Integration The Workspace module integrates with: - Booking module for checking workspace availability diff --git a/backend/feature/workspace/src/main/kotlin/band/effective/office/backend/feature/workspace/core/repository/entity/UtilityEntity.kt b/backend/feature/workspace/src/main/kotlin/band/effective/office/backend/feature/workspace/core/repository/entity/UtilityEntity.kt index 9c6bad6c1714942ca70c36f41b298c99bfd8baed..18616dc0d39500dfead9e979a4ff1199ad7c5adf 100644 --- a/backend/feature/workspace/src/main/kotlin/band/effective/office/backend/feature/workspace/core/repository/entity/UtilityEntity.kt +++ b/backend/feature/workspace/src/main/kotlin/band/effective/office/backend/feature/workspace/core/repository/entity/UtilityEntity.kt @@ -1,6 +1,7 @@ package band.effective.office.backend.feature.workspace.core.repository.entity import jakarta.persistence.Column +import jakarta.persistence.OneToMany import jakarta.persistence.Entity import jakarta.persistence.Id import jakarta.persistence.Table @@ -15,4 +16,7 @@ data class UtilityEntity( val name: String, @Column(name = "icon_url", nullable = false, unique = true, length = 255) val iconUrl: String, + + @OneToMany(mappedBy = "utility") + val workspaceUtilities: List = emptyList() ) diff --git a/backend/feature/workspace/src/main/kotlin/band/effective/office/backend/feature/workspace/core/repository/entity/WorkspaceEntity.kt b/backend/feature/workspace/src/main/kotlin/band/effective/office/backend/feature/workspace/core/repository/entity/WorkspaceEntity.kt index 630c98bf249080f53d1096cb6cb33f2f480b6d6f..b0b8ff4403e89998e1a5a97c8962c5ca2704584a 100644 --- a/backend/feature/workspace/src/main/kotlin/band/effective/office/backend/feature/workspace/core/repository/entity/WorkspaceEntity.kt +++ b/backend/feature/workspace/src/main/kotlin/band/effective/office/backend/feature/workspace/core/repository/entity/WorkspaceEntity.kt @@ -4,8 +4,7 @@ import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.Id import jakarta.persistence.JoinColumn -import jakarta.persistence.JoinTable -import jakarta.persistence.ManyToMany +import jakarta.persistence.OneToMany import jakarta.persistence.ManyToOne import jakarta.persistence.Table import java.util.UUID @@ -19,13 +18,8 @@ data class WorkspaceEntity( val name: String, @Column(name = "tag", nullable = false, unique = false, length = 255) val tag: String, - @ManyToMany - @JoinTable( - name = "workspace_utilities", - joinColumns = [JoinColumn(name = "workspace_id")], - inverseJoinColumns = [JoinColumn(name = "utility_id")] - ) - val utilities: List = emptyList(), + @OneToMany(mappedBy = "workspace") + val workspaceUtilities: List = emptyList(), @ManyToOne @JoinColumn(name = "zone_id") diff --git a/backend/feature/workspace/src/main/kotlin/band/effective/office/backend/feature/workspace/core/repository/entity/WorkspaceUtilityEntity.kt b/backend/feature/workspace/src/main/kotlin/band/effective/office/backend/feature/workspace/core/repository/entity/WorkspaceUtilityEntity.kt new file mode 100644 index 0000000000000000000000000000000000000000..4c8ba22e6da2720a89ed7303cbce626ef755c51c --- /dev/null +++ b/backend/feature/workspace/src/main/kotlin/band/effective/office/backend/feature/workspace/core/repository/entity/WorkspaceUtilityEntity.kt @@ -0,0 +1,20 @@ +package band.effective.office.backend.feature.workspace.core.repository.entity + +import jakarta.persistence.* + +@Entity +@Table(name = "workspace_utilities") +data class WorkspaceUtilityEntity( + @Id + @ManyToOne + @JoinColumn(name = "workspace_id") + val workspace: WorkspaceEntity, + + @Id + @ManyToOne + @JoinColumn(name = "utility_id") + val utility: UtilityEntity, + + @Column(name = "count", nullable = false) + val count: Int +) \ No newline at end of file diff --git a/backend/feature/workspace/src/main/kotlin/band/effective/office/backend/feature/workspace/core/repository/mapper/WokplaceMapper.kt b/backend/feature/workspace/src/main/kotlin/band/effective/office/backend/feature/workspace/core/repository/mapper/WorkspaceMapper.kt similarity index 67% rename from backend/feature/workspace/src/main/kotlin/band/effective/office/backend/feature/workspace/core/repository/mapper/WokplaceMapper.kt rename to backend/feature/workspace/src/main/kotlin/band/effective/office/backend/feature/workspace/core/repository/mapper/WorkspaceMapper.kt index 0b85ec15bb5b7bb30510efbbea9632e5cbc95358..b0b0a6a4fc2db41643ac4bc6054d41807bfd37eb 100644 --- a/backend/feature/workspace/src/main/kotlin/band/effective/office/backend/feature/workspace/core/repository/mapper/WokplaceMapper.kt +++ b/backend/feature/workspace/src/main/kotlin/band/effective/office/backend/feature/workspace/core/repository/mapper/WorkspaceMapper.kt @@ -3,25 +3,25 @@ package band.effective.office.backend.feature.workspace.core.repository.mapper 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.workspace.core.repository.entity.UtilityEntity import band.effective.office.backend.feature.workspace.core.repository.entity.WorkspaceEntity +import band.effective.office.backend.feature.workspace.core.repository.entity.WorkspaceUtilityEntity import org.springframework.stereotype.Component @Component -object WokplaceMapper { +object WorkspaceMapper { fun toDomain(entity: WorkspaceEntity): Workspace = Workspace( id = entity.id, name = entity.name, tag = entity.tag, - utilities = entity.utilities.map(WokplaceMapper::toDomain), + utilities = entity.workspaceUtilities.map { toDomain(it) }, zone = entity.zone?.let { WorkspaceZone(it.id, it.name) }, ) - private fun toDomain(entity: UtilityEntity): Utility = Utility( - id = entity.id, - name = entity.name, - iconUrl = entity.iconUrl, - count = 0, // TODO + private fun toDomain(entity: WorkspaceUtilityEntity): Utility = Utility( + id = entity.utility.id, + name = entity.utility.name, + iconUrl = entity.utility.iconUrl, + count = entity.count ) } \ No newline at end of file diff --git a/backend/feature/workspace/src/main/kotlin/band/effective/office/backend/feature/workspace/core/service/WorkspaceService.kt b/backend/feature/workspace/src/main/kotlin/band/effective/office/backend/feature/workspace/core/service/WorkspaceService.kt index 50fc353975f8befc2ee3eb515a8929f7f28dab4d..05c92dcae9003c20d9064c7b35d601e52ab7dfad 100644 --- a/backend/feature/workspace/src/main/kotlin/band/effective/office/backend/feature/workspace/core/service/WorkspaceService.kt +++ b/backend/feature/workspace/src/main/kotlin/band/effective/office/backend/feature/workspace/core/service/WorkspaceService.kt @@ -7,7 +7,7 @@ import band.effective.office.backend.core.domain.service.WorkspaceDomainService import band.effective.office.backend.feature.workspace.core.repository.CalendarIdRepository import band.effective.office.backend.feature.workspace.core.repository.WorkspaceRepository import band.effective.office.backend.feature.workspace.core.repository.mapper.CalendarIdMapper -import band.effective.office.backend.feature.workspace.core.repository.mapper.WokplaceMapper +import band.effective.office.backend.feature.workspace.core.repository.mapper.WorkspaceMapper import java.util.UUID import kotlin.jvm.optionals.getOrNull import org.springframework.stereotype.Service @@ -27,7 +27,7 @@ class WorkspaceService( */ @Transactional(readOnly = true) override fun findById(id: UUID): Workspace? { - return repository.findById(id).getOrNull()?.let { WokplaceMapper.toDomain(it) } + return repository.findById(id).getOrNull()?.let { WorkspaceMapper.toDomain(it) } } /** @@ -38,7 +38,7 @@ class WorkspaceService( */ @Transactional(readOnly = true) override fun findAllByTag(tag: String): List { - return repository.findAllByTag(tag).map { WokplaceMapper.toDomain(it) } + return repository.findAllByTag(tag).map { WorkspaceMapper.toDomain(it) } } /** diff --git a/clients/tablet/composeApp/build.gradle.kts b/clients/tablet/composeApp/build.gradle.kts index 080f844b1501061d53f464b763ea8e8383179a27..25a3b5a3810acd3d723c768bf73cc47710cd1bb0 100644 --- a/clients/tablet/composeApp/build.gradle.kts +++ b/clients/tablet/composeApp/build.gradle.kts @@ -85,7 +85,7 @@ android { applicationId = "band.effective.office.tablet" versionCode = 1 - versionName = "1.0.0" + versionName = "0.0.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/App.kt b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/App.kt index d1a8ff8ca72365e84b984dca43cfe0c4a1b66214..c5c283e46b39e427bbf8d511c51159197d93e29d 100644 --- a/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/App.kt +++ b/clients/tablet/composeApp/src/androidMain/kotlin/band/effective/office/tablet/App.kt @@ -1,6 +1,7 @@ package band.effective.office.tablet import android.app.Application +import android.util.Log import band.effective.office.tablet.core.domain.model.SettingsManager import band.effective.office.tablet.di.KoinInitializer import com.google.firebase.messaging.FirebaseMessaging diff --git a/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/di/FirebaseTopicsModule.kt b/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/di/FirebaseTopicsModule.kt index 6bf801ee591a8e3f5caf4aac3203fbd40194200d..ffb896de70ca42cd76240604673657cea474817c 100644 --- a/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/di/FirebaseTopicsModule.kt +++ b/clients/tablet/composeApp/src/commonMain/kotlin/band/effective/office/tablet/di/FirebaseTopicsModule.kt @@ -4,5 +4,5 @@ import org.koin.core.qualifier.named import org.koin.dsl.module val firebaseTopicsModule = module { - single(named("FireBaseTopics")) { listOf("workspace", "user", "booking") } + single(named("FireBaseTopics")) { listOf("effectiveoffice-workspace", "effectiveoffice-user", "effectiveoffice-booking") } } \ No newline at end of file diff --git a/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/api/BookingApi.kt b/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/api/BookingApi.kt index 7942b423128253403c38096ce7098de1a0f7803c..a77fa477bc8f50f77aa7bf5113901eaee47991d3 100644 --- a/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/api/BookingApi.kt +++ b/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/api/BookingApi.kt @@ -82,7 +82,6 @@ interface BookingApi { */ fun subscribeOnBookingsList( workspaceId: String, - scope: CoroutineScope ): Flow>> /** diff --git a/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/api/Collector.kt b/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/api/Collector.kt index 44620f3734e69231784d05a69c12f2c15e063321..2164723cc9bd50ed1f8372e6dfcef856e9fd7f0c 100644 --- a/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/api/Collector.kt +++ b/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/api/Collector.kt @@ -18,17 +18,8 @@ class Collector(defaultValue: T) { private val collection = MutableStateFlow(CollectableElement(defaultValue, 0)) - /** - * Creates a flow that emits the collected values. - * @param scope CoroutineScope for sharing the flow - * @return Flow of collected values - */ - fun flow(scope: CoroutineScope) = - collection.map { it.value }.shareIn( - scope = scope, - started = SharingStarted.Lazily, - replay = 1 - ) + fun flow() = + collection.map { it.value } /** * Emits a new value to the collection. diff --git a/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/api/impl/BookingApiImpl.kt b/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/api/impl/BookingApiImpl.kt index f836f9b7db3f4486a81345c52599dfe3ec82dac5..fbe34e5b831d9c6f7498c3637a7a0135b6a9b6ec 100644 --- a/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/api/impl/BookingApiImpl.kt +++ b/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/api/impl/BookingApiImpl.kt @@ -141,10 +141,9 @@ class BookingApiImpl( override fun subscribeOnBookingsList( workspaceId: String, - scope: CoroutineScope ): Flow>> { - return collector.flow(scope) - .filter { it == "booking" } + return collector.flow() + .filter { it == "effectiveoffice-booking" } .map { Either.Success(listOf()) } } diff --git a/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/api/impl/UserApiImpl.kt b/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/api/impl/UserApiImpl.kt index ea15dcbd3b3fd4170fe9c4b6d8091cb843787496..688d5077fa41615368312a7d698e83deb98afd8d 100644 --- a/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/api/impl/UserApiImpl.kt +++ b/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/api/impl/UserApiImpl.kt @@ -88,8 +88,8 @@ class UserApiImpl( } override fun subscribeOnOrganizersList(scope: CoroutineScope): Flow>> { - return collector.flow(scope) - .filter { it == "organizer" } + return collector.flow() + .filter { it == "effectiveoffice-organizer" } .map { Either.Success(listOf()) } } } \ No newline at end of file diff --git a/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/api/impl/WorkspaceApiImpl.kt b/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/api/impl/WorkspaceApiImpl.kt index 72fba329ff8d9a6120402781ef03f52e974fd165..baa97f19e9e44da2ef619af94d3b420bb29d4975 100644 --- a/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/api/impl/WorkspaceApiImpl.kt +++ b/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/api/impl/WorkspaceApiImpl.kt @@ -102,8 +102,8 @@ class WorkspaceApiImpl( id: String, scope: CoroutineScope ): Flow> { - return collector.flow(scope) - .filter { it == "workspace" } + return collector.flow() + .filter { it == "effectiveoffice-workspace" } .map { Either.Success( WorkspaceDTO( diff --git a/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/dto/booking/BookingResponseDTO.kt b/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/dto/booking/BookingResponseDTO.kt index d64f5779acc55bedbd406141c0057bd4bdf0803c..4c091eb69a709e3cb2f7688197b0a48712d4ca22 100644 --- a/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/dto/booking/BookingResponseDTO.kt +++ b/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/dto/booking/BookingResponseDTO.kt @@ -14,6 +14,7 @@ import kotlinx.serialization.Serializable * @property endBooking End time of the booking (Unix timestamp) * @property recurrence Recurrence pattern for recurring bookings * @property recurringBookingId ID of the recurring booking series this booking belongs to + * @property isEditable Flag indicating if the booking can be edited or deleted from tablet client */ @Serializable data class BookingResponseDTO( @@ -24,5 +25,6 @@ data class BookingResponseDTO( val beginBooking: Long, val endBooking: Long, val recurrence: RecurrenceDTO? = null, - val recurringBookingId: String? = null -) \ No newline at end of file + val recurringBookingId: String? = null, + val isEditable: Boolean = true +) diff --git a/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/mapper/EventInfoMapper.kt b/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/mapper/EventInfoMapper.kt index 843ea4238870f3d32c731cb5bf70559717429690..e0c2f9bb9259689c916aa433752d723496fdf28f 100644 --- a/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/mapper/EventInfoMapper.kt +++ b/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/mapper/EventInfoMapper.kt @@ -19,6 +19,7 @@ class EventInfoMapper { finishTime = Instant.fromEpochMilliseconds(dto.endBooking).asLocalDateTime, organizer = dto.owner?.toOrganizer() ?: Organizer.default, isLoading = false, + isEditable = dto.isEditable, ) fun mapToRequest(eventInfo: EventInfo, roomInfo: RoomInfo): BookingRequestDTO = BookingRequestDTO( @@ -28,4 +29,4 @@ class EventInfoMapper { participantEmails = listOfNotNull(eventInfo.organizer.email), workspaceId = roomInfo.id ) -} \ No newline at end of file +} diff --git a/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/repository/OrganizerRepositoryImpl.kt b/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/repository/OrganizerRepositoryImpl.kt index 8fba0240b09d13aefd57838ac4ddb97c5e819bea..119b1676a416de52560b943f209d7fa4cd28565a 100644 --- a/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/repository/OrganizerRepositoryImpl.kt +++ b/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/repository/OrganizerRepositoryImpl.kt @@ -63,6 +63,6 @@ class OrganizerRepositoryImpl(private val api: UserApi) : OrganizerRepository { ) }, successMapper = { user -> - user.filter { it.tag == "employer" }.map { it.toOrganizer() } + user.filter { it.tag == "employer" }.map { it.toOrganizer() }.sortedBy { it.fullName } }) } diff --git a/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/repository/RoomRepositoryImpl.kt b/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/repository/RoomRepositoryImpl.kt index 8fdabeb61c1956411035f4edfcbd124cd4b07b95..810f66569fad6afcd71312f87a85cda3f8426da5 100644 --- a/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/repository/RoomRepositoryImpl.kt +++ b/clients/tablet/core/data/src/commonMain/kotlin/band/effective/office/tablet/core/data/repository/RoomRepositoryImpl.kt @@ -29,10 +29,8 @@ class RoomRepositoryImpl( private val roomInfoMapper: RoomInfoMapper, ) : RoomRepository { - private val scope = CoroutineScope(Dispatchers.IO) - override fun subscribeOnUpdates(): Flow>, List>> { - return api.subscribeOnBookingsList("", scope) + return api.subscribeOnBookingsList("") .map { response -> when (response) { is Either.Error -> Either.Error(ErrorWithData(response.error, null)) diff --git a/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/model/EventInfo.kt b/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/model/EventInfo.kt index 830ab01e7e3ee53a305ab9c6aa7cdabb3d46fdd3..ed4e51f97e7e2b957bc911ea44c0430f6fef5187 100644 --- a/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/model/EventInfo.kt +++ b/clients/tablet/core/domain/src/commonMain/kotlin/band/effective/office/tablet/core/domain/model/EventInfo.kt @@ -14,6 +14,7 @@ data class EventInfo( val organizer: Organizer, val id: String, val isLoading: Boolean, + val isEditable: Boolean = true, ) { init { // Validate that start time is before finish time diff --git a/clients/tablet/core/ui/src/commonMain/composeResources/values/strings.xml b/clients/tablet/core/ui/src/commonMain/composeResources/values/strings.xml index 6485666e7d6354c9284591b9b8264a55cc9a0915..3ccdf63f76759e4c30816f2c672b21b69629f637 100644 --- a/clients/tablet/core/ui/src/commonMain/composeResources/values/strings.xml +++ b/clients/tablet/core/ui/src/commonMain/composeResources/values/strings.xml @@ -32,5 +32,5 @@ Занято %1$s %1$s брони Слот загружается - Error + Ошибка. Повторите попытку позже \ No newline at end of file diff --git a/clients/tablet/core/ui/src/commonMain/kotlin/band/effective/office/tablet/core/ui/common/AlertButton.kt b/clients/tablet/core/ui/src/commonMain/kotlin/band/effective/office/tablet/core/ui/common/AlertButton.kt index b013fa213baae10fd8ded2040b61dc13a8a98f13..2b91945d73ab8b41e2081cb6c8a5f0bd88101716 100644 --- a/clients/tablet/core/ui/src/commonMain/kotlin/band/effective/office/tablet/core/ui/common/AlertButton.kt +++ b/clients/tablet/core/ui/src/commonMain/kotlin/band/effective/office/tablet/core/ui/common/AlertButton.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.unit.dp fun AlertButton( modifier: Modifier = Modifier, onClick: () -> Unit, + enabled: Boolean = true, content: @Composable RowScope.() -> Unit ) { Button( @@ -26,8 +27,9 @@ fun AlertButton( color = MaterialTheme.colorScheme.onPrimary ), colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent), - onClick = { onClick() } + onClick = { onClick() }, + enabled = enabled ) { content() } -} \ No newline at end of file +} diff --git a/clients/tablet/feature/bookingEditor/src/commonMain/composeResources/values/strings.xml b/clients/tablet/feature/bookingEditor/src/commonMain/composeResources/values/strings.xml index ad06bbf1fa51b77f268d9d572475a5071bde2180..c73b265e276f65216228bcd4ab75fefc66cdd2b4 100644 --- a/clients/tablet/feature/bookingEditor/src/commonMain/composeResources/values/strings.xml +++ b/clients/tablet/feature/bookingEditor/src/commonMain/composeResources/values/strings.xml @@ -6,4 +6,7 @@ Изменить Удалить бронь Произошла ошибка + Ошибка, выбрана неправильная дата + Error creating event + Error deleting event" \ No newline at end of file diff --git a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/BookingEditor.kt b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/BookingEditor.kt index b2a91da835582ba9842c64a0c0fc1fedaa050eee..92e6b5ee2f8a562fdb53985f8c9514283d8fc122 100644 --- a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/BookingEditor.kt +++ b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/BookingEditor.kt @@ -11,10 +11,17 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -40,6 +47,9 @@ import band.effective.office.tablet.feature.bookingEditor.booking_view_title import band.effective.office.tablet.feature.bookingEditor.create_view_title import band.effective.office.tablet.feature.bookingEditor.delete_button import band.effective.office.tablet.feature.bookingEditor.error +import band.effective.office.tablet.feature.bookingEditor.error_creating_event +import band.effective.office.tablet.feature.bookingEditor.error_deleting_event +import band.effective.office.tablet.feature.bookingEditor.is_time_in_past_error import band.effective.office.tablet.feature.bookingEditor.presentation.datetimepicker.DateTimePickerModalView import band.effective.office.tablet.feature.bookingEditor.update_button import com.arkivanov.decompose.extensions.compose.stack.Children @@ -109,6 +119,8 @@ fun BookingEditor( start = state.event.startTime.format(timeFormatter), finish = state.event.finishTime.format(timeFormatter), room = component.roomName, + isTimeInPastError = state.isTimeInPastError, + isEditable = state.event.isEditable ) } } @@ -149,9 +161,21 @@ private fun BookingEditor( isNewEvent: Boolean, start: String, finish: String, - room: String + room: String, + isTimeInPastError: Boolean, + isEditable: Boolean = true ) { + val snackbarHostState = remember { SnackbarHostState() } + val timeInPastErrorMessage = stringResource(Res.string.is_time_in_past_error) + LaunchedEffect(isTimeInPastError) { + if (isTimeInPastError) { + snackbarHostState.showSnackbar( + message = timeInPastErrorMessage, + duration = SnackbarDuration.Short + ) + } + } Box { Column( modifier = Modifier @@ -212,7 +236,7 @@ private fun BookingEditor( when { isCreateLoad -> Loader() isCreateError -> Text( - text = "Error creating event", // Ideally, this should be a string resource + text = stringResource(Res.string.error_creating_event), style = MaterialTheme.typography.h6 ) else -> Text( @@ -225,7 +249,7 @@ private fun BookingEditor( SuccessButton( modifier = Modifier.fillMaxWidth().height(60.dp), onClick = onUpdateEvent, - enable = enableUpdateButton && !isUpdateLoad + enable = enableUpdateButton && !isUpdateLoad && isEditable ) { when { isUpdateLoad -> Loader() @@ -242,12 +266,13 @@ private fun BookingEditor( Spacer(modifier = Modifier.height(10.dp)) AlertButton( modifier = Modifier.fillMaxWidth().height(60.dp), - onClick = onDeleteEvent + onClick = onDeleteEvent, + enabled = isEditable ) { when { isDeleteLoad -> Loader() isDeleteError -> Text( - text = "Error deleting event", // Ideally, this should be a string resource + text = stringResource(Res.string.error_deleting_event), style = MaterialTheme.typography.h6 ) @@ -266,5 +291,22 @@ private fun BookingEditor( .fillMaxWidth().padding(35.dp), onDismissRequest = onDismissRequest ) + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 32.dp) + ) { snackbarData -> + Snackbar( + modifier = Modifier.padding(16.dp), + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) { + Text( + text = snackbarData.visuals.message, + style = MaterialTheme.typography.bodyMedium + ) + } + } } } diff --git a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/BookingEditorComponent.kt b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/BookingEditorComponent.kt index cd67f912a0c1591d0fc565632f18c77322ecdb8c..08f4e5a96841c05894a511bc596d6fbee767ca69 100644 --- a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/BookingEditorComponent.kt +++ b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/BookingEditorComponent.kt @@ -11,8 +11,6 @@ import band.effective.office.tablet.core.domain.useCase.OrganizersInfoUseCase import band.effective.office.tablet.core.domain.useCase.UpdateBookingUseCase import band.effective.office.tablet.core.domain.util.asInstant import band.effective.office.tablet.core.domain.util.asLocalDateTime -import band.effective.office.tablet.core.domain.util.currentLocalDateTime -import band.effective.office.tablet.core.domain.util.defaultTimeZone import band.effective.office.tablet.core.ui.common.ModalWindow import band.effective.office.tablet.core.ui.utils.componentCoroutineScope import band.effective.office.tablet.feature.bookingEditor.presentation.datetimepicker.DateTimePickerComponent @@ -31,8 +29,10 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock import kotlinx.datetime.LocalDateTime -import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime import kotlinx.serialization.Serializable import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -216,7 +216,7 @@ class BookingEditorComponent( inputError: Boolean, busyEvent: Boolean ) = mutableState.update { - it.copy(enableUpdateButton = !inputError && !busyEvent) + it.copy(enableUpdateButton = !inputError && !busyEvent && !it.isTimeInPastError) } /** @@ -229,12 +229,14 @@ class BookingEditorComponent( duration = duration, organizer = selectOrganizer ) + val isTimeInPast = newDate <= getCurrentTime() updateStateWithNewEventDetails( newDate = newDate, newDuration = duration, newOrganizer = selectOrganizer, - busyEvents = busyEvents + busyEvents = busyEvents, + isTimeInPast = isTimeInPast ) if (selectOrganizer != Organizer.default) { @@ -260,45 +262,33 @@ class BookingEditorComponent( val resolvedOrganizer = organizers.firstOrNull { it.fullName == newOrganizer.fullName } ?: event.organizer - + val isTimeInPast = newDate <= getCurrentTime() val busyEvents = checkForBusyEvents( date = newDate, duration = newDuration, organizer = resolvedOrganizer ) - if (isValidEventTime(newDate, newDuration)) { - updateStateWithNewEventDetails( - newDate = newDate, - newDuration = newDuration, - newOrganizer = resolvedOrganizer, - busyEvents = busyEvents - ) + updateStateWithNewEventDetails( + newDate = newDate, + newDuration = newDuration, + newOrganizer = resolvedOrganizer, + busyEvents = busyEvents, + isTimeInPast = isTimeInPast + ) - updateButtonState( - inputError = !organizers.contains(resolvedOrganizer), - busyEvent = busyEvents.isNotEmpty() - ) - } + updateButtonState( + inputError = !organizers.contains(resolvedOrganizer), + busyEvent = busyEvents.isNotEmpty() + ) } } /** - * Checks if the event time is valid - */ - private fun isValidEventTime(date: LocalDateTime, duration: Int): Boolean { - val today = getTodayStartTime() - val officeEndTime = OfficeTime.finishWorkTime(date.date) - val eventEndTime = date.asInstant.plus(duration.minutes).asLocalDateTime - - return duration > 0 && date > today && eventEndTime < officeEndTime - } - - /** - * Gets the start time of today + * Gets the current time */ - private fun getTodayStartTime(): LocalDateTime = - currentLocalDateTime.date.atStartOfDayIn(defaultTimeZone).asLocalDateTime + private fun getCurrentTime(): LocalDateTime = + Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) /** * Checks for busy events that conflict with the given parameters @@ -343,7 +333,8 @@ class BookingEditorComponent( newDate: LocalDateTime, newDuration: Int, newOrganizer: Organizer, - busyEvents: List + busyEvents: List, + isTimeInPast: Boolean ) { val updatedEvent = createEventInfo( id = state.value.event.id, @@ -358,7 +349,8 @@ class BookingEditorComponent( duration = newDuration, selectOrganizer = newOrganizer, event = updatedEvent, - isBusyEvent = busyEvents.isNotEmpty() + isBusyEvent = busyEvents.isNotEmpty(), + isTimeInPastError = isTimeInPast ) } } diff --git a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/State.kt b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/State.kt index d86db2d11a80bfe49aeac00ffca9f2858596a661..aeed462f0b8d5b94f0490c4b769e50935373572f 100644 --- a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/State.kt +++ b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/State.kt @@ -23,7 +23,8 @@ data class State( val isErrorCreate: Boolean, val showSelectDate: Boolean, val enableUpdateButton: Boolean, - val isBusyEvent: Boolean + val isBusyEvent: Boolean, + val isTimeInPastError: Boolean ) { companion object { val defaultValue = State( @@ -44,7 +45,8 @@ data class State( isErrorCreate = false, showSelectDate = false, enableUpdateButton = false, - isBusyEvent = false + isBusyEvent = false, + isTimeInPastError = false ) } diff --git a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/DatePickerView.kt b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/DatePickerView.kt index 2c81f87c5bb85a5f94e8daee17c9df3389ab1ad2..4e478e31bb3b824f1301b9ecaa12add26dac08c2 100644 --- a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/DatePickerView.kt +++ b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/DatePickerView.kt @@ -6,9 +6,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import band.effective.office.tablet.core.domain.util.asInstant import band.effective.office.tablet.core.domain.util.asLocalDateTime +import band.effective.office.tablet.core.ui.theme.LocalCustomColorsPalette import com.mohamedrejeb.calf.ui.datepicker.AdaptiveDatePicker import com.mohamedrejeb.calf.ui.datepicker.rememberAdaptiveDatePickerState import kotlinx.datetime.Instant @@ -47,7 +47,7 @@ fun DatePickerView( state = state, modifier = modifier, colors = DatePickerDefaults.colors( - containerColor = Color.Transparent, + containerColor = LocalCustomColorsPalette.current.elevationBackground, ), ) } diff --git a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/TimePickerView.kt b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/TimePickerView.kt index 608f6b07a5c597217642d655714ab00cf132395d..3f5bdca478cea5f88f73c817d505aa2b0fab2cec 100644 --- a/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/TimePickerView.kt +++ b/clients/tablet/feature/bookingEditor/src/commonMain/kotlin/band/effective/office/tablet/feature/bookingEditor/presentation/datetimepicker/components/TimePickerView.kt @@ -1,10 +1,12 @@ package band.effective.office.tablet.feature.bookingEditor.presentation.datetimepicker.components import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TimePickerDefaults import androidx.compose.material3.TimePickerLayoutType import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier +import band.effective.office.tablet.core.ui.theme.LocalCustomColorsPalette import com.mohamedrejeb.calf.ui.timepicker.AdaptiveTimePicker import com.mohamedrejeb.calf.ui.timepicker.rememberAdaptiveTimePickerState import kotlinx.datetime.LocalDateTime @@ -32,5 +34,8 @@ fun TimePickerView( state = state, modifier = modifier, layoutType = TimePickerLayoutType.Vertical, + colors = TimePickerDefaults.colors( + containerColor = LocalCustomColorsPalette.current.elevationBackground, + ) ) } \ No newline at end of file diff --git a/clients/tablet/feature/fastbooking/src/commonMain/kotlin/band/effective/office/tablet/feature/fastBooking/presentation/FastBookingComponent.kt b/clients/tablet/feature/fastbooking/src/commonMain/kotlin/band/effective/office/tablet/feature/fastBooking/presentation/FastBookingComponent.kt index 170c5ce57ad708892849e869a08cea37189c81ac..bf9b8e016ebffec7633816fd546be9234e831d43 100644 --- a/clients/tablet/feature/fastbooking/src/commonMain/kotlin/band/effective/office/tablet/feature/fastBooking/presentation/FastBookingComponent.kt +++ b/clients/tablet/feature/fastbooking/src/commonMain/kotlin/band/effective/office/tablet/feature/fastBooking/presentation/FastBookingComponent.kt @@ -29,6 +29,7 @@ import kotlinx.serialization.Serializable import org.koin.core.component.KoinComponent import org.koin.core.component.inject import kotlin.time.Duration.Companion.minutes +import kotlinx.coroutines.delay /** * Component responsible for fast booking of rooms. @@ -153,6 +154,7 @@ class FastBookingComponent( when (val result = createFastBookingUseCase(room, eventInfo)) { is Either.Success -> { + delay(2000) // NOTE(radchenko): wait for the event to be created in an external service handleSuccessfulEventCreation(room, eventInfo, result.data.id) } @@ -215,6 +217,7 @@ class FastBookingComponent( when (val result = deleteBookingUseCase(room, state.value.event)) { is Either.Success -> { + delay(3000) // NOTE(radchenko): wait for the event to be created in an external service mutableState.update { it.copy(isLoad = false) } onCloseRequest() } diff --git a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/components/uiComponent/BusyRoomInfoComponent.kt b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/components/uiComponent/BusyRoomInfoComponent.kt index e46c40ccc1327d7808e8a80845529e2030f7c8c3..563389c80ac25756c1303b61f61058551823c432 100644 --- a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/components/uiComponent/BusyRoomInfoComponent.kt +++ b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/components/uiComponent/BusyRoomInfoComponent.kt @@ -74,23 +74,25 @@ fun BusyRoomInfoComponent( color = roomInfoColor ) Spacer(modifier = Modifier.height(10.dp)) - Button( - modifier = Modifier - .clip(shape = RoundedCornerShape(40.dp)) - .height(45.dp) - .background(color = backgroundColor) - .border( - width = 3.dp, - color = roomInfoColor, - shape = RoundedCornerShape(40.dp), + if (event.isEditable) { + Button( + modifier = Modifier + .clip(shape = RoundedCornerShape(40.dp)) + .height(45.dp) + .background(color = backgroundColor) + .border( + width = 3.dp, + color = roomInfoColor, + shape = RoundedCornerShape(40.dp), + ), + colors = ButtonDefaults.buttonColors( + containerColor = colorButton ), - colors = ButtonDefaults.buttonColors( - containerColor = colorButton - ), - interactionSource = interactionSource, - onClick = onButtonClick - ) { - Text(text = stringResource(Res.string.stop_meeting_button), color = colorTextButton) + interactionSource = interactionSource, + onClick = onButtonClick + ) { + Text(text = stringResource(Res.string.stop_meeting_button), color = colorTextButton) + } } } } diff --git a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/components/uiComponent/FastBookingRightSide.kt b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/components/uiComponent/FastBookingRightSide.kt index b83d588f6b917c9431e4edb4f972d478673557a2..43c4186451e3a0fa8feaaee2e14e582e98605bf3 100644 --- a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/components/uiComponent/FastBookingRightSide.kt +++ b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/components/uiComponent/FastBookingRightSide.kt @@ -87,13 +87,13 @@ fun RoomList(list: List, indexSelectRoom: Int, onClick: (Int) -> Unit) list.forEachIndexed() { index, roomInfo -> RoomButton( modifier = Modifier + .clip(CircleShape) .background( color = if (index == indexSelectRoom) MaterialTheme.colorScheme.surface else Color.Transparent, shape = CircleShape ) .clickable { onClick(index) } .fillMaxWidth() - .clip(CircleShape) .padding(10.dp), room = roomInfo, ) diff --git a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/components/uiComponent/RoomInfoLeftPanel.kt b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/components/uiComponent/RoomInfoLeftPanel.kt index d41ab60836b9e8d0ef1768c54dd60b02f9b58940..7171e485a82430803d3953f34b01c647298267d9 100644 --- a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/components/uiComponent/RoomInfoLeftPanel.kt +++ b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/components/uiComponent/RoomInfoLeftPanel.kt @@ -22,6 +22,7 @@ import band.effective.office.tablet.core.ui.date.DateTimeView import band.effective.office.tablet.feature.main.components.RoomInfoComponent import band.effective.office.tablet.feature.slot.presentation.SlotComponent import band.effective.office.tablet.feature.slot.presentation.SlotIntent +import band.effective.office.tablet.feature.slot.presentation.SlotUi import band.effective.office.tablet.feature.slot.presentation.components.SlotView import kotlin.time.ExperimentalTime import kotlinx.datetime.LocalDateTime @@ -88,7 +89,8 @@ fun RoomInfoLeftPanel( ) { SlotView( slotUi = it, - onClick = { slotComponent.sendIntent(SlotIntent.ClickOnSlot(it)) }, + onClick = { slotComponent.sendIntent(SlotIntent.ClickToEdit(this)) }, + onToggle = { (it as? SlotUi.MultiSlot)?.let { slotComponent.sendIntent(SlotIntent.ClickToToggle(it)) } }, onCancel = { deleteSlot -> slotComponent.sendIntent(SlotIntent.OnCancelDelete(deleteSlot)) } ) } diff --git a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/main/MainComponent.kt b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/main/MainComponent.kt index 69dcbdab5a2420b3dd8b84bb825af4cb277d440f..9439e74324f6c1635d30f8ddebef5a4bbde5df1d 100644 --- a/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/main/MainComponent.kt +++ b/clients/tablet/feature/main/src/commonMain/kotlin/band/effective/office/tablet/feature/main/presentation/main/MainComponent.kt @@ -66,7 +66,6 @@ class MainComponent( // Timers private val currentTimeTimer = BootstrapperTimer(timerUseCase, coroutineScope) - private val currentRoomTimer = BootstrapperTimer(timerUseCase, coroutineScope) // State management private val mutableState = MutableStateFlow(State.defaultState) @@ -106,7 +105,7 @@ class MainComponent( updateUseCase.updateFlow().collect { delay(1.seconds) withContext(Dispatchers.Main) { - loadRooms() + loadRooms(state.value.indexSelectRoom) } } } @@ -233,7 +232,6 @@ class MainComponent( */ private fun updateSelectedDate(intent: Intent.OnUpdateSelectDate) { currentTimeTimer.restart() - currentRoomTimer.restart() val selectedDate = state.value.selectedDate val newDate = calculateNewDate(selectedDate, intent.updateInDays) @@ -260,8 +258,6 @@ class MainComponent( * Selects a room by index. */ private fun selectRoom(index: Int) { - currentRoomTimer.restart() - mutableState.update { it.copy( indexSelectRoom = index, @@ -298,9 +294,9 @@ class MainComponent( /** * Loads room information. */ - private fun loadRooms() = coroutineScope.launch { + private fun loadRooms(roomIndex: Int? = null) = coroutineScope.launch { val result = roomInfoUseCase() - val roomsResult = processRoomInfoResult(result) + val roomsResult = processRoomInfoResult(result, roomIndex) updateStateWithRoomsResult(roomsResult) } @@ -308,7 +304,10 @@ class MainComponent( /** * Processes the result of loading room information. */ - private fun processRoomInfoResult(result: Either>, List>): RoomsResult { + private fun processRoomInfoResult( + result: Either>, List>, + roomIndex: Int? = null, + ): RoomsResult { return when (result) { is Either.Error>> -> RoomsResult( isSuccess = false, @@ -317,10 +316,7 @@ class MainComponent( ) is Either.Success> -> { - val selectedRoomName = checkSettingsUseCase() - val roomIndex = - result.data.indexOfFirst { it.name == selectedRoomName } - .coerceAtLeast(0) + val roomIndex = roomIndex ?: result.data.indexOfFirst { it.name == checkSettingsUseCase() } RoomsResult( isSuccess = true, roomList = result.data, @@ -376,7 +372,7 @@ class MainComponent( roomInfoUseCase.updateCache() } - loadRooms() + loadRooms(roomIndex) currentState.roomList.getOrNull(roomIndex)?.let { roomInfo -> updateComponents(roomInfo, currentState.selectedDate) diff --git a/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/SlotComponent.kt b/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/SlotComponent.kt index 31dc5a16aa8172f1b7a7b4022e097827ca3061b7..74bf5858deb376d9000c387ed8969e75945a49a0 100644 --- a/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/SlotComponent.kt +++ b/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/SlotComponent.kt @@ -58,16 +58,6 @@ class SlotComponent( updateSlots(uiSlots) } } - /*coroutineScope.launch(Dispatchers.IO) { - roomInfoUseCase.subscribe().collect { roomsInfo -> - val roomInfo = roomsInfo.firstOrNull { it.name == roomName() } ?: return@collect - val uiSlots = getSlotsByRoomUseCase( - roomInfo = roomInfo, - - ).map(slotUiMapper::map) - updateSlots(uiSlots) - } - }*/ } private suspend fun updateSlots(uiSlots: List) = withContext(Dispatchers.Main.immediate) { @@ -82,7 +72,8 @@ class SlotComponent( fun sendIntent(intent: SlotIntent) { when (intent) { - is SlotIntent.ClickOnSlot -> intent.slot.execute(state.value) + is SlotIntent.ClickToEdit -> handleClickToEdit(intent.slot) + is SlotIntent.ClickToToggle -> openMultislot(intent.slot) is SlotIntent.Delete -> deleteSlot(intent) is SlotIntent.OnCancelDelete -> cancelDeletingSlot(intent) is SlotIntent.UpdateDate -> updateDate(intent.newDate) @@ -90,6 +81,14 @@ class SlotComponent( } } + private fun handleClickToEdit(slot: SlotUi) { + when (slot) { + is SlotUi.SimpleSlot -> slot.slot.execute() + is SlotUi.NestedSlot -> slot.slot.execute() + else -> {} + } + } + private fun updateRequest(intent: SlotIntent.UpdateRequest) = coroutineScope.launch { roomInfoUseCase.getRoom(intent.room)?.let { roomInfo -> val slots = getSlotsByRoomUseCase(roomInfo = roomInfo) @@ -129,10 +128,10 @@ class SlotComponent( when { uiSlot == null -> {} mainSlot != null -> { - val indexInMultiSlot = mainSlot.subSlots.indexOf(uiSlot) + val indexInMultiSlot = mainSlot!!.subSlots.indexOf(uiSlot) val indexMultiSlot = slots.indexOf(mainSlot) - val newMainSlot = mainSlot.copy( - subSlots = mainSlot.subSlots.toMutableList().apply { + val newMainSlot = mainSlot!!.copy( + subSlots = mainSlot!!.subSlots.toMutableList().apply { this[indexInMultiSlot] = SlotUi.DeleteSlot( slot = intent.slot, @@ -168,27 +167,16 @@ class SlotComponent( } } } - - private fun SlotUi.execute(state: State) = when (this) { - is SlotUi.DeleteSlot -> {} - is SlotUi.MultiSlot -> openMultislot(this, state) - is SlotUi.SimpleSlot -> slot.execute(state) - is SlotUi.NestedSlot -> slot.execute(state) - is SlotUi.LoadingSlot -> { - slot.execute(state) - } - } - - private fun Slot.execute(state: State) = when (this) { + private fun Slot.execute() = when (this) { is Slot.EmptySlot -> executeFreeSlot(this) is Slot.EventSlot -> executeEventSlot(this) - is Slot.MultiEventSlot -> {} - is Slot.LoadingEventSlot -> {} + else -> {} } - private fun openMultislot(multislot: SlotUi.MultiSlot, state: State) { - val slots = state.slots.toMutableList() + private fun openMultislot(multislot: SlotUi.MultiSlot) { + val slots = state.value.slots.toMutableList() val index = slots.indexOf(multislot) + if (index < 0) return slots[index] = multislot.copy(isOpen = !multislot.isOpen) mutableState.update { it.copy(slots = slots) } } diff --git a/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/SlotIntent.kt b/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/SlotIntent.kt index 9445df42e99286cd55e9821f183202b90dd3d2cb..bcb31aeefdfefcf3b2653d350b3fe784c1124523 100644 --- a/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/SlotIntent.kt +++ b/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/SlotIntent.kt @@ -4,7 +4,8 @@ import band.effective.office.tablet.core.domain.model.Slot import kotlinx.datetime.LocalDateTime sealed interface SlotIntent { - data class ClickOnSlot(val slot: SlotUi) : SlotIntent + data class ClickToEdit(val slot: SlotUi) : SlotIntent + data class ClickToToggle(val slot: SlotUi.MultiSlot) : SlotIntent data class UpdateDate(val newDate: LocalDateTime) : SlotIntent data class UpdateRequest(val room: String) : SlotIntent data class Delete(val slot: Slot, val onDelete: () -> Unit) : SlotIntent diff --git a/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/MultiSlotView.kt b/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/MultiSlotView.kt index 3f52db22959227791be612e2c8145f28cc3b5832..d522e9f663c6e115d2a7354ea3903d9b9daf5efa 100644 --- a/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/MultiSlotView.kt +++ b/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/MultiSlotView.kt @@ -2,6 +2,7 @@ package band.effective.office.tablet.feature.slot.presentation.components import androidx.compose.animation.animateContentSize import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight @@ -19,24 +20,32 @@ import org.jetbrains.compose.resources.painterResource fun MultiSlotView( modifier: Modifier = Modifier, slotUi: SlotUi.MultiSlot, - onItemClick: (SlotUi) -> Unit, + onItemClick: SlotUi.() -> Unit, + onToggle: SlotUi.() -> Unit, onCancel: (SlotUi.DeleteSlot) -> Unit ) { Column(Modifier.animateContentSize()) { - CommonSlotView(modifier, slotUi) { - val rotateDegrees = if (slotUi.isOpen) 180f else 0f + CommonSlotView( + modifier = modifier.clickable { onToggle(slotUi) }, + slotUi = slotUi + ) { Image( - modifier = Modifier.fillMaxHeight().rotate(rotateDegrees), + modifier = Modifier + .fillMaxHeight() + .rotate(if (slotUi.isOpen) 180f else 0f) + .clickable { onToggle(slotUi) }, painter = painterResource(Res.drawable.arrow_to_down), contentDescription = null ) } + if (slotUi.isOpen) { slotUi.subSlots.forEach { Spacer(Modifier.height(20.dp)) SlotView( slotUi = it, onClick = onItemClick, + onToggle = onToggle, onCancel = onCancel ) } diff --git a/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/SlotView.kt b/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/SlotView.kt index 276308bed43c2541a5c53bcfa57943decba2377b..e96e33cc1b0e5515c8d4d85fae9a85707294444c 100644 --- a/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/SlotView.kt +++ b/clients/tablet/feature/slot/src/commonMain/kotlin/band/effective/office/tablet/feature/slot/presentation/components/SlotView.kt @@ -29,21 +29,30 @@ import org.jetbrains.compose.resources.stringResource fun SlotView( slotUi: SlotUi, onClick: SlotUi.() -> Unit, + onToggle: SlotUi.() -> Unit, onCancel: (SlotUi.DeleteSlot) -> Unit ) { val borderShape = CircleShape - val itemModifier = Modifier + val baseModifier = Modifier .fillMaxWidth() .clip(borderShape) .background(MaterialTheme.colorScheme.surface) - .clickable { slotUi.onClick() } + + val itemModifier = baseModifier .run { - if (slotUi !is SlotUi.DeleteSlot) border( + when (slotUi) { + is SlotUi.MultiSlot -> this + else -> clickable { slotUi.onClick() } + } + } + .then( + if (slotUi !is SlotUi.DeleteSlot) Modifier.border( width = 5.dp, color = slotUi.borderColor(), shape = borderShape - ).padding(vertical = 15.dp, horizontal = 30.dp) else this - } + ).padding(vertical = 15.dp, horizontal = 30.dp) + else Modifier + ) when (slotUi) { is SlotUi.DeleteSlot -> DeletedSlotView( @@ -56,7 +65,8 @@ fun SlotView( is SlotUi.MultiSlot -> MultiSlotView( modifier = itemModifier, slotUi = slotUi, - onItemClick = { item -> item.onClick() }, + onItemClick = onClick, + onToggle = onToggle, onCancel = onCancel ) diff --git a/deploy/dev/.env.example b/deploy/dev/.env.example index 38bfc6e04cf6bb91f53a3aff30d0db2f9cf51ab9..866e491316d31d63b6acdfc3f36f7d19bfe41d81 100644 --- a/deploy/dev/.env.example +++ b/deploy/dev/.env.example @@ -3,7 +3,13 @@ POSTGRES_USER=youruser POSTGRES_PASSWORD=youruser SPRING_DATASOURCE_URL=yourpathtodatabase +# Application Configuration +MIGRATIONS_ENABLE=true +LOG_LEVEL=DEBUG + +CALENDAR_APPLICATION_NAME=yourcalendarapplicationname +DEFAULT_CALENDAR=yourdefaultcalendar TEST_CALENDARS=your calendars TEST_APPLICATION_URL=your test application url -LABEL=your domain label \ No newline at end of file +LABEL=yourdevdomain \ No newline at end of file diff --git a/deploy/dev/docker-compose.yml b/deploy/dev/docker-compose.yml index f018f109d9e9c0d7c4ad879a7a76b640001ecade..bd83f4605d38a589ef45b90e6848353bc97a47b3 100644 --- a/deploy/dev/docker-compose.yml +++ b/deploy/dev/docker-compose.yml @@ -7,6 +7,8 @@ services: build: context: . dockerfile: Dockerfile + env_file: + - .env container_name: effective-office-app-dev expose: - "8080" diff --git a/deploy/prod/.env.example b/deploy/prod/.env.example index 550a6de78a6faaa8aef549231a3247dcdcc69826..866e491316d31d63b6acdfc3f36f7d19bfe41d81 100644 --- a/deploy/prod/.env.example +++ b/deploy/prod/.env.example @@ -3,7 +3,13 @@ POSTGRES_USER=youruser POSTGRES_PASSWORD=youruser SPRING_DATASOURCE_URL=yourpathtodatabase -CALENDARS=your calendars -APPLICATION_URL=your application url +# Application Configuration +MIGRATIONS_ENABLE=true +LOG_LEVEL=DEBUG -LABEL=your domain label \ No newline at end of file +CALENDAR_APPLICATION_NAME=yourcalendarapplicationname +DEFAULT_CALENDAR=yourdefaultcalendar +TEST_CALENDARS=your calendars +TEST_APPLICATION_URL=your test application url + +LABEL=yourdevdomain \ No newline at end of file diff --git a/deploy/prod/docker-compose.yml b/deploy/prod/docker-compose.yml index 3952b0a33faa7dca94a9b4b561ae3a53202fc47b..b47f2103cba62deb8a85f3bc99bfac017e650da9 100644 --- a/deploy/prod/docker-compose.yml +++ b/deploy/prod/docker-compose.yml @@ -7,6 +7,8 @@ services: build: context: . dockerfile: Dockerfile + env_file: + - .env container_name: effective-office-app expose: - "8080" @@ -21,12 +23,6 @@ services: - effective-office-network - caddy restart: unless-stopped - healthcheck: - test: [ "CMD", "curl", "-f", "http://localhost:8080/api/actuator/health" ] - interval: 30s - timeout: 10s - retries: 5 - start_period: 40s labels: caddy: ${LABEL} caddy.reverse_proxy: "{{upstreams 8080}}" diff --git a/gradle.properties b/gradle.properties index 0e8c1c478390db761af4c7051a8f4e0bdb43cd16..cfac532387f9fee05f205ef648a58e582b1c81f5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,6 +9,6 @@ kotlin.incremental=true # Project properties group=band.effective.office -version=0.0.1 +version=0.0.2 android.useAndroidX=true diff --git a/media/tablet/demo-tablet.gif b/media/tablet/demo-tablet.gif new file mode 100644 index 0000000000000000000000000000000000000000..e6afa21c6e39706073a51847c0126be983877a52 Binary files /dev/null and b/media/tablet/demo-tablet.gif differ