Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import org.springframework.stereotype.Component
import org.springframework.transaction.PlatformTransactionManager
import java.sql.Timestamp
import java.time.Duration
import java.time.LocalDate
import java.time.ZoneId.systemDefault
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.TemporalAccessor
Expand Down Expand Up @@ -86,6 +88,11 @@ class CurrentDateProvider(
return forcedDate ?: Date()
}

val localDate: LocalDate
get() {
return date.toInstant().atZone(systemDefault()).toLocalDate()
}

override fun getNow(): Optional<TemporalAccessor> {
return Optional.of(date.toInstant())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package io.tolgee.component
import io.tolgee.util.Logging
import io.tolgee.util.logger
import jakarta.annotation.PreDestroy
import org.springframework.scheduling.support.CronTrigger
import org.springframework.stereotype.Component
import java.time.Duration
import java.util.UUID
Expand Down Expand Up @@ -46,6 +47,19 @@ class SchedulingManager(
return id
}

fun scheduleWithCron(
runnable: Runnable,
cron: String,
): String {
val future = taskScheduler.schedule(runnable, CronTrigger(cron))
if (future == null) {
throw IllegalStateException("Future from scheduler was null")
}
val id = UUID.randomUUID().toString()
scheduledTasks[id] = future
return id
}

@PreDestroy
fun cancelAll() {
Companion.cancelAll()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package io.tolgee.component.email.customTemplate

import io.tolgee.component.email.customTemplate.placeholder.EmailPlaceholdersExtractor
import org.springframework.stereotype.Component
import java.text.MessageFormat
import java.util.Locale
import kotlin.reflect.KClass

@Component
class EmailTemplateRenderer(
private val placeholderExtractor: EmailPlaceholdersExtractor,
) {
fun render(
template: String,
variables: EmailTemplateVariables,
): String {
@Suppress("UNCHECKED_CAST")
val entries =
placeholderExtractor.getEntries(
variables::class as KClass<EmailTemplateVariables>,
)

val parameters =
entries
.map { entry ->
entry.accessor(variables) ?: ""
}.toTypedArray()

return MessageFormat(template, Locale.ENGLISH).format(parameters)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package io.tolgee.component.email.customTemplate

/**
* Marker interface for classes that describe email template variables via [io.tolgee.component.email.customTemplate.placeholder.EmailPlaceholder] annotations.
*/
interface EmailTemplateVariables
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package io.tolgee.component.email.customTemplate.placeholder

@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class EmailPlaceholder(
val position: Int,
val placeholder: String,
val description: String,
val exampleValue: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package io.tolgee.component.email.customTemplate.placeholder

import io.tolgee.component.email.customTemplate.EmailTemplateVariables

data class EmailPlaceholderDefinition(
val position: Int,
val placeholder: String,
val description: String,
val exampleValue: String,
)

data class EmailPlaceholderEntry<T : EmailTemplateVariables>(
val definition: EmailPlaceholderDefinition,
val accessor: (T) -> String?,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package io.tolgee.component.email.customTemplate.placeholder

import io.tolgee.component.email.customTemplate.EmailTemplateVariables
import io.tolgee.util.I18n
import org.springframework.stereotype.Component
import java.util.concurrent.ConcurrentHashMap
import kotlin.collections.set
import kotlin.reflect.KClass
import kotlin.reflect.full.findAnnotation
import kotlin.reflect.full.memberProperties

@Component
class EmailPlaceholdersExtractor(
private val i18n: I18n,
) {
private val cache =
ConcurrentHashMap<KClass<*>, List<EmailPlaceholderEntry<*>>>()

fun <T : EmailTemplateVariables> getEntries(kClass: KClass<T>): List<EmailPlaceholderEntry<T>> {
val existing = cache[kClass]
if (existing != null) {
@Suppress("UNCHECKED_CAST")
return existing as List<EmailPlaceholderEntry<T>>
}

val extracted = extract(kClass)
cache[kClass] = extracted
return extracted
}

fun <T : EmailTemplateVariables> getDefinitions(kClass: KClass<T>): List<EmailPlaceholderDefinition> {
return getEntries(kClass).map { it.definition }
}

private fun <T : EmailTemplateVariables> extract(kClass: KClass<T>): List<EmailPlaceholderEntry<T>> {
return kClass.memberProperties
.mapNotNull { property ->
val annotation = property.findAnnotation<EmailPlaceholder>() ?: return@mapNotNull null

EmailPlaceholderEntry(
definition =
EmailPlaceholderDefinition(
position = annotation.position,
placeholder = annotation.placeholder,
description = i18n.translate(annotation.description),
exampleValue = annotation.exampleValue,
),
accessor = { instance: T ->
property.get(instance)?.toString()
},
)
}.sortedBy { it.definition.position }
}
}
3 changes: 3 additions & 0 deletions backend/data/src/main/kotlin/io/tolgee/constants/Message.kt
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,9 @@ enum class Message {
TRANSLATION_EXCEEDS_CHAR_LIMIT,
URL_NOT_VALID,
QA_CHECKS_NOT_ENABLED,
PLAN_MIGRATION_NOT_FOUND,
PLAN_HAS_MIGRATIONS,
SOURCE_AND_TARGET_PLAN_MUST_BE_DIFFERENT,
;

val code: String
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package io.tolgee.dtos.misc

data class EmailPlaceholderModel(
val placeholder: String,
val description: String,
val exampleValue: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package io.tolgee.dtos.misc

data class EmailTemplateModel(
val body: String,
val placeholders: List<EmailPlaceholderModel>,
)
23 changes: 23 additions & 0 deletions backend/data/src/main/resources/I18n_en.properties
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,26 @@ notifications.email.security-settings-link=Check your security settings <a href=
notifications.email.mfa.MFA_ENABLED=Multi-factor authentication has been enabled for your account.
notifications.email.mfa.MFA_DISABLED=Multi-factor authentication has been disabled for your account.
notifications.email.password-changed=Password has been changed for your account.

notifications.email.plan-migration-subject=Upcoming update to your Tolgee subscription plan
notifications.email.plan-migration-body=Earlier this year, we introduced a new pricing structure for new Tolgee customers while keeping existing users on their current plans. We’re now moving all subscriptions to the new plans to make things simpler and fair for everyone.<br/>\
<br/>\
In the new structure, translation strings have been replaced with keys and seats to better reflect how Tolgee is used. You can view the updated pricing here <a href="https://tolgee.io/pricing">https://tolgee.io/pricing</a>.<br/>\
<br/>\
Your current {1} plan will automatically switch to the new {2} plan on {3}. If you’d like to explore other subscriptions options, you can do so anytime in the <a href="{4}">Subscriptions</a> section within the Tolgee platform.<br/>\
<br/>\
Thank you for your continued trust and support!<br/>\
<br/>\
P.S. I understand these changes may cause discomfort and not accommodate everyone’s circumstances. If this materially impacts your business, I warmly invite you to contact me directly for a personal dialogue regarding your concerns. You can reach me at:<br/>\
<ul>\
<li>Email: jan@tolgee.io</li>\
<li><a href="https://tolg.ee/slack">Tolgee Slack</a></li>\
<li>Schedule a meeting via Calendly <a href="https://calendly.com/jancizmar/quick-chat-with-jan?month={5}">https://calendly.com/jancizmar/quick-chat-with-jan?month={5}</a></li>\
</ul>

email-placeholder-recipient-name = Recipient name
email-placeholder-current-plan-name = Current plan name
email-placeholder-target-plan-name = Target plan name
email-placeholder-transfer-date = Transfer date
email-placeholder-subscriptions-page-url = Subscriptions page URL
email-placeholder-current-date = Current month (yyyy-MM)
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ class EmailServiceTest {
"registration-confirm",
Locale.ENGLISH,
mapOf(
"username" to "bob@example.com",
"recipientName" to "bob@example.com",
"confirmUrl" to "https://app.tolgee.io/verify/123/abc",
"isSignup" to true,
),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,47 +1,24 @@
package io.tolgee.controllers.internal

import io.tolgee.configuration.tolgee.E2eRuntimeMutable
import io.tolgee.configuration.tolgee.TolgeeProperties
import io.tolgee.dtos.request.SetPropertyDto
import io.tolgee.exceptions.BadRequestException
import io.tolgee.exceptions.NotFoundException
import io.tolgee.facade.InternalPropertiesSetterFacade
import jakarta.validation.Valid
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import kotlin.reflect.KMutableProperty1
import kotlin.reflect.KProperty1
import kotlin.reflect.full.declaredMemberProperties
import kotlin.reflect.full.hasAnnotation

@InternalController(["internal/properties"])
class PropertiesController(
val tolgeeProperties: TolgeeProperties,
private val tolgeeProperties: TolgeeProperties,
private val internalPropertiesSetterFacade: InternalPropertiesSetterFacade,
) {
@PutMapping(value = ["/set"])
@Transactional
fun setProperty(
@RequestBody @Valid
setPropertyDto: SetPropertyDto,
) {
val name = setPropertyDto.name
var instance: Any = tolgeeProperties
name.split(".").let { namePath ->
namePath.forEachIndexed { idx, property ->
val isLast = idx == namePath.size - 1
val props = instance::class.declaredMemberProperties
val prop = props.find { it.name == property } ?: throw NotFoundException()
if (isLast) {
(prop as? KMutableProperty1<Any, Any?>)?.let {
if (!it.hasAnnotation<E2eRuntimeMutable>()) {
io.tolgee.constants.Message.PROPERTY_NOT_MUTABLE
}
it.set(instance, setPropertyDto.value)
return
} ?: throw BadRequestException(io.tolgee.constants.Message.PROPERTY_NOT_MUTABLE)
}
instance = (prop as KProperty1<Any, Any?>).get(instance)!!
}
}
internalPropertiesSetterFacade.setProperty(tolgeeProperties, setPropertyDto)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package io.tolgee.facade

import io.tolgee.configuration.tolgee.E2eRuntimeMutable
import io.tolgee.dtos.request.SetPropertyDto
import io.tolgee.exceptions.BadRequestException
import io.tolgee.exceptions.NotFoundException
import org.springframework.stereotype.Component
import kotlin.reflect.KMutableProperty1
import kotlin.reflect.KProperty1
import kotlin.reflect.full.declaredMemberProperties
import kotlin.reflect.full.hasAnnotation

@Component
class InternalPropertiesSetterFacade {
fun setProperty(
root: Any,
setPropertyDto: SetPropertyDto,
onSet: (() -> Unit)? = null,
) {
val name = setPropertyDto.name
var instance: Any = root
name.split(".").let { namePath ->
namePath.forEachIndexed { idx, property ->
val isLast = idx == namePath.size - 1
val props = instance::class.declaredMemberProperties
val prop = props.find { it.name == property } ?: throw NotFoundException()
if (isLast) {
(prop as? KMutableProperty1<Any, Any?>)?.let {
if (!it.hasAnnotation<E2eRuntimeMutable>()) {
io.tolgee.constants.Message.PROPERTY_NOT_MUTABLE
}
it.set(instance, setPropertyDto.value)
onSet?.invoke()
return
} ?: throw BadRequestException(io.tolgee.constants.Message.PROPERTY_NOT_MUTABLE)
Comment thread
dkrizan marked this conversation as resolved.
}
instance = (prop as KProperty1<Any, Any?>).get(instance)
?: throw NotFoundException()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import org.mockito.Mockito
import org.mockito.kotlin.KArgumentCaptor
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.springframework.beans.factory.annotation.Autowired
Expand Down Expand Up @@ -71,6 +72,10 @@ class EmailTestUtil {
verify(javaMailSender).send(any<MimeMessage>())
}

fun verifyTimesEmailSent(num: Int) {
verify(javaMailSender, times(num)).send(any<MimeMessage>())
}

val assertEmailTo: AbstractStringAssert<*>
get() {
@Suppress("CAST_NEVER_SUCCEEDS")
Expand Down
10 changes: 9 additions & 1 deletion e2e/cypress/common/shared.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
/// <reference types="cypress" />
import { getAnyContainingAriaLabelAttribute, getInput } from './xPath';
import {
getAnyContainingAriaLabelAttribute,
getInput,
getTextArea,
} from './xPath';
import { Scope } from './types';
import { waitForGlobalLoading } from './loading';
import { HOST } from './constants';
Expand Down Expand Up @@ -158,6 +162,10 @@ export const getInputByName = (name: string): Chainable => {
return cy.xpath(getInput(name));
};

export const getTextAreaByName = (name: string): Chainable => {
return cy.xpath(getTextArea(name));
};

export const switchToOrganizationWithSearch = (name: string): Chainable => {
cy.gcy('organization-switch').click();
cy.gcy('switch-popover-search').type(name);
Expand Down
4 changes: 4 additions & 0 deletions e2e/cypress/common/xPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export const getInput = (name, nth = 1) =>
`//input[translate(@name,` +
`'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz') = '${name.toLowerCase()}'][${nth}]`;

export const getTextArea = (name, nth = 1) =>
`//textarea[translate(@name,` +
`'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz') = '${name.toLowerCase()}'][${nth}]`;

export const getClosestContainingText = (text, tag = '*', nth = 1) =>
`./ancestor::*[.//*[${containsIgnoreCase(
'text()',
Expand Down
4 changes: 2 additions & 2 deletions email/emails/registration-confirm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ export default function RegistrationConfirmEmail() {
<Text style={{ margin: '0 0 16px' }}>
<LocalizedText
keyName="email-greetings"
defaultValue="Hello {username},"
demoParams={{ username: 'Bob' }}
defaultValue="Hello {recipientName},"
demoParams={{ recipientName: 'Bob' }}
/>
</Text>
<Text>
Expand Down
2 changes: 1 addition & 1 deletion email/i18n/messages_en.properties
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
email-greetings = Hello {username} 👋,
email-greetings = Hello {recipientName} 👋,
email-general-greetings = Hello! 👋,
email-signature = Kind Regards,\nTolgee
footer-cloud-address = Letovická 1421/22, Řečkovice, 621 00 Brno, Czech Republic
Expand Down
Loading
Loading