diff --git a/apps/backend/build.gradle.kts b/apps/backend/build.gradle.kts index 78504c6..5ccf2a8 100644 --- a/apps/backend/build.gradle.kts +++ b/apps/backend/build.gradle.kts @@ -31,6 +31,8 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-cache") implementation("org.springframework.boot:spring-boot-starter-data-redis") + implementation("org.springframework.boot:spring-boot-starter-flyway") + implementation("org.flywaydb:flyway-database-postgresql") developmentOnly("org.springframework.boot:spring-boot-devtools") diff --git a/apps/backend/src/main/kotlin/org/tobynguyen/solitar/model/dto/UrlDto.kt b/apps/backend/src/main/kotlin/org/tobynguyen/solitar/model/dto/UrlDto.kt index 179abeb..d7183b5 100644 --- a/apps/backend/src/main/kotlin/org/tobynguyen/solitar/model/dto/UrlDto.kt +++ b/apps/backend/src/main/kotlin/org/tobynguyen/solitar/model/dto/UrlDto.kt @@ -14,7 +14,7 @@ data class UrlCreateDto( "^https?://(?:www\\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b[-a-zA-Z0-9()@:%_+.~#?&/=]*$", ) val url: String, - @field:Future(message = "Expiration time must be in the future") val expiresTime: Instant?, + @field:Future(message = "Expiration time must be in the future") val expireTime: Instant?, @field:Size(message = "Alias must be longer than 7 characters", min = 7) @field:Size(message = "Alias cannot exceed 255 characters", max = 255) @field:Pattern( diff --git a/apps/backend/src/main/kotlin/org/tobynguyen/solitar/repository/UrlRepository.kt b/apps/backend/src/main/kotlin/org/tobynguyen/solitar/repository/UrlRepository.kt index 55c4b31..0fa7182 100644 --- a/apps/backend/src/main/kotlin/org/tobynguyen/solitar/repository/UrlRepository.kt +++ b/apps/backend/src/main/kotlin/org/tobynguyen/solitar/repository/UrlRepository.kt @@ -1,5 +1,6 @@ package org.tobynguyen.solitar.repository +import java.time.Instant import org.springframework.cache.annotation.Cacheable import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Modifying @@ -10,7 +11,12 @@ interface UrlRepository : JpaRepository { @Cacheable(value = ["urlEntities"], key = "#shortCode", unless = "#result == null") fun findByShortCode(shortCode: String): UrlEntity? - fun findByOriginalUrl(originalUrl: String): List + fun findByOriginalUrlAndExpiresAtAndHasAliasFalse( + originalUrl: String, + expiresAt: Instant, + ): UrlEntity? + + fun findByOriginalUrlAndExpiresAtIsNullAndHasAliasFalse(originalUrl: String): UrlEntity? @Modifying @Query("UPDATE UrlEntity u SET u.clickCount = u.clickCount + 1 WHERE u.id = :id") diff --git a/apps/backend/src/main/kotlin/org/tobynguyen/solitar/service/UrlService.kt b/apps/backend/src/main/kotlin/org/tobynguyen/solitar/service/UrlService.kt index c94a9cd..8cee7e0 100644 --- a/apps/backend/src/main/kotlin/org/tobynguyen/solitar/service/UrlService.kt +++ b/apps/backend/src/main/kotlin/org/tobynguyen/solitar/service/UrlService.kt @@ -1,7 +1,6 @@ package org.tobynguyen.solitar.service import java.time.Instant -import org.springframework.dao.DataIntegrityViolationException import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.sqids.Sqids @@ -36,23 +35,48 @@ class UrlService(private val urlRepository: UrlRepository, private val sqids: Sq @Transactional fun createUrl(data: UrlCreateDto): UrlEntity { + val (url, expireTime, alias) = data - if (data.alias == null) { - val existing = - urlRepository.findByOriginalUrl(data.url).firstOrNull { - (it.expiresAt == null || it.expiresAt!!.isAfter(Instant.now())) && !it.hasAlias - } + if (alias != null) { + val existing = urlRepository.findByShortCode(alias) - if (existing != null) { - if (existing.isDisabled) { - throw UrlDisabledException("We cannot shorten this URL") + return if (existing != null) { + if (existing.originalUrl == url && existing.expiresAt == expireTime) { + existing + } else { + throw UrlShortCodeConflictedException("This alias already exists.") } + } else { + createAndSaveUrl(url, alias, expireTime) + } + } else { + if (expireTime == null) { + val existing = + urlRepository.findByOriginalUrlAndExpiresAtIsNullAndHasAliasFalse(url) - return existing + return existing ?: createAndSaveUrl(url, null, null) + } else { + val existing = + urlRepository.findByOriginalUrlAndExpiresAtAndHasAliasFalse(url, expireTime) + + return existing ?: createAndSaveUrl(url, null, expireTime) } + } + } + fun createAndSaveUrl(url: String, alias: String?, expireTime: Instant?): UrlEntity { + if (alias != null) { val entity = - UrlEntity(shortCode = "", originalUrl = data.url, expiresAt = data.expiresTime) + UrlEntity( + shortCode = alias, + originalUrl = url, + expiresAt = expireTime, + hasAlias = true, + ) + + return urlRepository.saveAndFlush(entity) + } else { + val entity = UrlEntity(shortCode = "", originalUrl = url, expiresAt = expireTime) val savedEntity = urlRepository.save(entity) @@ -61,20 +85,6 @@ class UrlService(private val urlRepository: UrlRepository, private val sqids: Sq savedEntity.shortCode = generatedCode return savedEntity - } else { - val entity = - UrlEntity( - shortCode = data.alias, - originalUrl = data.url, - expiresAt = data.expiresTime, - hasAlias = true, - ) - - try { - return urlRepository.saveAndFlush(entity) - } catch (e: DataIntegrityViolationException) { - throw UrlShortCodeConflictedException("This alias already exists.") - } } } } diff --git a/apps/backend/src/main/kotlin/org/tobynguyen/solitar/config/AppConfig.kt b/apps/backend/src/main/kotlin/org/tobynguyen/solitar/util/SqidsUtil.kt similarity index 80% rename from apps/backend/src/main/kotlin/org/tobynguyen/solitar/config/AppConfig.kt rename to apps/backend/src/main/kotlin/org/tobynguyen/solitar/util/SqidsUtil.kt index e4dd8c5..8693a77 100644 --- a/apps/backend/src/main/kotlin/org/tobynguyen/solitar/config/AppConfig.kt +++ b/apps/backend/src/main/kotlin/org/tobynguyen/solitar/util/SqidsUtil.kt @@ -1,11 +1,11 @@ -package org.tobynguyen.solitar.config +package org.tobynguyen.solitar.util import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.sqids.Sqids @Configuration -class AppConfig { +class SqidsUtil { @Bean fun sqids(): Sqids { diff --git a/apps/backend/src/main/resources/application-dev.yml b/apps/backend/src/main/resources/application-dev.yml index 1b8fb09..5efdd4f 100644 --- a/apps/backend/src/main/resources/application-dev.yml +++ b/apps/backend/src/main/resources/application-dev.yml @@ -1,9 +1,6 @@ spring: application: version: 1.0.0-dev - jpa: - hibernate: - ddl-auto: update app: cors: diff --git a/apps/backend/src/main/resources/application-prod.yml b/apps/backend/src/main/resources/application-prod.yml index a2216fd..c6cbf51 100644 --- a/apps/backend/src/main/resources/application-prod.yml +++ b/apps/backend/src/main/resources/application-prod.yml @@ -1,9 +1,6 @@ spring: application: version: 1.0.0-prod - jpa: - hibernate: - ddl-auto: update app: cors: diff --git a/apps/backend/src/main/resources/application.yaml b/apps/backend/src/main/resources/application.yaml index 8086ef8..df01d82 100644 --- a/apps/backend/src/main/resources/application.yaml +++ b/apps/backend/src/main/resources/application.yaml @@ -11,6 +11,12 @@ spring: data: redis: url: ${REDIS_URL} + flyway: + enabled: true + locations: classpath:db/migration + jpa: + hibernate: + ddl-auto: validate server: port: ${SERVER_PORT:8080} diff --git a/apps/backend/src/main/resources/db/migration/V1__create_urls_table.sql b/apps/backend/src/main/resources/db/migration/V1__create_urls_table.sql new file mode 100644 index 0000000..bee151e --- /dev/null +++ b/apps/backend/src/main/resources/db/migration/V1__create_urls_table.sql @@ -0,0 +1,17 @@ +CREATE SEQUENCE IF NOT EXISTS url_seq START WITH 1 INCREMENT BY 1; + +CREATE TABLE urls +( + id BIGINT NOT NULL, + short_code VARCHAR(255) NOT NULL, + has_alias BOOLEAN NOT NULL, + original_url TEXT NOT NULL, + click_count BIGINT NOT NULL, + is_disabled BOOLEAN NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + expires_at TIMESTAMP WITHOUT TIME ZONE, + CONSTRAINT pk_urls PRIMARY KEY (id) +); + +ALTER TABLE urls + ADD CONSTRAINT uc_urls_shortcode UNIQUE (short_code); \ No newline at end of file diff --git a/apps/frontend/src/app/components/form/UrlShortenerForm.vue b/apps/frontend/src/app/components/form/UrlShortenerForm.vue index 793b126..573694c 100644 --- a/apps/frontend/src/app/components/form/UrlShortenerForm.vue +++ b/apps/frontend/src/app/components/form/UrlShortenerForm.vue @@ -81,7 +81,7 @@ async function onSubmit(event: FormSubmitEvent>) { url: longUrl, alias, ...(!neverExpire && { - expiresTime: generateExpireTime(event.data.expireTime, event.data.expireUnit), + expireTime: generateExpireTime(event.data.expireTime, event.data.expireUnit), }), }; diff --git a/apps/frontend/src/app/utils/repository.ts b/apps/frontend/src/app/utils/repository.ts index d90d8c0..cbf2ab0 100644 --- a/apps/frontend/src/app/utils/repository.ts +++ b/apps/frontend/src/app/utils/repository.ts @@ -14,7 +14,7 @@ type UrlShortenerResponse = { export type UrlShortenerBody = { url: string; alias?: string; - expiresTime?: string; + expireTime?: string; }; export const repository = (fetch: $Fetch) => ({