How to build a CRM with Kotlin and Spring Boot

How to build a CRM with Kotlin and Spring Boot

Purpose

This article will show you how to build a CRM with Kotlin and Spring Boot 3, developed with Java 17 and the Gradle build system.

The CRM application will provide the following features;

  • A Login page with Spring Security based authentication
  • A Dashboard page
  • A Customer list page, with a data grid that allows the user to browse and find their customers quickly using pagination, search, and sort functionality.
  • A User list, also with a fully functional data grid that allows the administrators to manage users of the system.
  • Create, Read, Update, and Delete basic CRUD functions.
  • Layout and views designed with Thymeleaf and Bootstrap.

I’ll utilize IntelliJ Idea as my preferred IDE, an H2 in-memory database loaded with 1000 mock records, Spring Boot, and the Thymeleaf templating system.

All source code can be found on our Github repository.

Let’s dive in and construct a CRM using Kotlin and Spring Boot.

Setup

IntelliJ offers a user-friendly interface to the Spring Initializer, which we’ll leverage to kickstart our project. Inside IntelliJ, initiate a new project and choose the Spring Boot Initializr option.

Kotlin CRM setup with Spring Initializer

Click “Next” to choose our starting dependencies. Based on the image provided, I’ve chosen

  • Spring Boot Dev Tools
  • Spring Web
  • Validation
  • Thymeleaf Templating Engine
  • Spring Data JPA
  • H2 Database
  • Spring Security
IntelliJ Spring Initializer Dependencies for CRM

Click “Create,” and we’ll dive into the essential code to bring everything together.

After your project is initialised, there are some extra dependencies we should incorporate into the build.gradle.kts file. Insert the following into your dependencies

implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity6")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")

The Data

We’ll start by defining our Customer entity, representing the ‘C’ in CRM. We’ll implement a basic set of validation rules to ensure each customer record possesses both a first and last name.

This serves as a foundation to showcase validation enforcement and how to relay errors to the user through Thymeleaf templates.

Now, let’s delve into our Customer domain

@Entity
class Customer {

    @Id
    @Column
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null

    @NotBlank
    var firstName: String? = null

    @NotBlank
    var lastName: String? = null

    @Email
    var emailAddress: String? = null

    var address: String? = null

    var city: String? = null

    var country: String? = null

    var phoneNumber: String? = null
}

We’ve assigned the GenerationType for the Id field as GenerationType.IDENTITY instead of GenerationType.AUTO. This is because using AUTO with H2 can lead to issues when trying to generate the next sequence number for new record additions.

For the first and last name fields, we’ve incorporated two @NotBlank validation constraints. Simply using @NotNull won’t suffice, as we aim to prevent entries with just blank spaces as well

Thanks to mockaroo, we can produce fictitious sample data to pre-populate our customer table. This allows us to immediately increase our productivity when we’re working against some actual data, we can see straightaway our sorting and filtering behave as expected.

To utilise this sample data include the following two files in the src/main/resources directory.

When the application boots up, Spring Boot will establish the database schema and execute all SQL insert commands, populating our database with 1,000 customers.

Now, let’s move on to defining the User entity

@Entity
@Table(name = "appuser")
class User(
    @NotNull
    @Enumerated(EnumType.STRING)
    var role: Role? = null,

    @NotBlank
    @Column(nullable = false)
    var username: String? = null,

    @NotBlank
    @Column(nullable = false)
    var fullName: String? = null,

    @Column(nullable = false, length = 64)
    var password: String? = null,

    var enabled: Boolean = true
) {
    @Id
    @Column
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null

    @CreationTimestamp
    @Column(updatable = false)
    val dateCreated: LocalDateTime? = null
}

JPA Data Repositories

Spring Data JPA can manage many of our CRUD tasks. For basic operations like ‘Find By,’ ‘Save,’ and ‘Delete,’ we can simply craft an interface extending CrudRepository, eliminating further work.

Yet, we’re incorporating a comprehensive datatable component on our list view. We aim to allow users to search across multiple fields simultaneously, producing sorted and paginated outcomes.

Thus, within a ‘repositories’ package, craft a UserRepository class that includes the following:

@Repository
interface UserRepository : CrudRepository<User, Long>,
    PagingAndSortingRepository<User, Long>,
    JpaSpecificationExecutor<User> {
    fun findByUsername(username: String): Optional<User?>?
}

and then a CustomerRepository with the following

@Repository
interface CustomerRepository : CrudRepository<Customer, Long>,
    PagingAndSortingRepository<Customer, Long?>,
    JpaSpecificationExecutor<Customer>

Default Data


To initialize users when the application starts, I usually create a file named Bootstrap.java within an ‘init’ package. Inside this class, I set up a EventListener tailored to respond to the ApplicationReadyEvent. Here, I execute any system initialization tasks. The content for this class should be as follows:

@Component
class Bootstrap(
    private val userRepository: UserRepository,
    private val passwordEncoder: PasswordEncoder
) {

    @EventListener
    fun appReady(event: ApplicationReadyEvent) {

        // Initialise initial users
        // Create admin user.
        if (!userRepository.findByUsername("admin")!!.isPresent) {
            val adminUser = User(
                username = "admin",
                fullName = "Sheev Palpatine",
                password = passwordEncoder.encode("admin"),
                role = Role.ADMIN,
                enabled = true
            )

            userRepository.save(adminUser)
        }

        // Create standard user.
        if (!userRepository.findByUsername("user")!!.isPresent) {
            val adminUser = User(
                username = "user",
                fullName = "Darth Tyranus",
                password = passwordEncoder.encode("user"),
                role = Role.USER,
                enabled = true
            )

            userRepository.save(adminUser)
        }

        // create readonly user
        if (!userRepository.findByUsername("readonly_user")!!.isPresent) {

            val adminUser = User(
                username = "readonly_user",
                fullName = "Shin Hati",
                password = passwordEncoder.encode("readonly_user"),
                role = Role.READONLY_USER,
                enabled = true
            )

            userRepository.save(adminUser)
        }
    }

}

Custom Filter Specifications

We inherit from ‘PagingAndSortingRepositoryto facilitate paging and sorting. Additionally, we use ‘JpaSpecificationExecutor' to enable filtering through a custom Hibernate Criteria Specification.

In the ‘specifications’ package, form a ‘UserDatatableFilter‘ class and incorporate the subsequent code


class UserDatatableFilter(var userQuery: String) :
    Specification<User?> {

    override fun toPredicate(root: Root<User?>, query: CriteriaQuery<*>, criteriaBuilder: CriteriaBuilder): Predicate? {
        val predicates = ArrayList<Predicate>()
        if (!userQuery.isNullOrEmpty()) {
            val lowerCaseQuery = userQuery.lowercase() // Convert query to lowercase
            predicates.add(
                criteriaBuilder.like(
                    criteriaBuilder.lower(root["username"]),
                    "%$lowerCaseQuery%"
                )
            )
            predicates.add(
                criteriaBuilder.like(
                    criteriaBuilder.lower(root["fullName"]),
                    "%$lowerCaseQuery%"
                )
            )
        }
        return if (predicates.isNotEmpty()) criteriaBuilder.or(*predicates.toTypedArray()) else null
    }
}

And then, a CustomerDatatableFilter class with the following:

class CustomerDatatableFilter(var userQuery: String) : Specification<Customer> {
    override fun toPredicate(
        root: Root<Customer>,
        query: CriteriaQuery<*>,
        criteriaBuilder: CriteriaBuilder
    ): Predicate? {
        val predicates = ArrayList<Predicate>()

        if (!userQuery.isNullOrEmpty()) {
            val lowerCaseQuery = userQuery.lowercase() // Convert query to lowercase
            predicates.add(
                criteriaBuilder.like(
                    criteriaBuilder.lower(root["firstName"]),
                    "%$lowerCaseQuery%"
                )
            )
            predicates.add(criteriaBuilder.like(criteriaBuilder.lower(root["lastName"]), "%$lowerCaseQuery%"))
            predicates.add(criteriaBuilder.like(criteriaBuilder.lower(root["city"]), "%$lowerCaseQuery%"))
            predicates.add(
                criteriaBuilder.like(
                    criteriaBuilder.lower(root["emailAddress"]),
                    "%$lowerCaseQuery%"
                )
            )
            predicates.add(
                criteriaBuilder.like(
                    criteriaBuilder.lower(root["phoneNumber"]),
                    "%$lowerCaseQuery%"
                )
            )
            predicates.add(criteriaBuilder.like(criteriaBuilder.lower(root["country"]), "%$lowerCaseQuery%"))
        }

        return if (predicates.isNotEmpty()) criteriaBuilder.or(*predicates.toTypedArray()) else null

    }
}

We’ll provide our users with a singular search input textbox. As they begin typing, we’ll execute a Criteria-based query on our Customer fields, delivering the results accordingly.

For this scenario, we’ll instantiate this filter, feeding the user’s input into the 'userQuery‘ field. We’ll then employ this to carry out ‘like’ criteria—essentially wildcard searches—across the name, city, address, phone number, and country fields simultaneously.

Leveraging Criteria Specifications endows us with immense versatility and potency in how we search within our tables.

Spring Security Interfaces

To set up Spring Security, our initial step involves crafting two custom classes. These enable Spring Security to interface with our User entity. Within a fresh ‘security’ package, formulate a new class titled CustomUserDetails. It’s designed to enact Spring Security’s ‘UserDetails', but with a twist—it has a constructor that accommodates our User entity. This allows us to fill a ‘UserDetails‘ object seamlessly. This class holds significance throughout the Spring Security authentication flow.

Incorporate the subsequent content into ‘CustomUserDetails‘:

class CustomUserDetails(private val user: User) : UserDetails {

    override fun getAuthorities(): Collection<GrantedAuthority?> {
        val authority = SimpleGrantedAuthority(user.role.toString())
        return List.of(authority)
    }

    override fun getPassword(): String {
        return user.password!!
    }

    override fun getUsername(): String {
        return user.username!!
    }

    override fun isAccountNonExpired(): Boolean {
        return true
    }

    override fun isAccountNonLocked(): Boolean {
        return true
    }

    override fun isCredentialsNonExpired(): Boolean {
        return true
    }

    override fun isEnabled(): Boolean {
        return user.enabled
    }
}

Next, to empower Spring Security to authenticate via our User JPA entity, we’ll implement ‘UserDetailsService‘. So, within the ‘security’ package, craft another class named ‘CustomUserDetailsService‘. Input the following code::

class CustomUserDetailsService(private val userRepository: UserRepository) : UserDetailsService {

    override fun loadUserByUsername(username: String): UserDetails {
        val user = userRepository.findByUsername(username)
            ?.orElseThrow { UsernameNotFoundException("User not found") }!!

        return CustomUserDetails(user)
    }
}

Configuration

Next, let’s set up Spring Security to recognize and utilize these classes.

In a new ‘config’ package, create a class named ‘SecurityConfig‘. This class will present multiple security-related Beans. Because of their class structures, Spring Security will automatically detect and use them.

The 'SecurityConfig' class should have the following code:

@Configuration
@EnableWebSecurity
class SecurityConfig {

    @Autowired
    lateinit var userRepository: UserRepository

    private val log = LoggerFactory.getLogger(this.javaClass)

    @Bean
    @Throws(Exception::class)
    fun securityFilterChain(http: HttpSecurity, mvc: MvcRequestMatcher.Builder): SecurityFilterChain? {
        http
            .csrf { obj: CsrfConfigurer<HttpSecurity> -> obj.disable() }
            .authorizeHttpRequests(
                Customizer { requests ->
                    requests // only allow standard user and admin to edit customers
                        .requestMatchers(mvc.pattern("/customer/edit/**"))
                        .hasAnyAuthority("ADMIN", "USER") // only allow admin and standard users to edit customers
                        .requestMatchers(mvc.pattern("/customer/create/**"))
                        .hasAnyAuthority("ADMIN", "USER") // only allow admin and standard users to create customers
                        .requestMatchers(mvc.pattern("/user/**")).hasAuthority("ADMIN")
                        .anyRequest().authenticated()
                }
            ) // allow access to login endpoint
            .formLogin { form: FormLoginConfigurer<HttpSecurity?> ->
                form
                    .loginPage("/login")
                    .permitAll()
            } // allow access to logout endpoint
            .logout { obj: LogoutConfigurer<HttpSecurity?> -> obj.permitAll() } // in case of 403 / Forbidden authorization failure, use a custom handler
            .exceptionHandling { exception: ExceptionHandlingConfigurer<HttpSecurity?> ->
                exception.accessDeniedHandler(
                    accessDeniedHandler()
                )
            }
        return http.build()
    }

    @Bean
    fun mvc(introspector: HandlerMappingIntrospector?): MvcRequestMatcher.Builder? {
        return MvcRequestMatcher.Builder(introspector)
    }

    @Bean
    fun passwordEncoder(): PasswordEncoder? {
        return BCryptPasswordEncoder()
    }

    @Bean
    fun userDetailsService(): UserDetailsService? {
        return CustomUserDetailsService(userRepository)
    }

    @Bean
    fun authenticationProvider(): AuthenticationProvider? {
        val authenticationProvider = DaoAuthenticationProvider()
        authenticationProvider.setUserDetailsService(userDetailsService())
        authenticationProvider.setPasswordEncoder(passwordEncoder())
        return authenticationProvider
    }

    @Bean
    fun accessDeniedHandler(): AccessDeniedHandler? {
        return AccessDeniedHandler { request: HttpServletRequest, response: HttpServletResponse?,
                                     accessDeniedException: AccessDeniedException? ->

            log.warn(accessDeniedException?.message)

            request.setAttribute("error", "You do not have permission to access this page. ")
            val dispatcher = request.getRequestDispatcher("/")
            dispatcher.forward(request, response)
        }
    }
}

Data Transfer Objects (DTOs)

Another class we’re adding is the ‘UserDTO'. For the User endpoints, I’m adopting this method as it aligns with industry best practices and is the common approach, as opposed to employing Entity classes directly in your parameters. This also offers the flexibility to apply distinct validation from what might be in the Entity, and it allows for declaring only a subset of the fields. In the ‘dto’ package, create a new ‘UserDTO‘ class with the following content:

class UserDTO {
    var id: Long = 0

    @NotNull
    var role: Role? = null

    @NotBlank
    var username: String? = null

    @NotBlank
    var fullName: String? = null

    var password: String? = null

    var enabled: Boolean? = false
}

Services


First, let’s create a customer service that will contain the business logic for handling Customer filtering needs. In the ‘services’ package, establish a ‘CustomerServiceclass and include the following code:

@Service
class CustomerService(private val customerRepository: CustomerRepository) {

    fun getCustomersForDatatable(queryString: String, pageable: Pageable): Page<Customer> {
        val customerDatatableFilter = CustomerDatatableFilter(queryString)
        return customerRepository.findAll(customerDatatableFilter, pageable)
    }

}

Following that, we require a 'UserService'. This service will supply data for the Datatable grid and serve as the foundation for several CRUD operations. When creating users, there’s some additional logic involved, such as password encoding. When the logic transcends basic operations, it’s advisable to transition this from the controller layer to a service layer. So, within the ‘services’ package, establish a ‘UserService' class.

@Service
class UserService(
    private val userRepository: UserRepository,
    private val passwordEncoder: PasswordEncoder
) {


    fun getUsersForDatatable(queryString: String?, pageable: Pageable?): Page<User> {
        val userDatatableFilter = UserDatatableFilter(queryString!!)
        return userRepository.findAll(userDatatableFilter, pageable!!)
    }

    fun createNewUser(userDTO: UserDTO): User? {

        val user: User = User(
            fullName = userDTO.username,
            role = userDTO.role,
            username = userDTO.username,
            password = passwordEncoder.encode(userDTO.password),
            enabled = true
        )

        return userRepository.save(user)
    }

    fun updateUser(userDTO: UserDTO): User? {
        val user = userRepository.findById(userDTO.id).orElseThrow()!!

        user.fullName = userDTO.fullName
        user.username = userDTO.username
        user.enabled = userDTO.enabled!!
        user.role = userDTO.role

        return userRepository.save(user)
    }
}

The Controller Layer

With our data and services layer established, incorporating both custom code and the Spring Data JPA implementation, it’s time to focus on the controller layer to cater to our web requests.

We’ll leverage Spring MVC to manage request routing, variable binding, and Bean validation on our behalf.

Additionally, we’ll employ Thymeleaf for templating, which aids in embedding our dynamic content into HTML-based templates.

In the ‘controllers’ package, create a ‘CustomerWebController' class and include the following code:


@Controller
@RequestMapping("/customer")
class CustomerWebController(
    private val customerRepository: CustomerRepository,
    private val customerService: CustomerService,
    private val objectMapper: ObjectMapper
) {

    @GetMapping
    fun index(): String {
        return "/customer/index"
    }

    @GetMapping("/data_for_datatable")
    @ResponseBody
    fun getDataForDatatable(@RequestParam params: Map<String, Any>): String {
        val draw = params["draw"]?.toString()?.toIntOrNull() ?: 1
        val length = params["length"]?.toString()?.toIntOrNull() ?: 30
        val start = params["start"]?.toString()?.toIntOrNull() ?: 30
        val currentPage = start / length

        val sortName: String = params["columns[${params["order[0][column]"]?.toString()}][data]"]?.toString() ?: "id"
        val sortDir = params["order[0][dir]"]?.toString() ?: "asc"

        val sortOrder = if (sortDir == "desc") Sort.Order.desc(sortName) else Sort.Order.asc(sortName)
        val sort = Sort.by(sortOrder)

        val pageRequest = PageRequest.of(currentPage, length, sort)

        val queryString = params["search[value]"] as? String ?: ""

        val customers: Page<Customer> = customerService.getCustomersForDatatable(queryString, pageRequest)

        val totalRecords = customers.totalElements

        val cells = customers.map { customer ->
            mapOf(
                "id" to customer.id,
                "firstName" to customer.firstName,
                "lastName" to customer.lastName,
                "emailAddress" to customer.emailAddress,
                "city" to customer.city,
                "country" to customer.country,
                "phoneNumber" to customer.phoneNumber
            ).toMutableMap()
        }.toList()

        val jsonMap = mutableMapOf(
            "draw" to draw,
            "recordsTotal" to totalRecords,
            "recordsFiltered" to totalRecords,
            "data" to cells
        )

        var json: String? = null
        try {
            json = objectMapper.writeValueAsString(jsonMap)
        } catch (e: JsonProcessingException) {
            e.printStackTrace()
        }

        return json ?: ""
    }

    @GetMapping("/show/{id}")
    fun show(@PathVariable id: Long, model: Model): String {
        val customerInstance = customerRepository.findById(id).get()
        model.addAttribute("customerInstance", customerInstance)
        return "/customer/show"
    }

    @GetMapping("/edit/{id}")
    fun edit(@PathVariable id: Long, model: Model): String {
        val customerInstance = customerRepository.findById(id).get()
        model.addAttribute("customerInstance", customerInstance)
        return "/customer/edit"
    }

    @PostMapping("/update")
    fun update(
        @ModelAttribute("customerInstance") customerInstance: @Valid Customer?,
        bindingResult: BindingResult,
        model: Model?,
        atts: RedirectAttributes
    ): String? {
        return if (bindingResult.hasErrors()) {
            "/customer/edit"
        } else {
            if (customerRepository.save(customerInstance!!) != null)
                atts.addFlashAttribute("message", "Customer updated successfully")
            else
                atts.addFlashAttribute("message", "Customer update failed.")

            "redirect:/customer"
        }
    }

    @GetMapping("/create")
    fun create(model: Model): String {
        model.addAttribute("customerInstance", Customer())
        return "/customer/create"
    }

    @PostMapping("/save")
    fun save(
        @ModelAttribute("customerInstance") customerInstance: @Valid Customer,
        bindingResult: BindingResult,
        model: Model?,
        atts: RedirectAttributes
    ): String? {
        return if (bindingResult.hasErrors()) {
            "/customer/create"
        } else {
            if (customerRepository.save(customerInstance) != null)
                atts.addFlashAttribute("message", "Customer created successfully")
            else
                atts.addFlashAttribute("message", "Customer creation failed.")

            "redirect:/customer"
        }
    }

    @PostMapping("/delete")
    fun delete(@RequestParam id: Long, atts: RedirectAttributes): String {
        val customerInstance = customerRepository.findById(id)
            .orElseThrow { IllegalArgumentException("Customer Not Found:$id") }

        customerRepository.delete(customerInstance)
        atts.addFlashAttribute("message", "Customer deleted.")

        return "redirect:/customer"
    }
}

Within the above controller class, the index() method stands out as a mere placeholder for the customer index page. We’re not processing any data here since our datatable will initiate the data call via an Ajax request.

The heart of the processing lies within the getDataForDatatable() method. Here, we first discern parameters that inform our pagination and sorting. This sets the foundation for the 'PageRequest‘, which we subsequently combine with any query string to invoke getCustomersForDatatable() from our CustomerService.

Following this, we cycle through the results, constructing a map tailored to Datatable’s expectations. An ‘ObjectMapper‘ then translates our map into JSON format.

Our controller’s remaining methods serve as facades for our rudimentary CRUD operations: edit() and update(), create() and save(), and delete(). I’m partial to employing the Flash Scope to relay a confirmation message to the user post successful action execution. Thus, we harness ‘RedirectAttributes atts’ within our method parameters to designate attributes in the Flash scope.

Let’s now shift our attention to the User controller. Formulate a ‘UserWebController‘ class and incorporate the following code

@Controller
@RequestMapping("/user")
class UserWebController(
    private val userRepository: UserRepository,
    private val userService: UserService,
    private val objectMapper: ObjectMapper
) {

    @GetMapping
    fun index(): String {
        return "/user/index"
    }

    @GetMapping("/data_for_datatable")
    @ResponseBody
    fun getDataForDatatable(@RequestParam params: Map<String, Any>): String {
        val draw = params["draw"]?.toString()?.toIntOrNull() ?: 1
        val length = params["length"]?.toString()?.toIntOrNull() ?: 30
        val start = params["start"]?.toString()?.toIntOrNull() ?: 30
        val currentPage = start / length

        val sortName: String = params["columns[${params["order[0][column]"]?.toString()}][data]"]?.toString() ?: "id"
        val sortDir = params["order[0][dir]"]?.toString() ?: "asc"

        val sortOrder = if (sortDir == "desc") Sort.Order.desc(sortName) else Sort.Order.asc(sortName)
        val sort = Sort.by(sortOrder)

        val pageRequest = PageRequest.of(currentPage, length, sort)

        val queryString = params["search[value]"] as? String ?: ""

        val users: Page<User> = userService.getUsersForDatatable(queryString, pageRequest)

        val totalRecords = users.totalElements

        val cells = users.map { user ->
            mapOf(
                "id" to user.id,
                "username" to user.username,
                "fullName" to user.fullName,
                "enabled" to user.enabled,
                "dateCreated" to user.dateCreated
            ).toMutableMap()
        }.toList()

        val jsonMap = mutableMapOf(
            "draw" to draw,
            "recordsTotal" to totalRecords,
            "recordsFiltered" to totalRecords,
            "data" to cells
        )

        var json: String? = null
        try {
            json = objectMapper.writeValueAsString(jsonMap)
        } catch (e: JsonProcessingException) {
            e.printStackTrace()
        }

        return json ?: ""
    }

    @GetMapping("/edit/{id}")
    fun edit(@PathVariable id: Long, model: Model): String {
        val userInstance = userRepository.findById(id).get()
        model.addAttribute("userInstance", userInstance)
        return "/user/edit.html"
    }

    @PostMapping("/update")
    fun update(
        @ModelAttribute("userInstance") userDTO: @Valid UserDTO?,
        bindingResult: BindingResult,
        atts: RedirectAttributes
    ): String? {
        return if (bindingResult.hasErrors()) {
            "/user/edit.html"
        } else {
            if (userService.updateUser(userDTO!!) != null) atts.addFlashAttribute(
                "message",
                "User updated successfully"
            ) else atts.addFlashAttribute("message", "User update failed.")
            "redirect:/user"
        }
    }

    @GetMapping("/create")
    fun create(model: Model): String {
        model.addAttribute("userInstance", User())
        return "/user/create.html"
    }

    @PostMapping("/save")
    fun save(
        @ModelAttribute("userInstance") userDTO: @Valid UserDTO?,
        bindingResult: BindingResult,
        atts: RedirectAttributes
    ): String? {
        return if (bindingResult.hasErrors()) {
            "/user/create.html"
        } else {
            if (userService.createNewUser(userDTO!!) != null) atts.addFlashAttribute(
                "message",
                "User created successfully"
            ) else atts.addFlashAttribute("message", "User creation failed.")
            "redirect:/user"
        }
    }

    @PostMapping("/delete")
    fun delete(@RequestParam id: Long, atts: RedirectAttributes): String {
        val userInstance = userRepository.findById(id)
            .orElseThrow {
                IllegalArgumentException(
                    "User Not Found:$id"
                )
            }
        userRepository.delete(userInstance)
        atts.addFlashAttribute("message", "User deleted.")
        return "redirect:/user"
    }
}

That's it for the M(Model) and C(Controller) sections of our MVC application, next let's move onto the views.

The View Layer

With our backend set up and poised for UI interaction, it’s time to integrate Thymeleaf templates into our project. For the sake of conciseness, I won’t display the entire template content here. Instead, I recommend checking the provided repository and importing the templates into your project.

While Thymeleaf unveils a plethora of functionalities in this tutorial, bear in mind that this article merely skims the surface of its capabilities. I encourage you to use this project as a foundation and build upon it for deeper understanding.

To enhance the user interface and ensure consistent display across different browsers, our templates utilise Twitter Bootstrap. Additionally, we’re leveraging jQuery to imbue some JavaScript functionalities.

Our home page has no dynamic content except for the copyright notice where I write the current year.

<span th:text="${#dates.format(#dates.createNow(), 'yyyy')}"></span>

Other than this, this is just a splash page that passes the user through to our customer/index.html page.

Customer List Page

This page serves as the directory for our customers, facilitating users in searching, sorting, and scrolling through the customer table.

Additionally, it showcases any confirmation messages to the user, particularly those originating from the Flash Scope:

<div class="alert alert-info" th:if="${message}">
    <h3 th:text="${message}"></h3>
</div>

The ‘th:if’ attribute is a conditional test, if the boolean result is false, the div will NOT be present in the resulting page.

Further, we define our table structure:

<table id="customerTable" class="table table-striped table-bordered" style="width:100%">
    <thead>
    <tr>
        <th>Id</th>
        <th>First Name</th>
        <th>Last Name</th>
        <th>Email</th>
        <th>City</th>
        <th>Country</th>
        <th>Phone</th>
    </tr>
    </thead>
</table>

Which in turn is transformed into our fully functional grid with a call to Datatables:

var url = '/customer/data_for_datatable';

  $(document).ready(function () {

    $('#customerTable').DataTable({
      "ajax": url,
      "processing": true,
      "serverSide": true,
      "columns": [
        {
          "data": "id",
          "render": function (data, type, row, meta) {
            return '<a href="/customer/edit/' + row.id + '">' + data + '</a>';
          }
        },
        {
          "data": "firstName",
          "render": function (data, type, row, meta) {
            return '<a href="/customer/edit/' + row.id + '">' + data + '</a>';
          }
        },
        {
          "data": "lastName",
          "render": function (data, type, row, meta) {
            return '<a href="/customer/edit/' + row.id + '">' + data + '</a>';
          }
        },
        {"data": "emailAddress"},
        {"data": "city"},
        {"data": "country"},
        {"data": "phoneNumber"}
      ]
    });
  });

Create & Edit Templates

Upon examining the ‘create.html', we can delve deeper into the intricacies of the form;

<form action="/customer/save" th:object="${customerInstance}" class="form" method="post">

    <div class="alert alert-danger" th:if="${! #fields.errors('all').isEmpty()}">
        <li th:each="e : ${#fields.detailedErrors()}" th:class="${e.global}? globalerr : fielderr">
            <span th:text="${e.global}? '*' : ${e.fieldName}">The field name</span>
            <span th:text="${e.message}">The error message</span>
        </li>
    </div>

    <div class="row">
        <div class="form-group col-6">
            <label>First Name</label>
            <input class="form-control" name="firstName" th:value="${customerInstance?.firstName}"/>
        </div>
        <div class="form-group col-6">
            <label>Last Name</label>
            <input class="form-control" name="lastName" th:value="${customerInstance?.lastName}"/>
        </div>
    </div>

    <!-- OTHER FIELDS OMITTED FOR BREVITY -->

    <div class="row">
        <div class="col">
            <button type="submit" class="btn btn-success btn-block">Create Customer</button>
        </div>
    </div>
</form>

The crucial element to note here is the Thymeleaf binding that bridges the Form with our Customer backing bean through th:object="${customerInstance}".

As a result, this facilitates, among other functionalities, the ‘fields’ object to mirror the attributes within our backing bean. If there’s a hiccup during the form submission, like attempting to save the form without providing a first name, the system will pinpoint and showcase the errors.

To ensure code efficiency and avoid redundancy across pages, we employ partial layouts, evident in elements like the navbar and footer. Within ‘templates/layouts/navbar.html‘, you’ll find a notable section:

<nav class="navbar navbar-dark bg-dark fixed-top justify-content-between px-4"
     xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
    <a class="navbar-brand" href="/">Simple CRM</a>
    <a class="mr-auto btn btn-sm btn-outline-light" href="/logout" sec:authorize="isAuthenticated()">Logout</a>
</nav>

This utilizes the integration of Thymeleaf with Spring Security, which furnishes us with an extra tag courtesy of the ‘sec’ namespace: ‘xmlns:sec="http://www.thymeleaf.org/extras/spring-security"‘. This means that the Logout link in the navbar is only visible when the user is authenticated.

Another intriguing application of conditional rendering can be spotted in the customer list (‘/customer/index.html'). Here, it’s essential to determine if the user possesses the appropriate permissions to modify customer records. If they are designated as a read-only user, they should solely have the capability to view the customer records.

var isReadOnly = /*[[${#authorization.expression('hasAuthority(''READONLY_USER'')')}]]*/ false;
var customerUrlPrefix =  '/customer/' + (isReadOnly ? 'show' : 'edit') + '/';

function renderLink(data, type, row, meta) {
  return '<a href="' + customerUrlPrefix + row.id + '">' + data + '</a>';
}

On our dashboard page we only want to show a link to the “User” administration page if the current user has the role ‘ROLE_ADMIN’:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
    <title>Simple CRM - Customer Management made Simple</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/css/bootstrap.min.css">
</head>
<body>

<nav th:replace="~{/layout/navbar}"/>

<div class="container" style="margin-top:80px">
    <div>
        <h1>Welcome to simple crm</h1>
        <h2>Customer Management made Simple</h2>
    </div>

    <div class="alert alert-danger" th:if="${error}" th:text="${error}"></div>

    <p class="mt-5"><a href="/customer" class="btn btn-primary w-100">Manage Customers</a></p>

    <p class="mt-5" sec:authorize="hasAuthority('ADMIN')">
        <a href="/user" class="btn btn-primary w-100">Manage Users</a>
    </p>
</div>

<footer th:replace="~{/layout/footer}"/>
</body>

The Web Application

If all steps have been executed accurately, you should be greeted with the following screens. For any discrepancies or issues, please consult the provided code within our GitHub repository.

[rev_slider alias=”kotlincrm” slidertitle=”kotlincrm”][/rev_slider]

Conclusion

I hope this tutorial has helped you and shown you how rapidly you can build a CRM with Kotlin and Spring Boot. As a reminder, all source code is available here.

Please contact us if you are having problems with this tutorial, or if you need assistance with your web application development.

.

Founder of Tucanoo Solutions Ltd, a Cloud / Web Application development company. AWS Cloud Solutions Architect. Specialties: Spring Boot, Java, Grails, React.JS, App Architecture, Agile, Scrum, Git, AWS, Javascript.

Leave a Reply

Your email address will not be published. Required fields are marked *