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
2 changes: 1 addition & 1 deletion .claude/conventions/project-specific/project-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Open CRM is an open-source CRM system designed for startups and small teams to m
- **Print Views** — Print button opens a new tab with all filtered records in a print-optimized table. Optimized for DIN A4 portrait with text wrapping, repeating table headers on page breaks, and Safari compatibility. Comment count column is excluded from print.
- **Brevo Import** — One-directional import from Brevo to CRM, triggered manually from a dedicated "Brevo Import" page. Imports companies (name, domain) and contacts (name, email, phone, position, LinkedIn, language) with field mapping. Matches by Brevo ID on subsequent imports, or by name/email on first import. Brevo-managed fields on contacts (firstName, lastName, email, language) are read-only. Re-import only overwrites Brevo-managed fields, preserving user-editable fields. A "Brevo" badge appears on imported records in detail views. Filtering by Brevo origin (All/From Brevo/Not from Brevo) is available on both lists. Newsletter subscription status is synced from Brevo during import.
- **User Management** — OIDC users are stored in the database with sub (unique OIDC subject), name, email, and optional avatar. User info is synced from the OIDC token on login.
- **Admin Pages** — Admin functionality split into three focused pages under a collapsible "Admin" sidebar group: **Server Status** (`/admin/status`) with health check indicator, **Bearer Token** (`/admin/token`) with masked token display, show/hide toggle, copy-to-clipboard, and validity countdown, and **Brevo Integration** (`/admin/brevo`) with API key management and sync trigger. The sidebar groups all admin-related pages (including API Keys and Webhooks) under a collapsible parent with chevron toggle. Desktop uses collapsible sub-menu; mobile shows flat list.
- **Admin Pages** — Admin functionality split into focused pages under a collapsible "Admin" sidebar group (IT-ADMIN only): **Server Status** (`/admin/status`) with health check indicator, **Bearer Token** (`/admin/token`) with masked token display, show/hide toggle, copy-to-clipboard, and validity countdown, **Brevo Integration** (`/admin/brevo`) with API key management and sync trigger, **Users** (`/admin/users`) with a paginated read-only list of registered users (avatar, name, email), and **Audit Log** (`/admin/audit-logs`) with a paginated table of every INSERT/UPDATE/DELETE event (Type, Entity ID, Action, User, Date) sorted by `createdAt` descending and filterable by entity type and user (System entries are visible only when no user filter is applied). The sidebar groups all admin-related pages (including API Keys and Webhooks) under a collapsible parent with chevron toggle. Desktop uses a collapsible sub-menu; mobile shows a flat list.
- **Health Monitoring** — A health endpoint to verify backend availability, displayed in the frontend.
- **Internationalization (i18n)** — Frontend supports German and English with automatic language detection and manual switching via sidebar toggle.
- **API Documentation** — Swagger UI and OpenAPI spec auto-generated from backend annotations with @Parameter descriptions on all controller parameters.
Expand Down
7 changes: 5 additions & 2 deletions .claude/conventions/project-specific/project-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ open-crm/
│ │ ├── tag/ — Tag domain (entity, repository, service, controller, DTOs)
│ │ ├── user/ — User domain (entity, repository, service, controller, DTO)
│ │ ├── apikey/ — API key domain (entity, repository, service, controller, DTOs, auth filter)
│ │ ├── auditlog/ — Audit log REST surface (controller, project-local repository over AuditLogEntity from spring-services)
│ │ ├── webhook/ — Webhook domain (entity, repository, service, controller, DTOs, event listener, sender, event types, config)
│ │ ├── brevo/ — Brevo integration (sync service, API client, controller, DTOs, records)
│ │ ├── health/ — Health check endpoint
Expand Down Expand Up @@ -57,8 +58,10 @@ open-crm/
│ │ │ ├── page.tsx — Home page (redirects to companies)
│ │ │ ├── admin/ — Admin pages (redirect to /admin/status)
│ │ │ │ ├── status/ — Server Status page (health check)
│ │ │ ��� ├── token/ — Bearer Token page (show/copy/validity)
│ │ │ │ └─��� brevo/ — Brevo Integration page (settings + import)
│ │ │ │ ├── token/ — Bearer Token page (show/copy/validity)
│ │ │ │ ├── brevo/ — Brevo Integration page (settings + import)
│ │ │ │ ├── users/ — Read-only paginated list of registered users (IT-ADMIN)
│ │ │ │ └── audit-logs/ — Read-only paginated, filterable audit log table (IT-ADMIN)
│ │ │ ├── companies/ — Company pages (list, detail, new, edit, print)
│ │ │ ├── contacts/ — Contact pages (list, detail, new, edit, print)
│ │ │ ├── tasks/ — Task pages (list, detail, new, edit)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.openelements.crm.auditlog;

import com.openelements.crm.security.RequiresItAdmin;
import com.openelements.spring.base.services.audit.AuditLogDto;
import com.openelements.spring.base.services.audit.AuditLogEntity;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort.Direction;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/audit-logs")
@Tag(name = "Audit Log")
@SecurityRequirement(name = "oidc")
@RequiresItAdmin
public class AuditLogController {

private final CrmAuditLogRepository auditLogRepository;

public AuditLogController(final CrmAuditLogRepository auditLogRepository) {
this.auditLogRepository = auditLogRepository;
}

@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
@Operation(
summary = "List audit log entries",
description = "Returns a paginated list of audit log entries with optional filtering by entity type and user. "
+ "Sorted by createdAt descending (newest first). Requires the IT-ADMIN role."
)
public Page<AuditLogDto> listAuditLogs(
@Parameter(description = "Filter by entity type (exact match)")
@RequestParam(required = false) final String entityType,
@Parameter(description = "Filter by user name (exact match)")
@RequestParam(required = false) final String user,
@Parameter(hidden = true)
@PageableDefault(size = 20, sort = "createdAt", direction = Direction.DESC) final Pageable pageable) {
final Page<AuditLogEntity> entities;
final boolean hasEntityType = entityType != null && !entityType.isBlank();
final boolean hasUser = user != null && !user.isBlank();
if (hasEntityType && hasUser) {
entities = auditLogRepository.findByEntityTypeAndUserName(entityType, user, pageable);
} else if (hasEntityType) {
entities = auditLogRepository.findByEntityType(entityType, pageable);
} else if (hasUser) {
entities = auditLogRepository.findByUserName(user, pageable);
} else {
entities = auditLogRepository.findAll(pageable);
}
return entities.map(AuditLogDto::fromEntity);
}

@GetMapping(path = "/entity-types", produces = MediaType.APPLICATION_JSON_VALUE)
@Operation(
summary = "List distinct entity types",
description = "Returns the distinct entity-type strings present in the audit log, sorted alphabetically. "
+ "Used to populate the entity-type filter dropdown. Requires the IT-ADMIN role."
)
public List<String> listEntityTypes() {
return auditLogRepository.findDistinctEntityTypes();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.openelements.crm.auditlog;

import com.openelements.spring.base.services.audit.AuditLogEntity;
import java.util.List;
import java.util.UUID;
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;

/**
* Project-local repository over {@link AuditLogEntity} that exposes paginated
* filter queries. The {@code AuditLogDataService} from {@code spring-services}
* only offers unpaginated {@code List}-returning filter methods, which would
* force in-memory pagination over an unbounded audit table.
*/
public interface CrmAuditLogRepository extends JpaRepository<AuditLogEntity, UUID> {

Page<AuditLogEntity> findByEntityType(String entityType, Pageable pageable);

Page<AuditLogEntity> findByUserName(String userName, Pageable pageable);

Page<AuditLogEntity> findByEntityTypeAndUserName(String entityType, String userName, Pageable pageable);

@Query("SELECT DISTINCT a.entityType FROM AuditLogEntity a ORDER BY a.entityType")
List<String> findDistinctEntityTypes();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package com.openelements.crm.auditlog;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.openelements.spring.base.services.audit.AuditAction;
import com.openelements.spring.base.services.audit.AuditLogDataService;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;

/**
* Behaviour tests for the GET /api/audit-logs and /entity-types endpoints
* added in spec 090.
*
* <p>Role-based 401/403/200 coverage lives in
* {@link com.openelements.crm.security.SecurityRoleIntegrationTest}; this class
* focuses on the API contract — pagination, filter combinations, and the
* entity-types listing.
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class AuditLogControllerTest {

@Autowired
private MockMvc mockMvc;

@Autowired
private AuditLogDataService auditLogDataService;

@Autowired
private CrmAuditLogRepository auditLogRepository;

private static MockHttpServletRequestBuilder asItAdmin(MockHttpServletRequestBuilder builder) {
final List<String> roles = List.of("IT-ADMIN");
final Jwt jwt = Jwt.withTokenValue("token")
.header("alg", "none")
.subject("test-user")
.claim("preferred_username", "test-user")
.claim("email", "test@example.com")
.claim("roles", roles)
.build();
final Collection<GrantedAuthority> authorities = new ArrayList<>();
for (final String role : roles) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
}
return builder.with(jwt().jwt(jwt).authorities(authorities));
}

@BeforeEach
void cleanAuditLog() {
auditLogRepository.deleteAll();
}

@Test
void listAuditLogsReturnsPagedResponseShape() throws Exception {
mockMvc.perform(asItAdmin(get("/api/audit-logs")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").isArray())
.andExpect(jsonPath("$.page.size").exists())
.andExpect(jsonPath("$.page.number").exists())
.andExpect(jsonPath("$.page.totalElements").exists())
.andExpect(jsonPath("$.page.totalPages").exists());
}

@Test
void listAuditLogsUsesDefaultPageSize20WithoutParams() throws Exception {
mockMvc.perform(asItAdmin(get("/api/audit-logs")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.page.size").value(20))
.andExpect(jsonPath("$.page.number").value(0));
}

@Test
void listAuditLogsHonoursExplicitPageAndSize() throws Exception {
mockMvc.perform(asItAdmin(get("/api/audit-logs")
.param("page", "0").param("size", "10")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.page.size").value(10))
.andExpect(jsonPath("$.page.number").value(0));
}

@Test
void listAuditLogsFiltersByEntityType() throws Exception {
auditLogDataService.createEntry("CompanyDto", UUID.randomUUID(), AuditAction.INSERT, "Alice");
auditLogDataService.createEntry("ContactDto", UUID.randomUUID(), AuditAction.INSERT, "Alice");
auditLogDataService.createEntry("CompanyDto", UUID.randomUUID(), AuditAction.UPDATE, "Bob");

mockMvc.perform(asItAdmin(get("/api/audit-logs").param("entityType", "CompanyDto")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.page.totalElements").value(2))
.andExpect(jsonPath("$.content[0].entityType").value("CompanyDto"))
.andExpect(jsonPath("$.content[1].entityType").value("CompanyDto"));
}

@Test
void listAuditLogsFiltersByUser() throws Exception {
auditLogDataService.createEntry("CompanyDto", UUID.randomUUID(), AuditAction.INSERT, "Alice");
auditLogDataService.createEntry("ContactDto", UUID.randomUUID(), AuditAction.INSERT, "Alice");
auditLogDataService.createEntry("CompanyDto", UUID.randomUUID(), AuditAction.UPDATE, "Bob");

mockMvc.perform(asItAdmin(get("/api/audit-logs").param("user", "Alice")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.page.totalElements").value(2))
.andExpect(jsonPath("$.content[0].user").value("Alice"))
.andExpect(jsonPath("$.content[1].user").value("Alice"));
}

@Test
void listAuditLogsFiltersByEntityTypeAndUser() throws Exception {
auditLogDataService.createEntry("CompanyDto", UUID.randomUUID(), AuditAction.INSERT, "Alice");
auditLogDataService.createEntry("ContactDto", UUID.randomUUID(), AuditAction.INSERT, "Alice");
auditLogDataService.createEntry("CompanyDto", UUID.randomUUID(), AuditAction.UPDATE, "Bob");

mockMvc.perform(asItAdmin(get("/api/audit-logs")
.param("entityType", "CompanyDto")
.param("user", "Alice")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.page.totalElements").value(1))
.andExpect(jsonPath("$.content[0].entityType").value("CompanyDto"))
.andExpect(jsonPath("$.content[0].user").value("Alice"));
}

@Test
void listAuditLogsSortsByCreatedAtDescending() throws Exception {
auditLogDataService.createEntry("CompanyDto", UUID.randomUUID(), AuditAction.INSERT, "Alice");
Thread.sleep(5L);
auditLogDataService.createEntry("CompanyDto", UUID.randomUUID(), AuditAction.UPDATE, "Bob");
Thread.sleep(5L);
final UUID newestId = auditLogDataService
.createEntry("CompanyDto", UUID.randomUUID(), AuditAction.DELETE, "Charlie")
.id();

mockMvc.perform(asItAdmin(get("/api/audit-logs")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].id").value(newestId.toString()));
}

@Test
void entityTypesReturnsDistinctSortedValues() throws Exception {
auditLogDataService.createEntry("ContactDto", UUID.randomUUID(), AuditAction.INSERT, "Alice");
auditLogDataService.createEntry("CompanyDto", UUID.randomUUID(), AuditAction.INSERT, "Alice");
auditLogDataService.createEntry("ContactDto", UUID.randomUUID(), AuditAction.UPDATE, "Bob");

mockMvc.perform(asItAdmin(get("/api/audit-logs/entity-types")))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$.length()").value(2))
.andExpect(jsonPath("$[0]").value("CompanyDto"))
.andExpect(jsonPath("$[1]").value("ContactDto"));
}

@Test
void entityTypesReturnsEmptyArrayWhenNoEntries() throws Exception {
mockMvc.perform(asItAdmin(get("/api/audit-logs/entity-types")))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$.length()").value(0));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import static org.junit.jupiter.api.Assertions.assertTrue;

import com.openelements.crm.apikey.ApiKeyController;
import com.openelements.crm.auditlog.AuditLogController;
import com.openelements.crm.brevo.BrevoSyncController;
import com.openelements.crm.comment.CommentController;
import com.openelements.crm.company.CompanyController;
Expand Down Expand Up @@ -85,6 +86,11 @@ void brevoSyncControllerRequiresItAdmin() {
assertClassHasRequiresItAdmin(BrevoSyncController.class);
}

@Test
void auditLogControllerRequiresItAdmin() {
assertClassHasRequiresItAdmin(AuditLogController.class);
}

@Test
void userControllerListUsersRequiresItAdmin() throws NoSuchMethodException {
assertHasRequiresItAdmin(UserController.class.getDeclaredMethod(
Expand Down
Loading
Loading