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