diff --git a/json-export-specs/README.md b/json-export-specs/README.md index 7767d755e0..d524383ac2 100644 --- a/json-export-specs/README.md +++ b/json-export-specs/README.md @@ -63,6 +63,33 @@ The CIA JSON Export System transforms the comprehensive political intelligence d | **Committees** | 15 riksdag | Daily | ~600 KB | | **Intelligence Products** | Risk scores, trends | Daily | ~1.2 MB | +### Implementation Status + +> **✅ v1.36 Update (2024-12-08)**: Core JSON export service layer completed + +**Implemented Components**: +- ✅ **IntelligenceExportService** - Service interface for JSON exports (`service.data.api`) +- ✅ **IntelligenceExportServiceImpl** - Jackson-based JSON serialization (`service.data.impl`) +- ✅ **Export DTOs** - Data transfer objects for risk assessments, coalition alignment, temporal trends +- ✅ **Unit Tests** - Comprehensive tests with Mockito (6/6 passing) +- ✅ **Build Integration** - Clean compile and test pass in CI/CD pipeline + +**Ready for Integration**: +1. `exportRiskAssessments()` - Exports all rule violations with severity and metadata +2. `exportCoalitionAlignment()` - Exports party alignment matrix with voting cohesion +3. `exportTemporalTrends()` - Exports decision trends with daily/weekly/monthly moving averages +4. `writeJsonToFile()` - File writer for CDN-ready static JSON generation + +**Next Steps**: +- REST endpoint integration (planned) +- Scheduled daily export job (planned) +- CDN deployment automation (planned) + +**Technical Foundation**: Service layer built on Spring Framework with JPA/Hibernate, using existing DAO layer for database access to: +- `RuleViolationDAO` → Risk assessment data (50 behavioral rules) +- `ViewRiksdagenCoalitionAlignmentMatrixDAO` → Coalition alignment data +- `ViewDecisionTemporalTrendsDAO` → Temporal trend analysis data + --- ## 📋 Table of Contents diff --git a/service.data.api/src/main/java/com/hack23/cia/service/data/api/IntelligenceExportService.java b/service.data.api/src/main/java/com/hack23/cia/service/data/api/IntelligenceExportService.java new file mode 100644 index 0000000000..02bdac1f3c --- /dev/null +++ b/service.data.api/src/main/java/com/hack23/cia/service/data/api/IntelligenceExportService.java @@ -0,0 +1,80 @@ +/* + * Copyright 2010-2025 James Pether Sörling + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * $Id$ + * $HeadURL$ + */ +package com.hack23.cia.service.data.api; + +import java.io.IOException; + +/** + * Service interface for exporting intelligence products as JSON. + * + * Provides JSON export capabilities for risk assessments, coalition alignment, + * and temporal trends to support CDN-ready static file generation and API endpoints. + * + * @author intelligence-operative + * @since v1.36 (Intelligence Products JSON Export) + */ +public interface IntelligenceExportService { + + /** + * Export risk assessment data for all rule violations. + * + * Generates JSON containing all 50 risk rules with their current violations, + * severity levels, and affected entities (politicians, parties, committees, ministries). + * + * @return JSON string containing risk assessment data + * @throws IOException if JSON generation fails + */ + String exportRiskAssessments() throws IOException; + + /** + * Export coalition alignment matrix showing voting alignment between parties. + * + * Generates JSON containing pairwise party alignment rates, total votes, + * and aligned votes for coalition stability analysis. + * + * @return JSON string containing coalition alignment matrix + * @throws IOException if JSON generation fails + */ + String exportCoalitionAlignment() throws IOException; + + /** + * Export temporal trend analysis with daily/weekly/monthly/annual patterns. + * + * Generates JSON containing decision volumes, approval rates, moving averages, + * and year-over-year comparisons for trend forecasting. + * + * @return JSON string containing temporal trends data + * @throws IOException if JSON generation fails + */ + String exportTemporalTrends() throws IOException; + + /** + * Write JSON export to file for CDN deployment. + * + * Note: Subdirectories are not supported. The fileName parameter must be a flat + * file name without path separators. Path traversal sequences are validated and rejected. + * + * @param jsonContent the JSON content to write + * @param fileName the file name (e.g., "risk-assessments.json") - must not contain path separators + * @param outputDirectory the output directory path + * @throws IOException if file write fails or directory creation fails + * @throws IllegalArgumentException if fileName contains path separators or path traversal is detected + */ + void writeJsonToFile(String jsonContent, String fileName, String outputDirectory) throws IOException; +} diff --git a/service.data.impl/src/main/java/com/hack23/cia/service/data/impl/IntelligenceExportServiceImpl.java b/service.data.impl/src/main/java/com/hack23/cia/service/data/impl/IntelligenceExportServiceImpl.java new file mode 100644 index 0000000000..d93fefb6bc --- /dev/null +++ b/service.data.impl/src/main/java/com/hack23/cia/service/data/impl/IntelligenceExportServiceImpl.java @@ -0,0 +1,203 @@ +/* + * Copyright 2010-2025 James Pether Sörling + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * $Id$ + * $HeadURL$ + */ +package com.hack23.cia.service.data.impl; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.hack23.cia.model.internal.application.data.impl.ViewDecisionTemporalTrends; +import com.hack23.cia.model.internal.application.data.party.impl.ViewRiksdagenCoalitionAlignmentMatrix; +import com.hack23.cia.model.internal.application.data.rules.impl.RuleViolation; +import com.hack23.cia.service.data.api.IntelligenceExportService; +import com.hack23.cia.service.data.api.RuleViolationDAO; +import com.hack23.cia.service.data.api.ViewDecisionTemporalTrendsDAO; +import com.hack23.cia.service.data.api.ViewRiksdagenCoalitionAlignmentMatrixDAO; +import com.hack23.cia.service.data.impl.export.CoalitionAlignmentExportDTO; +import com.hack23.cia.service.data.impl.export.CoalitionAlignmentExportDTO.PartyAlignment; +import com.hack23.cia.service.data.impl.export.ExportMetadata; +import com.hack23.cia.service.data.impl.export.RiskAssessmentExportDTO; +import com.hack23.cia.service.data.impl.export.RiskAssessmentExportDTO.RiskViolation; +import com.hack23.cia.service.data.impl.export.TemporalTrendsExportDTO; +import com.hack23.cia.service.data.impl.export.TemporalTrendsExportDTO.TrendDataPoint; + +/** + * Implementation of IntelligenceExportService. + * + * @author intelligence-operative + * @since v1.36 + */ +@Service +@Transactional(readOnly = true) +final class IntelligenceExportServiceImpl implements IntelligenceExportService { + + @Autowired + private RuleViolationDAO ruleViolationDAO; + + @Autowired + private ViewRiksdagenCoalitionAlignmentMatrixDAO coalitionAlignmentDAO; + + @Autowired + private ViewDecisionTemporalTrendsDAO temporalTrendsDAO; + + private final ObjectMapper objectMapper; + + public IntelligenceExportServiceImpl() { + this.objectMapper = new ObjectMapper(); + this.objectMapper.enable(SerializationFeature.INDENT_OUTPUT); + this.objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + this.objectMapper.setDateFormat(new com.fasterxml.jackson.databind.util.StdDateFormat().withColonInTimeZone(true)); + } + + @Override + public String exportRiskAssessments() throws IOException { + final List violations = ruleViolationDAO.getAll(); + + final RiskAssessmentExportDTO dto = new RiskAssessmentExportDTO(); + + final ExportMetadata metadata = new ExportMetadata(); + metadata.setSchema("intelligence-schema"); + metadata.setRecordCount(violations.size()); + metadata.setDataDate(new Date()); + dto.setMetadata(metadata); + + final List riskViolations = new ArrayList<>(); + for (final RuleViolation violation : violations) { + final RiskViolation riskViolation = new RiskViolation(); + riskViolation.setId(violation.getId()); + riskViolation.setDetectedDate(violation.getDetectedDate()); + riskViolation.setReferenceId(violation.getReferenceId()); + riskViolation.setName(violation.getName()); + riskViolation.setResourceType(violation.getResourceType() != null ? violation.getResourceType().toString() : null); + riskViolation.setRuleName(violation.getRuleName()); + riskViolation.setRuleDescription(violation.getRuleDescription()); + riskViolation.setRuleGroup(violation.getRuleGroup()); + riskViolation.setStatus(violation.getStatus() != null ? violation.getStatus().toString() : null); + riskViolation.setPositive(violation.getPositive()); + riskViolations.add(riskViolation); + } + dto.setViolations(riskViolations); + + return objectMapper.writeValueAsString(dto); + } + + @Override + public String exportCoalitionAlignment() throws IOException { + final List alignments = coalitionAlignmentDAO.getAll(); + + final CoalitionAlignmentExportDTO dto = new CoalitionAlignmentExportDTO(); + + final ExportMetadata metadata = new ExportMetadata(); + metadata.setSchema("intelligence-schema"); + metadata.setRecordCount(alignments.size()); + metadata.setDataDate(new Date()); + dto.setMetadata(metadata); + + final List partyAlignments = new ArrayList<>(); + for (final ViewRiksdagenCoalitionAlignmentMatrix alignment : alignments) { + final PartyAlignment partyAlignment = new PartyAlignment(); + if (alignment.getEmbeddedId() != null) { + partyAlignment.setParty1(alignment.getEmbeddedId().getParty1()); + partyAlignment.setParty2(alignment.getEmbeddedId().getParty2()); + } + partyAlignment.setAlignmentRate(alignment.getAlignmentRate()); + partyAlignment.setSharedVotes(alignment.getSharedVotes()); + partyAlignment.setAlignedVotes(alignment.getAlignedVotes()); + partyAlignments.add(partyAlignment); + } + dto.setAlignments(partyAlignments); + + return objectMapper.writeValueAsString(dto); + } + + @Override + public String exportTemporalTrends() throws IOException { + final List trends = temporalTrendsDAO.getAll(); + + final TemporalTrendsExportDTO dto = new TemporalTrendsExportDTO(); + + final ExportMetadata metadata = new ExportMetadata(); + metadata.setSchema("intelligence-schema"); + metadata.setRecordCount(trends.size()); + metadata.setDataDate(new Date()); + dto.setMetadata(metadata); + + final List dataPoints = new ArrayList<>(); + for (final ViewDecisionTemporalTrends trend : trends) { + final TrendDataPoint dataPoint = new TrendDataPoint(); + dataPoint.setDecisionDay(trend.getDecisionDay()); + dataPoint.setDailyDecisions(trend.getDailyDecisions()); + dataPoint.setDailyApprovalRate(trend.getDailyApprovalRate()); + dataPoint.setApprovedDecisions(trend.getApprovedDecisions()); + dataPoint.setRejectedDecisions(trend.getRejectedDecisions()); + dataPoint.setReferredBackDecisions(trend.getReferredBackDecisions()); + dataPoint.setCommitteeReferralDecisions(trend.getCommitteeReferralDecisions()); + dataPoint.setMa7dayDecisions(trend.getMa7dayDecisions()); + dataPoint.setMa30dayDecisions(trend.getMa30dayDecisions()); + dataPoint.setMa90dayDecisions(trend.getMa90dayDecisions()); + dataPoint.setMa30dayApprovalRate(trend.getMa30dayApprovalRate()); + dataPoint.setDecisionsLastYear(trend.getDecisionsLastYear()); + dataPoint.setYoyDecisionsChange(trend.getYoyDecisionsChange()); + dataPoint.setYoyDecisionsChangePct(trend.getYoyDecisionsChangePct()); + dataPoint.setDecisionYear(trend.getDecisionYear()); + dataPoint.setDecisionMonth(trend.getDecisionMonth()); + dataPoint.setDecisionWeek(trend.getDecisionWeek()); + dataPoint.setDayOfWeek(trend.getDayOfWeek()); + dataPoints.add(dataPoint); + } + dto.setTrends(dataPoints); + + return objectMapper.writeValueAsString(dto); + } + + @Override + public void writeJsonToFile(final String jsonContent, final String fileName, final String outputDirectory) + throws IOException { + // Validate fileName doesn't contain path separators (subdirectories not supported) + if (fileName.contains("/") || fileName.contains("\\")) { + throw new IllegalArgumentException("Invalid file name: subdirectories not supported, must be a flat file name"); + } + + // Validate against path traversal by ensuring resolved path stays within target directory + final Path targetDir = Paths.get(outputDirectory).toAbsolutePath().normalize(); + final Path resolvedPath = targetDir.resolve(fileName).normalize(); + if (!resolvedPath.startsWith(targetDir)) { + throw new IllegalArgumentException("Invalid file name: path traversal detected"); + } + + final File directory = new File(outputDirectory); + if (!directory.exists() && !directory.mkdirs()) { + throw new IOException("Failed to create directory: " + outputDirectory); + } + + Files.write(resolvedPath, jsonContent.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/service.data.impl/src/main/java/com/hack23/cia/service/data/impl/export/CoalitionAlignmentExportDTO.java b/service.data.impl/src/main/java/com/hack23/cia/service/data/impl/export/CoalitionAlignmentExportDTO.java new file mode 100644 index 0000000000..d935c3bffe --- /dev/null +++ b/service.data.impl/src/main/java/com/hack23/cia/service/data/impl/export/CoalitionAlignmentExportDTO.java @@ -0,0 +1,130 @@ +/* + * Copyright 2010-2025 James Pether Sörling + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * $Id$ + * $HeadURL$ + */ +package com.hack23.cia.service.data.impl.export; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * DTO for coalition alignment JSON export. + * + * @author intelligence-operative + * @since v1.36 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CoalitionAlignmentExportDTO implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("metadata") + private ExportMetadata metadata; + + @JsonProperty("alignments") + private List alignments; + + public CoalitionAlignmentExportDTO() { + this.alignments = new ArrayList<>(); + } + + public ExportMetadata getMetadata() { + return metadata; + } + + public void setMetadata(final ExportMetadata metadata) { + this.metadata = metadata; + } + + public List getAlignments() { + return Collections.unmodifiableList(alignments); + } + + public void setAlignments(final List alignments) { + this.alignments = alignments; + } + + /** + * Party alignment entry. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class PartyAlignment implements Serializable { + private static final long serialVersionUID = 1L; + + @JsonProperty("party1") + private String party1; + + @JsonProperty("party2") + private String party2; + + @JsonProperty("alignmentRate") + private Double alignmentRate; + + @JsonProperty("sharedVotes") + private Long sharedVotes; + + @JsonProperty("alignedVotes") + private Long alignedVotes; + + // Getters and setters + public String getParty1() { + return party1; + } + + public void setParty1(final String party1) { + this.party1 = party1; + } + + public String getParty2() { + return party2; + } + + public void setParty2(final String party2) { + this.party2 = party2; + } + + public Double getAlignmentRate() { + return alignmentRate; + } + + public void setAlignmentRate(final Double alignmentRate) { + this.alignmentRate = alignmentRate; + } + + public Long getSharedVotes() { + return sharedVotes; + } + + public void setSharedVotes(final Long sharedVotes) { + this.sharedVotes = sharedVotes; + } + + public Long getAlignedVotes() { + return alignedVotes; + } + + public void setAlignedVotes(final Long alignedVotes) { + this.alignedVotes = alignedVotes; + } + } +} diff --git a/service.data.impl/src/main/java/com/hack23/cia/service/data/impl/export/ExportMetadata.java b/service.data.impl/src/main/java/com/hack23/cia/service/data/impl/export/ExportMetadata.java new file mode 100644 index 0000000000..dc6a2d2655 --- /dev/null +++ b/service.data.impl/src/main/java/com/hack23/cia/service/data/impl/export/ExportMetadata.java @@ -0,0 +1,110 @@ +/* + * Copyright 2010-2025 James Pether Sörling + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * $Id$ + * $HeadURL$ + */ +package com.hack23.cia.service.data.impl.export; + +import java.io.Serializable; +import java.util.Date; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Common metadata for JSON exports. + * + * @author intelligence-operative + * @since v1.36 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ExportMetadata implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("version") + private String version = "1.0.0"; + + @JsonProperty("generated") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'", timezone = "UTC") + private Date generated; + + @JsonProperty("source") + private String source = "Citizen Intelligence Agency"; + + @JsonProperty("schema") + private String schema; + + @JsonProperty("recordCount") + private Integer recordCount; + + @JsonProperty("dataDate") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd", timezone = "UTC") + private Date dataDate; + + public ExportMetadata() { + this.generated = new Date(); + } + + public String getVersion() { + return version; + } + + public void setVersion(final String version) { + this.version = version; + } + + public Date getGenerated() { + return generated != null ? new Date(generated.getTime()) : null; + } + + public void setGenerated(final Date generated) { + this.generated = generated != null ? new Date(generated.getTime()) : null; + } + + public String getSource() { + return source; + } + + public void setSource(final String source) { + this.source = source; + } + + public String getSchema() { + return schema; + } + + public void setSchema(final String schema) { + this.schema = schema; + } + + public Integer getRecordCount() { + return recordCount; + } + + public void setRecordCount(final Integer recordCount) { + this.recordCount = recordCount; + } + + public Date getDataDate() { + return dataDate != null ? new Date(dataDate.getTime()) : null; + } + + public void setDataDate(final Date dataDate) { + this.dataDate = dataDate != null ? new Date(dataDate.getTime()) : null; + } +} diff --git a/service.data.impl/src/main/java/com/hack23/cia/service/data/impl/export/RiskAssessmentExportDTO.java b/service.data.impl/src/main/java/com/hack23/cia/service/data/impl/export/RiskAssessmentExportDTO.java new file mode 100644 index 0000000000..7edf28cfd5 --- /dev/null +++ b/service.data.impl/src/main/java/com/hack23/cia/service/data/impl/export/RiskAssessmentExportDTO.java @@ -0,0 +1,185 @@ +/* + * Copyright 2010-2025 James Pether Sörling + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * $Id$ + * $HeadURL$ + */ +package com.hack23.cia.service.data.impl.export; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * DTO for risk assessment JSON export. + * + * @author intelligence-operative + * @since v1.36 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class RiskAssessmentExportDTO implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("metadata") + private ExportMetadata metadata; + + @JsonProperty("violations") + private List violations; + + public RiskAssessmentExportDTO() { + this.violations = new ArrayList<>(); + } + + public ExportMetadata getMetadata() { + return metadata; + } + + public void setMetadata(final ExportMetadata metadata) { + this.metadata = metadata; + } + + public List getViolations() { + return Collections.unmodifiableList(violations); + } + + public void setViolations(final List violations) { + this.violations = violations; + } + + /** + * Risk violation entry. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class RiskViolation implements Serializable { + private static final long serialVersionUID = 1L; + + @JsonProperty("id") + private Long id; + + @JsonProperty("detectedDate") + private Date detectedDate; + + @JsonProperty("referenceId") + private String referenceId; + + @JsonProperty("name") + private String name; + + @JsonProperty("resourceType") + private String resourceType; + + @JsonProperty("ruleName") + private String ruleName; + + @JsonProperty("ruleDescription") + private String ruleDescription; + + @JsonProperty("ruleGroup") + private String ruleGroup; + + @JsonProperty("status") + private String status; + + @JsonProperty("positive") + private String positive; + + // Getters and setters + public Long getId() { + return id; + } + + public void setId(final Long id) { + this.id = id; + } + + public Date getDetectedDate() { + return detectedDate != null ? new Date(detectedDate.getTime()) : null; + } + + public void setDetectedDate(final Date detectedDate) { + this.detectedDate = detectedDate != null ? new Date(detectedDate.getTime()) : null; + } + + public String getReferenceId() { + return referenceId; + } + + public void setReferenceId(final String referenceId) { + this.referenceId = referenceId; + } + + public String getName() { + return name; + } + + public void setName(final String name) { + this.name = name; + } + + public String getResourceType() { + return resourceType; + } + + public void setResourceType(final String resourceType) { + this.resourceType = resourceType; + } + + public String getRuleName() { + return ruleName; + } + + public void setRuleName(final String ruleName) { + this.ruleName = ruleName; + } + + public String getRuleDescription() { + return ruleDescription; + } + + public void setRuleDescription(final String ruleDescription) { + this.ruleDescription = ruleDescription; + } + + public String getRuleGroup() { + return ruleGroup; + } + + public void setRuleGroup(final String ruleGroup) { + this.ruleGroup = ruleGroup; + } + + public String getStatus() { + return status; + } + + public void setStatus(final String status) { + this.status = status; + } + + public String getPositive() { + return positive; + } + + public void setPositive(final String positive) { + this.positive = positive; + } + } +} diff --git a/service.data.impl/src/main/java/com/hack23/cia/service/data/impl/export/TemporalTrendsExportDTO.java b/service.data.impl/src/main/java/com/hack23/cia/service/data/impl/export/TemporalTrendsExportDTO.java new file mode 100644 index 0000000000..daf0f7d4bd --- /dev/null +++ b/service.data.impl/src/main/java/com/hack23/cia/service/data/impl/export/TemporalTrendsExportDTO.java @@ -0,0 +1,276 @@ +/* + * Copyright 2010-2025 James Pether Sörling + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * $Id$ + * $HeadURL$ + */ +package com.hack23.cia.service.data.impl.export; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * DTO for temporal trends JSON export. + * + * @author intelligence-operative + * @since v1.36 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class TemporalTrendsExportDTO implements Serializable { + + private static final long serialVersionUID = 1L; + + @JsonProperty("metadata") + private ExportMetadata metadata; + + @JsonProperty("trends") + private List trends; + + public TemporalTrendsExportDTO() { + this.trends = new ArrayList<>(); + } + + public ExportMetadata getMetadata() { + return metadata; + } + + public void setMetadata(final ExportMetadata metadata) { + this.metadata = metadata; + } + + public List getTrends() { + return Collections.unmodifiableList(trends); + } + + public void setTrends(final List trends) { + this.trends = trends; + } + + /** + * Trend data point. + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class TrendDataPoint implements Serializable { + private static final long serialVersionUID = 1L; + + @JsonProperty("decisionDay") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd", timezone = "UTC") + private Date decisionDay; + + @JsonProperty("dailyDecisions") + private Long dailyDecisions; + + @JsonProperty("dailyApprovalRate") + private BigDecimal dailyApprovalRate; + + @JsonProperty("approvedDecisions") + private Long approvedDecisions; + + @JsonProperty("rejectedDecisions") + private Long rejectedDecisions; + + @JsonProperty("referredBackDecisions") + private Long referredBackDecisions; + + @JsonProperty("committeeReferralDecisions") + private Long committeeReferralDecisions; + + @JsonProperty("ma7dayDecisions") + private BigDecimal ma7dayDecisions; + + @JsonProperty("ma30dayDecisions") + private BigDecimal ma30dayDecisions; + + @JsonProperty("ma90dayDecisions") + private BigDecimal ma90dayDecisions; + + @JsonProperty("ma30dayApprovalRate") + private BigDecimal ma30dayApprovalRate; + + @JsonProperty("decisionsLastYear") + private Long decisionsLastYear; + + @JsonProperty("yoyDecisionsChange") + private Long yoyDecisionsChange; + + @JsonProperty("yoyDecisionsChangePct") + private BigDecimal yoyDecisionsChangePct; + + @JsonProperty("decisionYear") + private Integer decisionYear; + + @JsonProperty("decisionMonth") + private Integer decisionMonth; + + @JsonProperty("decisionWeek") + private Integer decisionWeek; + + @JsonProperty("dayOfWeek") + private Integer dayOfWeek; + + // Getters and setters + public Date getDecisionDay() { + return decisionDay != null ? new Date(decisionDay.getTime()) : null; + } + + public void setDecisionDay(final Date decisionDay) { + this.decisionDay = decisionDay != null ? new Date(decisionDay.getTime()) : null; + } + + public Long getDailyDecisions() { + return dailyDecisions; + } + + public void setDailyDecisions(final Long dailyDecisions) { + this.dailyDecisions = dailyDecisions; + } + + public BigDecimal getDailyApprovalRate() { + return dailyApprovalRate; + } + + public void setDailyApprovalRate(final BigDecimal dailyApprovalRate) { + this.dailyApprovalRate = dailyApprovalRate; + } + + public Long getApprovedDecisions() { + return approvedDecisions; + } + + public void setApprovedDecisions(final Long approvedDecisions) { + this.approvedDecisions = approvedDecisions; + } + + public Long getRejectedDecisions() { + return rejectedDecisions; + } + + public void setRejectedDecisions(final Long rejectedDecisions) { + this.rejectedDecisions = rejectedDecisions; + } + + public Long getReferredBackDecisions() { + return referredBackDecisions; + } + + public void setReferredBackDecisions(final Long referredBackDecisions) { + this.referredBackDecisions = referredBackDecisions; + } + + public Long getCommitteeReferralDecisions() { + return committeeReferralDecisions; + } + + public void setCommitteeReferralDecisions(final Long committeeReferralDecisions) { + this.committeeReferralDecisions = committeeReferralDecisions; + } + + public BigDecimal getMa7dayDecisions() { + return ma7dayDecisions; + } + + public void setMa7dayDecisions(final BigDecimal ma7dayDecisions) { + this.ma7dayDecisions = ma7dayDecisions; + } + + public BigDecimal getMa30dayDecisions() { + return ma30dayDecisions; + } + + public void setMa30dayDecisions(final BigDecimal ma30dayDecisions) { + this.ma30dayDecisions = ma30dayDecisions; + } + + public BigDecimal getMa90dayDecisions() { + return ma90dayDecisions; + } + + public void setMa90dayDecisions(final BigDecimal ma90dayDecisions) { + this.ma90dayDecisions = ma90dayDecisions; + } + + public BigDecimal getMa30dayApprovalRate() { + return ma30dayApprovalRate; + } + + public void setMa30dayApprovalRate(final BigDecimal ma30dayApprovalRate) { + this.ma30dayApprovalRate = ma30dayApprovalRate; + } + + public Long getDecisionsLastYear() { + return decisionsLastYear; + } + + public void setDecisionsLastYear(final Long decisionsLastYear) { + this.decisionsLastYear = decisionsLastYear; + } + + public Long getYoyDecisionsChange() { + return yoyDecisionsChange; + } + + public void setYoyDecisionsChange(final Long yoyDecisionsChange) { + this.yoyDecisionsChange = yoyDecisionsChange; + } + + public BigDecimal getYoyDecisionsChangePct() { + return yoyDecisionsChangePct; + } + + public void setYoyDecisionsChangePct(final BigDecimal yoyDecisionsChangePct) { + this.yoyDecisionsChangePct = yoyDecisionsChangePct; + } + + public Integer getDecisionYear() { + return decisionYear; + } + + public void setDecisionYear(final Integer decisionYear) { + this.decisionYear = decisionYear; + } + + public Integer getDecisionMonth() { + return decisionMonth; + } + + public void setDecisionMonth(final Integer decisionMonth) { + this.decisionMonth = decisionMonth; + } + + public Integer getDecisionWeek() { + return decisionWeek; + } + + public void setDecisionWeek(final Integer decisionWeek) { + this.decisionWeek = decisionWeek; + } + + public Integer getDayOfWeek() { + return dayOfWeek; + } + + public void setDayOfWeek(final Integer dayOfWeek) { + this.dayOfWeek = dayOfWeek; + } + } +} diff --git a/service.data.impl/src/main/java/com/hack23/cia/service/data/impl/export/package-info.java b/service.data.impl/src/main/java/com/hack23/cia/service/data/impl/export/package-info.java new file mode 100644 index 0000000000..3ecda4bb03 --- /dev/null +++ b/service.data.impl/src/main/java/com/hack23/cia/service/data/impl/export/package-info.java @@ -0,0 +1,40 @@ +/* + * Copyright 2010-2025 James Pether Sörling + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * $Id$ + * $HeadURL$ + */ +/** + * JSON export data transfer objects for intelligence products. + * + *

This package contains DTOs for exporting intelligence data as JSON, + * supporting CDN-ready static file generation and API responses.

+ * + *

Key Classes

+ *
    + *
  • {@link com.hack23.cia.service.data.impl.export.ExportMetadata} - Common metadata for all exports
  • + *
  • {@link com.hack23.cia.service.data.impl.export.RiskAssessmentExportDTO} - Risk assessment violations
  • + *
  • {@link com.hack23.cia.service.data.impl.export.CoalitionAlignmentExportDTO} - Party alignment matrix
  • + *
  • {@link com.hack23.cia.service.data.impl.export.TemporalTrendsExportDTO} - Temporal trend analysis
  • + *
+ * + *

Usage

+ *

These DTOs are populated by {@link com.hack23.cia.service.data.impl.IntelligenceExportServiceImpl} + * and serialized to JSON using Jackson ObjectMapper with pretty-print and ISO 8601 date formatting.

+ * + * @author intelligence-operative + * @since v1.36 (Intelligence Products JSON Export) + */ +package com.hack23.cia.service.data.impl.export; diff --git a/service.data.impl/src/test/java/com/hack23/cia/service/data/impl/IntelligenceExportServiceImplTest.java b/service.data.impl/src/test/java/com/hack23/cia/service/data/impl/IntelligenceExportServiceImplTest.java new file mode 100644 index 0000000000..715274bd49 --- /dev/null +++ b/service.data.impl/src/test/java/com/hack23/cia/service/data/impl/IntelligenceExportServiceImplTest.java @@ -0,0 +1,231 @@ +/* + * Copyright 2010-2025 James Pether Sörling + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * $Id$ + * $HeadURL$ + */ +package com.hack23.cia.service.data.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import com.hack23.cia.model.internal.application.data.impl.ViewDecisionTemporalTrends; +import com.hack23.cia.model.internal.application.data.party.impl.ViewRiksdagenCoalitionAlignmentMatrix; +import com.hack23.cia.model.internal.application.data.party.impl.ViewRiksdagenCoalitionAlignmentMatrixEmbeddedId; +import com.hack23.cia.model.internal.application.data.rules.impl.ResourceType; +import com.hack23.cia.model.internal.application.data.rules.impl.RuleViolation; +import com.hack23.cia.model.internal.application.data.rules.impl.Status; +import com.hack23.cia.service.data.api.RuleViolationDAO; +import com.hack23.cia.service.data.api.ViewDecisionTemporalTrendsDAO; +import com.hack23.cia.service.data.api.ViewRiksdagenCoalitionAlignmentMatrixDAO; + +/** + * Test class for IntelligenceExportServiceImpl. + * + * @author intelligence-operative + * @since v1.36 + */ +class IntelligenceExportServiceImplTest { + + @Mock + private RuleViolationDAO ruleViolationDAO; + + @Mock + private ViewRiksdagenCoalitionAlignmentMatrixDAO coalitionAlignmentDAO; + + @Mock + private ViewDecisionTemporalTrendsDAO temporalTrendsDAO; + + @InjectMocks + private IntelligenceExportServiceImpl service; + + private Path tempDir; + + @BeforeEach + void setUp() throws IOException { + MockitoAnnotations.openMocks(this); + tempDir = Files.createTempDirectory("test-json-export"); + } + + @AfterEach + void tearDown() throws IOException { + // Clean up temp directory + if (tempDir != null && Files.exists(tempDir)) { + Files.walk(tempDir) + .sorted((a, b) -> b.compareTo(a)) + .forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + // Ignore cleanup errors + } + }); + } + } + + @Test + void testExportRiskAssessments() throws Exception { + // Arrange + final List violations = new ArrayList<>(); + final RuleViolation violation = new RuleViolation( + "ref123", + "Test Person", + ResourceType.POLITICIAN, + "HIGH_ABSENCE_RATE", + "High absence from parliamentary sessions", + "Attendance", + Status.MAJOR, + "Improve attendance" + ); + violations.add(violation); + + when(ruleViolationDAO.getAll()).thenReturn(violations); + + // Act + final String json = service.exportRiskAssessments(); + + // Assert + assertNotNull(json); + assertTrue(json.contains("metadata")); + assertTrue(json.contains("violations")); + assertTrue(json.contains("HIGH_ABSENCE_RATE")); + assertTrue(json.contains("Test Person")); + } + + @Test + void testExportCoalitionAlignment() throws Exception { + // Arrange + final List alignments = new ArrayList<>(); + final ViewRiksdagenCoalitionAlignmentMatrix alignment = new ViewRiksdagenCoalitionAlignmentMatrix(); + final ViewRiksdagenCoalitionAlignmentMatrixEmbeddedId embeddedId = + new ViewRiksdagenCoalitionAlignmentMatrixEmbeddedId(); + embeddedId.setParty1("S"); + embeddedId.setParty2("V"); + alignment.setEmbeddedId(embeddedId); + alignment.setAlignmentRate(0.75); + alignment.setSharedVotes(100L); + alignment.setAlignedVotes(75L); + alignments.add(alignment); + + when(coalitionAlignmentDAO.getAll()).thenReturn(alignments); + + // Act + final String json = service.exportCoalitionAlignment(); + + // Assert + assertNotNull(json); + assertTrue(json.contains("metadata")); + assertTrue(json.contains("alignments")); + assertTrue(json.contains("\"party1\"")); + assertTrue(json.contains("\"party2\"")); + } + + @Test + void testExportTemporalTrends() throws Exception { + // Arrange + final List trends = new ArrayList<>(); + final ViewDecisionTemporalTrends trend = new ViewDecisionTemporalTrends(); + trend.setDecisionDay(new Date()); + trend.setDailyDecisions(50L); + trend.setDailyApprovalRate(new BigDecimal("0.75")); + trend.setApprovedDecisions(38L); + trend.setRejectedDecisions(12L); + trend.setMa7dayDecisions(new BigDecimal("45.5")); + trend.setMa30dayDecisions(new BigDecimal("48.2")); + trends.add(trend); + + when(temporalTrendsDAO.getAll()).thenReturn(trends); + + // Act + final String json = service.exportTemporalTrends(); + + // Assert + assertNotNull(json); + assertTrue(json.contains("metadata")); + assertTrue(json.contains("trends")); + assertTrue(json.contains("dailyDecisions")); + assertTrue(json.contains("dailyApprovalRate")); + } + + @Test + void testWriteJsonToFile() throws Exception { + // Arrange + final String jsonContent = "{\"test\": \"data\"}"; + final String fileName = "test-output.json"; + + // Act + service.writeJsonToFile(jsonContent, fileName, tempDir.toString()); + + // Assert + final Path filePath = tempDir.resolve(fileName); + assertTrue(Files.exists(filePath)); + final String content = Files.readString(filePath); + assertEquals(jsonContent, content); + } + + @Test + void testWriteJsonToFileCreatesDirectory() throws Exception { + // Arrange + final String jsonContent = "{\"test\": \"data\"}"; + final String fileName = "test-output.json"; + final Path newDir = tempDir.resolve("newdir"); + + // Act + service.writeJsonToFile(jsonContent, fileName, newDir.toString()); + + // Assert + assertTrue(Files.exists(newDir)); + final Path filePath = newDir.resolve(fileName); + assertTrue(Files.exists(filePath)); + } + + @Test + void testWriteJsonToFileRejectsPathTraversal() { + // Arrange + final String jsonContent = "{\"test\": \"data\"}"; + + // Act & Assert - Test various path traversal attempts + assertThrows(IllegalArgumentException.class, () -> + service.writeJsonToFile(jsonContent, "../etc/passwd", tempDir.toString())); + + assertThrows(IllegalArgumentException.class, () -> + service.writeJsonToFile(jsonContent, "..\\windows\\system32\\config", tempDir.toString())); + + assertThrows(IllegalArgumentException.class, () -> + service.writeJsonToFile(jsonContent, "path/to/file.json", tempDir.toString())); + + assertThrows(IllegalArgumentException.class, () -> + service.writeJsonToFile(jsonContent, "path\\to\\file.json", tempDir.toString())); + } +}