Working on adding event support in the new impl

Just need to finish the remaining CRUD methods (create, update, delete)
This commit is contained in:
NovaFox161
2024-11-16 16:03:15 -06:00
parent d6b16e63c1
commit e3c70b9f74
11 changed files with 402 additions and 8 deletions
@@ -3,7 +3,9 @@ package org.dreamexposure.discal.core.business
import discord4j.common.util.Snowflake
import org.dreamexposure.discal.core.`object`.new.Calendar
import org.dreamexposure.discal.core.`object`.new.CalendarMetadata
import org.dreamexposure.discal.core.`object`.new.CalendarMetadata.*
import org.dreamexposure.discal.core.`object`.new.CalendarMetadata.Host
import org.dreamexposure.discal.core.`object`.new.Event
import java.time.Instant
interface CalendarProvider {
val host: Host
@@ -16,5 +18,14 @@ interface CalendarProvider {
suspend fun deleteCalendar(guildId: Snowflake, metadata: CalendarMetadata)
suspend fun getEvent(guildId: Snowflake, calendar: Calendar, id: String): Event?
suspend fun getUpcomingEvents(guildId: Snowflake, calendar: Calendar, amount: Int): List<Event>
suspend fun getOngoingEvents(guildId: Snowflake, calendar: Calendar): List<Event>
suspend fun getEventsInTimeRange(guildId: Snowflake, calendar: Calendar, start: Instant, end: Instant): List<Event>
// TODO: Implement the rest of required CRUD functions
}
@@ -6,24 +6,29 @@ import kotlinx.coroutines.reactor.awaitSingleOrNull
import kotlinx.coroutines.reactor.mono
import org.dreamexposure.discal.CalendarCache
import org.dreamexposure.discal.CalendarMetadataCache
import org.dreamexposure.discal.EventCache
import org.dreamexposure.discal.core.crypto.AESEncryption
import org.dreamexposure.discal.core.database.CalendarMetadataData
import org.dreamexposure.discal.core.database.CalendarMetadataRepository
import org.dreamexposure.discal.core.exceptions.NotFoundException
import org.dreamexposure.discal.core.`object`.new.Calendar
import org.dreamexposure.discal.core.`object`.new.CalendarMetadata
import org.dreamexposure.discal.core.`object`.new.Event
import org.springframework.stereotype.Component
import java.time.Instant
@Component
class CalendarService(
private val calendarMetadataRepository: CalendarMetadataRepository,
private val calendarMetadataCache: CalendarMetadataCache,
private val eventCache: EventCache,
private val calendarProviders: List<CalendarProvider>,
private val calendarCache: CalendarCache,
private val settingsService: GuildSettingsService,
private val staticMessageService: StaticMessageService,
private val rsvpService: RsvpService,
private val announcementService: AnnouncementService,
private val eventMetadataService: EventMetadataService,
) {
/////////
/// Calendar count
@@ -161,20 +166,70 @@ class CalendarService(
calendarMetadataCache.evict(key = guildId)
/*
// TODO: Need to call a modern version of DatabaseManager.deleteCalendarAndRelatedData method
This is a set of calls to replicate the behavior of the old monolith db call
that would go through all tables to handle deleting (as cascade delete constraints have not yet been added,
and to update the calendar number references down-stream. This is again for user-convenience, or so I tell myself
as a cope for how badly designed this project originally was and I just, can't let go of it,
so I keep trying to fix it bit by bit <3
*/
// TODO: Delete related events
eventMetadataService.deleteEventMetadataForCalendarDeletion(guildId, number)
rsvpService.deleteRsvpForCalendarDeletion(guildId, number)
announcementService.deleteAnnouncementsForCalendarDeletion(guildId, number)
staticMessageService.deleteStaticMessagesForCalendarDeletion(guildId, number)
}
/////////
/// Event
/// TODO: Need to figure out if I can fetch event sets from cache one day (eg, ongoing events)
/////////
suspend fun getEvent(guildId: Snowflake, calendarNumber: Int, id: String): Event? {
var event = eventCache.get(guildId, id)
if (event != null) return event
val calendar = getCalendar(guildId, calendarNumber) ?: return null
event = calendarProviders
.first { it.host == calendar.metadata.host }
.getEvent(guildId, calendar, id)
if (event != null) eventCache.put(guildId, id, event)
return event
}
suspend fun getUpcomingEvents(guildId: Snowflake, calendarNumber: Int, amount: Int): List<Event> {
val calendar = getCalendar(guildId, calendarNumber) ?: return emptyList()
val events = calendarProviders
.first { it.host == calendar.metadata.host }
.getUpcomingEvents(guildId, calendar, amount)
events.forEach { event -> eventCache.put(guildId, event.id, event) }
return events
}
suspend fun getOngoingEvents(guildId: Snowflake, calendarNumber: Int): List<Event> {
val calendar = getCalendar(guildId, calendarNumber) ?: return emptyList()
val events = calendarProviders
.first { it.host == calendar.metadata.host }
.getOngoingEvents(guildId, calendar)
events.forEach { event -> eventCache.put(guildId, event.id, event) }
return events
}
suspend fun getEventsInTimeRange(guildId: Snowflake, calendarNumber: Int, start: Instant, end: Instant): List<Event> {
val calendar = getCalendar(guildId, calendarNumber) ?: return emptyList()
val events = calendarProviders
.first { it.host == calendar.metadata.host }
.getEventsInTimeRange(guildId, calendar, start, end)
events.forEach { event -> eventCache.put(guildId, event.id, event) }
return events
}
// TODO: Add remaining CRUD methods (create/update/delete)
/////////
/// Extra functions
@@ -0,0 +1,67 @@
package org.dreamexposure.discal.core.business
import discord4j.common.util.Snowflake
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.dreamexposure.discal.EventCache
import org.dreamexposure.discal.core.database.EventMetadataData
import org.dreamexposure.discal.core.database.EventMetadataRepository
import org.dreamexposure.discal.core.`object`.new.EventMetadata
import org.springframework.stereotype.Component
@Component
class EventMetadataService(
private val eventMetadataRepository: EventMetadataRepository,
private val eventCache: EventCache,
) {
/////////
/// Event metadata - Prefer using full Event implementation in CalendarService
/////////
suspend fun getEventMetadata(guildId: Snowflake, eventId: String): EventMetadata? {
val computedId = eventId.split("_")[0]
return eventMetadataRepository.findByGuildIdAndEventId(guildId.asLong(), computedId)
.map(::EventMetadata)
.awaitSingleOrNull()
}
suspend fun getMultipleEventsMetadata(guildId: Snowflake, eventIds: List<String>): List<EventMetadata> {
val computedIds = eventIds.map { eventId -> eventId.split("_")[0] }
return eventMetadataRepository.findAllByGuildIdAndEventIdIn(guildId.asLong(), computedIds)
.map(::EventMetadata)
.collectList()
.awaitSingle()
}
suspend fun createEventMetadata(event: EventMetadata): EventMetadata {
val computedId = event.id.split("_")[0]
return eventMetadataRepository.save(EventMetadataData(
guildId = event.guildId.asLong(),
eventId = computedId,
calendarNumber = event.calendarNumber,
eventEnd = event.eventEnd.toEpochMilli(),
imageLink = event.imageLink,
)).map(::EventMetadata).awaitSingle()
}
suspend fun updateEventMetadata(event: EventMetadata) {
val computedId = event.id.split("_")[0]
eventMetadataRepository.updateByGuildIdAndEventId(
guildId = event.guildId.asLong(),
eventId = computedId,
calendarNumber = event.calendarNumber,
eventEnd = event.eventEnd.toEpochMilli(),
imageLink = event.imageLink
).awaitSingleOrNull()
}
suspend fun deleteEventMetadataForCalendarDeletion(guildId: Snowflake, calendarNumber: Int) {
eventMetadataRepository.deleteAllByGuildIdAndCalendarNumber(guildId.asLong(), calendarNumber).awaitSingleOrNull()
eventMetadataRepository.decrementCalendarsByGuildIdAndCalendarNumber(guildId.asLong(), calendarNumber).awaitSingleOrNull()
eventCache.evictAll(guildId)
}
}
@@ -3,24 +3,34 @@ package org.dreamexposure.discal.core.business.google
import com.google.api.services.calendar.model.AclRule
import discord4j.common.util.Snowflake
import org.dreamexposure.discal.core.business.CalendarProvider
import org.dreamexposure.discal.core.business.EventMetadataService
import org.dreamexposure.discal.core.config.Config
import org.dreamexposure.discal.core.crypto.KeyGenerator
import org.dreamexposure.discal.core.enums.event.EventColor
import org.dreamexposure.discal.core.exceptions.ApiException
import org.dreamexposure.discal.core.extensions.google.asInstant
import org.dreamexposure.discal.core.`object`.event.Recurrence
import org.dreamexposure.discal.core.`object`.new.Calendar
import org.dreamexposure.discal.core.`object`.new.CalendarMetadata
import org.dreamexposure.discal.core.`object`.new.Event
import org.dreamexposure.discal.core.`object`.new.EventMetadata
import org.springframework.stereotype.Component
import java.time.Instant
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import kotlin.random.Random
import com.google.api.services.calendar.model.Event as GoogleEvent
@Component
class GoogleCalendarProviderService(
val googleCalendarApiWrapper: GoogleCalendarApiWrapper,
val eventMetadataService: EventMetadataService,
) : CalendarProvider {
override val host = CalendarMetadata.Host.GOOGLE
private fun randomCredentialId() = Random.nextInt(Config.SECRET_GOOGLE_CREDENTIAL_COUNT.getInt())
/////////
/// Calendar
/////////
override suspend fun getCalendar(metadata: CalendarMetadata): Calendar? {
val response = googleCalendarApiWrapper.getCalendar(metadata)
if (response.entity == null) return null
@@ -67,7 +77,10 @@ class GoogleCalendarProviderService(
AclRule().setScope(AclRule.Scope().setType("default")).setRole("reader"),
metadata
)
if (aclRuleResponse.error != null) throw ApiException(aclRuleResponse.error.error, aclRuleResponse.error.exception)
if (aclRuleResponse.error != null) throw ApiException(
aclRuleResponse.error.error,
aclRuleResponse.error.exception
)
return Calendar(
metadata = metadata,
@@ -93,7 +106,10 @@ class GoogleCalendarProviderService(
AclRule().setScope(AclRule.Scope().setType("default")).setRole("reader"),
metadata
)
if (aclRuleResponse.error != null) throw ApiException(aclRuleResponse.error.error, aclRuleResponse.error.exception)
if (aclRuleResponse.error != null) throw ApiException(
aclRuleResponse.error.error,
aclRuleResponse.error.exception
)
return Calendar(
metadata = metadata,
@@ -108,4 +124,103 @@ class GoogleCalendarProviderService(
val response = googleCalendarApiWrapper.deleteCalendar(metadata)
if (response.error != null) throw ApiException(response.error.error, response.error.exception)
}
/////////
/// Events
/////////
override suspend fun getEvent(guildId: Snowflake, calendar: Calendar, id: String): Event? {
val response = googleCalendarApiWrapper.getEvent(calendar.metadata, id)
if (response.entity == null) return null
val baseEvent = response.entity
val metadata = eventMetadataService.getEventMetadata(guildId, id) ?: EventMetadata(id, guildId, calendar.metadata.number)
return mapGoogleEventToDisCalEvent(calendar, baseEvent, metadata)
}
override suspend fun getUpcomingEvents(guildId: Snowflake, calendar: Calendar, amount: Int): List<Event> {
val response = googleCalendarApiWrapper.getEvents(calendar.metadata, amount, Instant.now())
if (response.entity == null) return emptyList()
return loadEvents(guildId, calendar, response.entity)
}
override suspend fun getOngoingEvents(guildId: Snowflake, calendar: Calendar): List<Event> {
val now = Instant.now()
val start = now.minus(14, ChronoUnit.DAYS) // 2 weeks ago
val end = now.plus(1, ChronoUnit.DAYS) // One day from now
val response = googleCalendarApiWrapper.getEvents(calendar.metadata, start, end)
if (response.entity == null) return emptyList()
// Filter for only the ongoing events
val filtered = response.entity
.filter { it.start.asInstant(calendar.timezone).isBefore(now) }
.filter { it.end.asInstant(calendar.timezone).isAfter(now) }
return loadEvents(guildId, calendar, filtered)
}
override suspend fun getEventsInTimeRange(guildId: Snowflake, calendar: Calendar, start: Instant, end: Instant): List<Event> {
val response = googleCalendarApiWrapper.getEvents(calendar.metadata, start, end)
if (response.entity == null) return emptyList()
return loadEvents(guildId, calendar, response.entity)
}
/////////
/// Private util functions
/////////
private fun randomCredentialId() = Random.nextInt(Config.SECRET_GOOGLE_CREDENTIAL_COUNT.getInt())
private suspend fun loadEvents(guildId: Snowflake, calendar: Calendar, events: List<GoogleEvent>): List<Event> {
val metadataList = eventMetadataService.getMultipleEventsMetadata(guildId, events.map { it.id})
return events.map { googleEvent ->
val computedId = googleEvent.id.split("_")[0]
val metadata = metadataList.firstOrNull { it.id == computedId } ?: EventMetadata(googleEvent.id, guildId, calendar.metadata.number)
mapGoogleEventToDisCalEvent(calendar, googleEvent, metadata)
}
}
private fun mapGoogleEventToDisCalEvent(calendar: Calendar, baseEvent: GoogleEvent, metadata: EventMetadata): Event {
return Event(
id = baseEvent.id,
guildId = calendar.metadata.guildId,
calendarNumber = calendar.metadata.number,
name = baseEvent.summary.orEmpty(),
description = baseEvent.description.orEmpty(),
location = baseEvent.location.orEmpty(),
link = baseEvent.htmlLink.orEmpty(),
color = if (baseEvent.colorId.isNullOrBlank()) {
EventColor.fromNameOrHexOrId(baseEvent.colorId)
} else EventColor.NONE,
start = if (baseEvent.start.dateTime != null) {
Instant.ofEpochMilli(baseEvent.start.dateTime.value)
} else Instant.ofEpochMilli(baseEvent.start.date.value)
.plus(1, ChronoUnit.DAYS)
.atZone(calendar.timezone)
.truncatedTo(ChronoUnit.DAYS)
.toLocalDate()
.atStartOfDay()
.atZone(calendar.timezone)
.toInstant(),
end = if (baseEvent.end.dateTime != null) {
Instant.ofEpochMilli(baseEvent.end.dateTime.value)
} else Instant.ofEpochMilli(baseEvent.end.date.value)
.plus(1, ChronoUnit.DAYS)
.atZone(calendar.timezone)
.truncatedTo(ChronoUnit.DAYS)
.toLocalDate()
.atStartOfDay()
.atZone(calendar.timezone)
.toInstant(),
recur = !baseEvent.recurrence.isNullOrEmpty(),
recurrence = if (baseEvent.recurrence.isNullOrEmpty()) Recurrence() else Recurrence.fromRRule(baseEvent.recurrence[0]),
image = metadata.imageLink,
timezone = calendar.timezone,
)
}
}
@@ -22,6 +22,7 @@ class CacheConfig {
private val announcementTll = Config.CACHE_TTL_ANNOUNCEMENT_MINUTES.getLong().asMinutes()
private val wizardTtl = Config.TIMING_WIZARD_TIMEOUT_MINUTES.getLong().asMinutes()
private val calendarTokenTtl = Config.CACHE_TTL_CALENDAR_TOKEN_MINUTES.getLong().asMinutes()
private val eventTtl = Config.CACHE_TTL_EVENTS_MINUTES.getLong().asMinutes()
// Redis caching
@@ -79,6 +80,12 @@ class CacheConfig {
fun announcementWizardRedisCache(objectMapper: ObjectMapper, redisTemplate: ReactiveStringRedisTemplate): AnnouncementWizardStateCache =
RedisStringCacheRepository(objectMapper, redisTemplate, "AnnouncementWizards", wizardTtl)
@Bean
@Primary
@ConditionalOnProperty("bot.cache.redis", havingValue = "true")
fun eventRedisCache(objectMapper: ObjectMapper, redisTemplate: ReactiveStringRedisTemplate): EventCache =
RedisStringCacheRepository(objectMapper, redisTemplate, "Events", eventTtl)
// In-memory fallback caching
@Bean
@@ -110,4 +117,7 @@ class CacheConfig {
@Bean
fun calendarTokenFallbackCache(): CalendarTokenCache = JdkCacheRepository(calendarTokenTtl)
@Bean
fun eventFallbackCache(): EventCache = JdkCacheRepository(eventTtl)
}
@@ -30,6 +30,7 @@ enum class Config(private val key: String, private var value: Any? = null) {
CACHE_TTL_STATIC_MESSAGE_MINUTES("bot.cache.ttl-minutes.static-messages", 60),
CACHE_TTL_ANNOUNCEMENT_MINUTES("bot.cache.ttl-minutes.announcements", 120),
CACHE_TTL_CALENDAR_TOKEN_MINUTES("bot.cache.ttl-minutes.calendar", 60),
CACHE_TTL_EVENTS_MINUTES("bot.cache.ttl-minutes.event", 15),
// Security configuration
@@ -0,0 +1,12 @@
package org.dreamexposure.discal.core.database
import org.springframework.data.relational.core.mapping.Table
@Table("events")
data class EventMetadataData(
val guildId: Long,
val eventId: String,
val calendarNumber: Int,
val eventEnd: Long,
val imageLink: String,
)
@@ -0,0 +1,36 @@
package org.dreamexposure.discal.core.database
import org.springframework.data.r2dbc.repository.Query
import org.springframework.data.r2dbc.repository.R2dbcRepository
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
interface EventMetadataRepository : R2dbcRepository<EventMetadataData, Long> {
fun findByGuildIdAndEventId(guildId: Long, eventId: String): Mono<EventMetadataData>
fun findAllByGuildIdAndEventIdIn(guildId: Long, eventIds: Collection<String>): Flux<EventMetadataData>
@Query("""
UPDATE events
SET calendar_number = :calendarNumber,
event_end = :eventEnd,
image_link = :imageLink
WHERE guild_id = :guildId AND event_id = :eventId
""")
fun updateByGuildIdAndEventId(
guildId: Long,
eventId: String,
calendarNumber: Int,
eventEnd: Long,
imageLink: String,
): Mono<Long>
fun deleteAllByGuildIdAndCalendarNumber(guildId: Long, calendarNumber: Int): Mono<Void>
@Query("""
UPDATE events
SET calendar_number = calendar_number - 1
WHERE calendar_number >= :calendarNumber AND guild_id = :guildId
""")
fun decrementCalendarsByGuildIdAndCalendarNumber(guildId: Long, calendarNumber: Int): Mono<Long>
}
@@ -0,0 +1,56 @@
package org.dreamexposure.discal.core.`object`.new
import discord4j.common.util.Snowflake
import org.dreamexposure.discal.core.enums.event.EventColor
import org.dreamexposure.discal.core.`object`.event.Recurrence
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
import java.time.temporal.ChronoUnit
data class Event(
val id: String,
val guildId: Snowflake,
val calendarNumber: Int,
val name: String,
val description: String,
val location: String,
val link: String,
val color: EventColor,
val start: Instant,
val end: Instant,
val recur: Boolean,
val recurrence: Recurrence,
val image: String,
val timezone: ZoneId,
) {
// Some helpful functions
fun isOngoing(): Boolean = start.isBefore(Instant.now()) && end.isAfter(Instant.now())
fun isOver(): Boolean = end.isBefore(Instant.now())
fun isStarted() = start.isBefore(Instant.now())
fun is24Hours() = Duration.between(start, end).toHours() == 24L
fun isAllDay(): Boolean {
val start = this.start.atZone(timezone)
return start.hour == 0 && is24Hours()
}
fun isMultiDay(): Boolean {
if (isAllDay()) return false // All day events should not count as multi-day events
val start = this.start.atZone(timezone).truncatedTo(ChronoUnit.DAYS)
val end = this.end.atZone(timezone).truncatedTo(ChronoUnit.DAYS)
return when {
start.year != end.year -> true
start.month != end.month -> true
start.dayOfYear != end.dayOfYear -> true
else -> false
}
}
}
@@ -0,0 +1,30 @@
package org.dreamexposure.discal.core.`object`.new
import discord4j.common.util.Snowflake
import org.dreamexposure.discal.core.database.EventMetadataData
import org.dreamexposure.discal.core.extensions.asSnowflake
import java.time.Instant
data class EventMetadata(
val id: String,
val guildId: Snowflake,
val calendarNumber: Int,
val eventEnd: Instant,
val imageLink: String,
) {
constructor(data: EventMetadataData): this(
id = data.eventId,
guildId = data.guildId.asSnowflake(),
calendarNumber = data.calendarNumber,
eventEnd = Instant.ofEpochMilli(data.eventEnd),
imageLink = data.imageLink,
)
constructor(id: String, guildId: Snowflake, calendarNumber: Int): this(
id = id,
guildId = guildId,
calendarNumber = calendarNumber,
eventEnd = Instant.now(),
imageLink = "",
)
}
@@ -16,3 +16,4 @@ typealias StaticMessageCache = CacheRepository<Snowflake, StaticMessage>
typealias AnnouncementCache = CacheRepository<Snowflake, Array<Announcement>>
typealias AnnouncementWizardStateCache = CacheRepository<Snowflake, AnnouncementWizardState>
typealias CalendarTokenCache = CacheRepository<Int, TokenV1Model>
typealias EventCache = CacheRepository<String, Event>