Export customers as CSV

Java · Spring Boot · intermediate · modification

Adds the CSV export behind the Export button on the customers page: `GET /api/customers/export.csv`. Rows come out in id order and are read in keyset batches of 500 into a small `CustomerCsvRow` projection, so a large org streams out with bounded memory instead of being loaded in one go. `CustomerCsvExporter.cell` does the escaping: a value starting with one of the formula characters OWASP lists (`=`, `+`, `-`, `@`, tab, CR) gets an apostrophe prefix, then RFC 4180 quoting applies. The file starts with a BOM so Excel reads it as UTF-8, and it downloads as an attachment named with today's date. Unit tests cover the escaping rules and the paging.

CRM backend on Spring Boot 3.3 (Spring Framework 6.1, Spring Data JPA 3.3 with Hibernate 6.5, Java 21) on PostgreSQL 16, served by the embedded Tomcat, with `spring.jpa.open-in-view=false` set in `application.yml`, so each repository call borrows a pooled connection only for its own query. Existing and unchanged: `SecurityConfig` requires an authenticated session for every `/api/**` request (401 otherwise) and puts a `CurrentUser` record (`id`, `orgId`, `email`) in the security context as the principal, which every controller reads with `@AuthenticationPrincipal`. `Customer` (table `customers`) has an identity `id` (positive, never changes), `orgId` (never changes), `name` and `email` (not null), `company` and `phone` (nullable) and `createdAt` (an `Instant` the server sets when the customer is created, not null); customers are deleted outright, there is no soft delete. Text columns hold valid Unicode. An index on `customers (org_id, id)` exists. `CustomerView.from` is unchanged. The Export button in the web app is a plain link to the new endpoint.

Requirements

Files touched

--- src/main/java/com/acme/crm/customer/CustomerRepository.java
 package com.acme.crm.customer;
 
+import java.util.List;
 import java.util.Optional;
+import org.springframework.data.domain.Limit;
 import org.springframework.data.domain.Page;
 import org.springframework.data.domain.Pageable;
 import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
 
 public interface CustomerRepository extends JpaRepository<Customer, Long> {
 
     Optional<Customer> findByIdAndOrgId(Long id, Long orgId);
+
+    @Query("""
+            select new com.acme.crm.customer.CustomerCsvRow(
+                c.id, c.name, c.email, c.company, c.phone, c.createdAt)
+            from Customer c
+            where c.orgId = :orgId and c.id > :afterId
+            order by c.id""")
+    List<CustomerCsvRow> findCsvRows(@Param("orgId") Long orgId, @Param("afterId") long afterId, Limit limit);
 }
 
--- src/main/java/com/acme/crm/customer/CustomerCsvRow.java
+package com.acme.crm.customer;
+
+import java.time.Instant;
+

Review this PR

Java practice