diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresAuditRepository.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresAuditRepository.java new file mode 100644 index 00000000..c03084ce --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresAuditRepository.java @@ -0,0 +1,131 @@ +package com.cameleer3.server.app.storage; + +import com.cameleer3.server.core.admin.AuditCategory; +import com.cameleer3.server.core.admin.AuditRecord; +import com.cameleer3.server.core.admin.AuditRepository; +import com.cameleer3.server.core.admin.AuditResult; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Repository +public class PostgresAuditRepository implements AuditRepository { + + private static final Set ALLOWED_SORT_COLUMNS = + Set.of("timestamp", "username", "action", "category"); + private static final int MAX_PAGE_SIZE = 100; + + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + + public PostgresAuditRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) { + this.jdbc = jdbc; + this.objectMapper = objectMapper; + } + + @Override + public void insert(AuditRecord record) { + String detailJson = null; + if (record.detail() != null) { + try { + detailJson = objectMapper.writeValueAsString(record.detail()); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to serialize audit detail", e); + } + } + + jdbc.update(""" + INSERT INTO audit_log (username, action, category, target, detail, result, ip_address, user_agent) + VALUES (?, ?, ?, ?, ?::jsonb, ?, ?, ?) + """, + record.username(), record.action(), + record.category() != null ? record.category().name() : null, + record.target(), detailJson, + record.result() != null ? record.result().name() : null, + record.ipAddress(), record.userAgent()); + } + + @Override + public AuditPage find(AuditQuery query) { + int pageSize = Math.min(query.size() > 0 ? query.size() : 20, MAX_PAGE_SIZE); + int offset = query.page() * pageSize; + + StringBuilder where = new StringBuilder("WHERE timestamp >= ? AND timestamp <= ?"); + List params = new ArrayList<>(); + params.add(Timestamp.from(query.from())); + params.add(Timestamp.from(query.to())); + + if (query.username() != null && !query.username().isBlank()) { + where.append(" AND username = ?"); + params.add(query.username()); + } + if (query.category() != null) { + where.append(" AND category = ?"); + params.add(query.category().name()); + } + if (query.search() != null && !query.search().isBlank()) { + where.append(" AND (action ILIKE ? OR target ILIKE ?)"); + String like = "%" + query.search() + "%"; + params.add(like); + params.add(like); + } + + // Count query + String countSql = "SELECT COUNT(*) FROM audit_log " + where; + Long totalCount = jdbc.queryForObject(countSql, Long.class, params.toArray()); + + // Sort column validation + String sortCol = ALLOWED_SORT_COLUMNS.contains(query.sort()) ? query.sort() : "timestamp"; + String order = "asc".equalsIgnoreCase(query.order()) ? "ASC" : "DESC"; + + String dataSql = "SELECT * FROM audit_log " + where + + " ORDER BY " + sortCol + " " + order + + " LIMIT ? OFFSET ?"; + List dataParams = new ArrayList<>(params); + dataParams.add(pageSize); + dataParams.add(offset); + + List items = jdbc.query(dataSql, (rs, rowNum) -> mapRecord(rs), dataParams.toArray()); + return new AuditPage(items, totalCount != null ? totalCount : 0); + } + + @SuppressWarnings("unchecked") + private AuditRecord mapRecord(ResultSet rs) throws SQLException { + Map detail = null; + String detailStr = rs.getString("detail"); + if (detailStr != null) { + try { + detail = objectMapper.readValue(detailStr, Map.class); + } catch (JsonProcessingException e) { + // leave detail as null if unparseable + } + } + + Timestamp ts = rs.getTimestamp("timestamp"); + String categoryStr = rs.getString("category"); + String resultStr = rs.getString("result"); + + return new AuditRecord( + rs.getLong("id"), + ts != null ? ts.toInstant() : null, + rs.getString("username"), + rs.getString("action"), + categoryStr != null ? AuditCategory.valueOf(categoryStr) : null, + rs.getString("target"), + detail, + resultStr != null ? AuditResult.valueOf(resultStr) : null, + rs.getString("ip_address"), + rs.getString("user_agent") + ); + } +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresThresholdRepository.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresThresholdRepository.java new file mode 100644 index 00000000..0a9f9606 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresThresholdRepository.java @@ -0,0 +1,58 @@ +package com.cameleer3.server.app.storage; + +import com.cameleer3.server.core.admin.ThresholdConfig; +import com.cameleer3.server.core.admin.ThresholdRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public class PostgresThresholdRepository implements ThresholdRepository { + + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + + public PostgresThresholdRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) { + this.jdbc = jdbc; + this.objectMapper = objectMapper; + } + + @Override + public Optional find() { + List results = jdbc.query( + "SELECT config FROM admin_thresholds WHERE id = 1", + (rs, rowNum) -> { + String json = rs.getString("config"); + try { + return objectMapper.readValue(json, ThresholdConfig.class); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to deserialize threshold config", e); + } + }); + return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); + } + + @Override + public void save(ThresholdConfig config, String updatedBy) { + String json; + try { + json = objectMapper.writeValueAsString(config); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to serialize threshold config", e); + } + + jdbc.update(""" + INSERT INTO admin_thresholds (id, config, updated_by, updated_at) + VALUES (1, ?::jsonb, ?, now()) + ON CONFLICT (id) DO UPDATE SET + config = EXCLUDED.config, + updated_by = EXCLUDED.updated_by, + updated_at = now() + """, + json, updatedBy); + } +}