Skip to content
Open
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
4 changes: 3 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ dependencies {
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
runtimeOnly("org.postgresql:postgresql")
implementation("org.json:json:20230618")
implementation("org.postgresql:postgresql")
implementation("org.flywaydb:flyway-core:9.3.1")

testImplementation("org.springframework.boot:spring-boot-starter-test")
}
Expand Down
9 changes: 9 additions & 0 deletions database.production.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
DATABASE_DRIVER=org.postgresql.Driver
POSTGRES_USER=postgres
POSTGRES_PASSWORD=7gn[[G780PH[,JP'HIUG
DATABASE_NAME=organisations
DATABASE_HOSTNAME=database
DATABASE_URL=jdbc:postgresql://database:5432/
DATABASE_PORT=5432
DATABASE_MAX_CONNECTIONS=20
DATABASE_MIGRATION=classpath:db/migration
8 changes: 7 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
version: "3.9"
volumes:
billie_pairing_exercise_data:
services:
traefik:
image: "traefik:v2.4"
Expand All @@ -25,6 +27,8 @@ services:
- database.env
ports:
- "5432:5432"
volumes:
- billie_pairing_exercise_data:/var/lib/postgresql/data
service:
build: .
links:
Expand All @@ -34,8 +38,10 @@ services:
- traefik
- database
env_file:
- database.env
- database.production.env
- service.env
ports:
- "8080:8080"
labels:
- "traefik.enable=true"
- "traefik.http.routers.organisations.rule=PathPrefix(`/swagger-ui`) || PathPrefix(`/organisations`) || PathPrefix(`/countries`)"
Expand Down
112 changes: 87 additions & 25 deletions src/main/kotlin/io/billie/organisations/data/OrganisationRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package io.billie.organisations.data

import io.billie.countries.model.CountryResponse
import io.billie.organisations.viewmodel.*
import org.postgresql.util.PGobject
import org.json.JSONObject
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.jdbc.core.ResultSetExtractor
Expand All @@ -14,6 +16,8 @@ import java.sql.Date
import java.sql.ResultSet
import java.util.*

// use JPA instead of manually writing queries
// no unit tests for this file

@Repository
class OrganisationRepository {
Expand All @@ -31,23 +35,29 @@ class OrganisationRepository {
if(!valuesValid(organisation)) {
throw UnableToFindCountry(organisation.countryCode)
}
val id: UUID = createContactDetails(organisation.contactDetails)
return createOrganisation(organisation, id)
val contactDetailsId: UUID = createContactDetails(organisation.contactDetails)
val addressId = createAddress(organisation.address)
return createOrganisation(organisation, contactDetailsId, addressId)
}

private fun valuesValid(organisation: OrganisationRequest): Boolean {
val addressCountryCode = organisation.address.countryCode
return countryCodeValid(organisation.countryCode) && countryCodeValid(addressCountryCode)
}

private fun countryCodeValid(countryCode: String): Boolean {
val reply: Int? = jdbcTemplate.query(
"select count(country_code) from organisations_schema.countries c WHERE c.country_code = ?",
ResultSetExtractor {
it.next()
it.getInt(1)
},
organisation.countryCode
countryCode
)
return (reply != null) && (reply > 0)
}

private fun createOrganisation(org: OrganisationRequest, contactDetailsId: UUID): UUID {
private fun createOrganisation(org: OrganisationRequest, contactDetailsId: UUID, addressId: UUID): UUID {
val keyHolder: KeyHolder = GeneratedKeyHolder()
jdbcTemplate.update(
{ connection ->
Expand All @@ -59,8 +69,9 @@ class OrganisationRepository {
"vat_number, " +
"registration_number, " +
"legal_entity_type, " +
"contact_details_id" +
") VALUES (?, ?, ?, ?, ?, ?, ?)",
"contact_details_id, " +
"address_id" +
") VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
arrayOf("id")
)
ps.setString(1, org.name)
Expand All @@ -70,12 +81,14 @@ class OrganisationRepository {
ps.setString(5, org.registrationNumber)
ps.setString(6, org.legalEntityType.toString())
ps.setObject(7, contactDetailsId)
ps.setObject(8, addressId)
ps
}, keyHolder
)
return keyHolder.getKeyAs(UUID::class.java)!!
}

// contact details creation can be moved to a separate repository
private fun createContactDetails(contactDetails: ContactDetailsRequest): UUID {
val keyHolder: KeyHolder = GeneratedKeyHolder()
jdbcTemplate.update(
Expand All @@ -99,24 +112,60 @@ class OrganisationRepository {
return keyHolder.getKeyAs(UUID::class.java)!!
}

private fun organisationQuery() = "select " +
"o.id as id, " +
"o.name as name, " +
"o.date_founded as date_founded, " +
"o.country_code as country_code, " +
"c.id as country_id, " +
"c.name as country_name, " +
"o.VAT_number as VAT_number, " +
"o.registration_number as registration_number," +
"o.legal_entity_type as legal_entity_type," +
"o.contact_details_id as contact_details_id, " +
"cd.phone_number as phone_number, " +
"cd.fax as fax, " +
"cd.email as email " +
"from " +
"organisations_schema.organisations o " +
"INNER JOIN organisations_schema.contact_details cd on o.contact_details_id::uuid = cd.id::uuid " +
"INNER JOIN organisations_schema.countries c on o.country_code = c.country_code "
private fun createAddress(address: AddressRequest): UUID {
val keyHolder: KeyHolder = GeneratedKeyHolder()
jdbcTemplate.update(
{ connection ->
val addressData = hashMapOf<String,Any?>("line1" to address.line1,
"country_code" to address.countryCode,
"city" to address.city,
"post_code" to address.postCode,
"line2" to address.line2
)
// val jsonObject = PGobject()
// jsonObject.setType("json")
val ps = connection.prepareStatement(
"insert into organisations_schema.addresses (data) VALUES (?) ",
arrayOf("id")
)
val jsonObject = PGobject()
jsonObject.type = "json"
jsonObject.value = JSONObject(addressData).toString()
ps.setObject(1, jsonObject)
ps
},
keyHolder
)
return keyHolder.getKeyAs(UUID::class.java)!!
}

private fun organisationQuery() = """
select
o.id as id,
o.name as name,
o.date_founded as date_founded,
o.country_code as country_code,
c.id as country_id,
c.name as country_name,
o.VAT_number as VAT_number,
o.registration_number as registration_number,
o.legal_entity_type as legal_entity_type,
o.contact_details_id as contact_details_id,
cd.phone_number as phone_number,
cd.fax as fax,
cd.email as email,
o.address_id as address_id,
oa.data -> 'line1' as address_line1,
oa.data -> 'line2' as address_line2,
oa.data -> 'city' as address_city,
oa.data -> 'post_code' as address_post_code,
oa.data -> 'country_code' as address_country_code
from
organisations_schema.organisations o
INNER JOIN organisations_schema.contact_details cd on o.contact_details_id::uuid = cd.id::uuid
INNER JOIN organisations_schema.addresses oa on o.address_id::uuid = oa.id::uuid
INNER JOIN organisations_schema.countries c on o.country_code = c.country_code
""".trimIndent()

private fun organisationMapper() = RowMapper<OrganisationResponse> { it: ResultSet, _: Int ->
OrganisationResponse(
Expand All @@ -127,7 +176,8 @@ class OrganisationRepository {
it.getString("vat_number"),
it.getString("registration_number"),
LegalEntityType.valueOf(it.getString("legal_entity_type")),
mapContactDetails(it)
mapContactDetails(it),
mapAddress(it)
)
}

Expand All @@ -140,6 +190,18 @@ class OrganisationRepository {
)
}

private fun mapAddress(it: ResultSet): Address? {
val addressId: String = it.getString("address_id") ?: return null
return Address(
UUID.fromString(addressId),
it.getString("address_line1"),
it.getString("address_line2"),
it.getString("address_post_code"),
it.getString("address_city"),
it.getString("address_country_code")
)
}

private fun mapCountry(it: ResultSet): CountryResponse {
return CountryResponse(
it.getObject("country_id", UUID::class.java),
Expand Down
13 changes: 13 additions & 0 deletions src/main/kotlin/io/billie/organisations/viewmodel/Address.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package io.billie.organisations.viewmodel

import com.fasterxml.jackson.annotation.JsonProperty
import java.util.UUID

data class Address(
val id: UUID,
val line1: String,
val line2: String?,
@JsonProperty("post_code") val postCode: String,
val city: String,
@JsonProperty("country_code") val countryCode: String
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package io.billie.organisations.viewmodel

import com.fasterxml.jackson.annotation.JsonProperty
import javax.validation.constraints.NotBlank

data class AddressRequest(
@field:NotBlank val line1: String,
val line2: String?,
@field:NotBlank @JsonProperty("post_code") val postCode: String,
@field:NotBlank val city: String,
@field:NotBlank @JsonProperty("country_code") val countryCode: String
)
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ data class OrganisationRequest(
@JsonProperty("registration_number") val registrationNumber: String?,
@JsonProperty("legal_entity_type") val legalEntityType: LegalEntityType,
@JsonProperty("contact_details") val contactDetails: ContactDetailsRequest,
@JsonProperty("address") val address: AddressRequest
)
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ data class OrganisationResponse(
@JsonProperty("registration_number") val registrationNumber: String?,
@JsonProperty("legal_entity_type") val legalEntityType: LegalEntityType,
@JsonProperty("contact_details") val contactDetails: ContactDetails,
@JsonProperty("address") val address: Address?
)
12 changes: 12 additions & 0 deletions src/main/resources/application-production.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
spring.config.import=optional:file:./database.production.env[.properties]
spring.datasource.driver-class-name=${DATABASE_DRIVER}
spring.datasource.url=${DATABASE_URL}
spring.datasource.username=${POSTGRES_USER}
spring.datasource.password=${POSTGRES_PASSWORD}
spring.datasource.schema=${DATABASE_SCHEMA}
spring.datasource.initialization-mode=always
spring.flyway.enabled=true
spring.flyway.locations=${DATABASE_MIGRATION}
spring.flyway.url = ${DATABASE_URL}
spring.flyway.password=${POSTGRES_PASSWORD}
spring.flyway.user=${POSTGRES_USER}
5 changes: 5 additions & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,8 @@ spring.datasource.username=${POSTGRES_USER}
spring.datasource.password=${POSTGRES_PASSWORD}
spring.datasource.schema=${DATABASE_SCHEMA}
spring.datasource.initialization-mode=always
spring.flyway.enabled=true
spring.flyway.locations=${DATABASE_MIGRATION}
spring.flyway.url = ${DATABASE_URL}
spring.flyway.password=${POSTGRES_PASSWORD}
spring.flyway.user=${POSTGRES_USER}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE organisations_schema.organisations
ADD COLUMN address_id VARCHAR(36);
5 changes: 5 additions & 0 deletions src/main/resources/db/migration/V9__add_adresses_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS organisations_schema.addresses
(
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
data jsonb NOT NULL
);
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
package io.billie.functional

// database is not cleaned up after each test run
import com.fasterxml.jackson.databind.ObjectMapper
import io.billie.functional.data.Fixtures.bbcAddressFixture
import io.billie.functional.data.Fixtures.bbcContactFixture
import io.billie.functional.data.Fixtures.bbcFixture
import io.billie.functional.data.Fixtures.orgRequestJson
import io.billie.functional.data.Fixtures.orgRequestJsonAddressCountryCodeIncorrect
import io.billie.functional.data.Fixtures.orgRequestJsonCountryCodeBlank
import io.billie.functional.data.Fixtures.orgRequestJsonCountryCodeIncorrect
import io.billie.functional.data.Fixtures.orgRequestJsonNoName
import io.billie.functional.data.Fixtures.orgRequestJsonNameBlank
import io.billie.functional.data.Fixtures.orgRequestJsonNoAddres
import io.billie.functional.data.Fixtures.orgRequestJsonNoContactDetails
import io.billie.functional.data.Fixtures.orgRequestJsonNoCountryCode
import io.billie.functional.data.Fixtures.orgRequestJsonNoLegalEntityType
import io.billie.organisations.viewmodel.Entity
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.core.IsEqual.equalTo
import org.json.JSONObject
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.postgresql.util.PGobject
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
Expand Down Expand Up @@ -94,6 +100,14 @@ class CanStoreAndReadOrganisationTest {
.andExpect(status().isBadRequest)
}

@Test
fun cannotStoreOrgWhenAddressCountryCodeIsNotRecognised() {
mockMvc.perform(
post("/organisations").contentType(APPLICATION_JSON).content(orgRequestJsonAddressCountryCodeIncorrect())
)
.andExpect(status().isBadRequest)
}

@Test
fun cannotStoreOrgWhenNoLegalEntityType() {
mockMvc.perform(
Expand All @@ -110,6 +124,14 @@ class CanStoreAndReadOrganisationTest {
.andExpect(status().isBadRequest)
}

@Test
fun cannotStoreOrgWhenNoAddress() {
mockMvc.perform(
post("/organisations").contentType(APPLICATION_JSON).content(orgRequestJsonNoAddres())
)
.andExpect(status().isBadRequest)
}

@Test
fun canStoreOrg() {
val result = mockMvc.perform(
Expand All @@ -126,6 +148,13 @@ class CanStoreAndReadOrganisationTest {
val contactDetailsId: UUID = UUID.fromString(org["contact_details_id"] as String)
val contactDetails: Map<String, Any> = contactDetailsFromDatabase(contactDetailsId)
assertDataMatches(contactDetails, bbcContactFixture(contactDetailsId))

if (org["address_id"] != null){
val addressId: UUID = UUID.fromString(org["address_id"] as String)
val address: Map<String, Any> = addressFromDatabase(addressId)
assertEquals(address["id"],addressId)
assertDataMatches(JSONObject((address["data"] as PGobject).value).toMap(), bbcAddressFixture())
}
}

fun assertDataMatches(reply: Map<String, Any>, assertions: Map<String, Any>) {
Expand All @@ -143,4 +172,6 @@ class CanStoreAndReadOrganisationTest {
private fun contactDetailsFromDatabase(id: UUID): MutableMap<String, Any> =
queryEntityFromDatabase("select * from organisations_schema.contact_details where id = ?", id)

private fun addressFromDatabase(id: UUID): MutableMap<String, Any> =
queryEntityFromDatabase("select * from organisations_schema.addresses where id = ?", id)
}
Loading