diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..7677d42 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(JAVA_HOME=\"/c/Program Files/Eclipse Adoptium/jdk-25.0.1.8-hotspot\" ./gradlew compileJava --no-daemon -q)" + ] + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..50ecd5e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true + +[*.properties] +charset = utf-8 diff --git a/build.gradle b/build.gradle index 98126c4..4efbb8f 100644 --- a/build.gradle +++ b/build.gradle @@ -23,6 +23,18 @@ configurations { extendsFrom annotationProcessor } } +configurations.configureEach { + resolutionStrategy { + force 'com.google.protobuf:protobuf-java:4.34.0' + force 'com.google.protobuf:protobuf-java-util:4.34.0' + eachDependency { details -> + if (details.requested.group == 'com.google.protobuf' && (details.requested.name == 'protobuf-java' || details.requested.name == 'protobuf-java-util')) { + details.useVersion '4.34.0' + details.because 'Keep protobuf runtime aligned with generated proto-common classes' + } + } + } +} repositories { mavenCentral() @@ -43,6 +55,12 @@ ext { } dependencies { + implementation 'net.logstash.logback:logstash-logback-encoder:8.1' + implementation 'io.opentelemetry:opentelemetry-exporter-otlp' + implementation 'io.micrometer:micrometer-tracing-bridge-otel' + implementation 'io.micrometer:micrometer-registry-prometheus' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'com.isums:isums-observability-common:1.0-SNAPSHOT' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.2' implementation 'org.springframework.boot:spring-boot-starter-cache' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' @@ -57,12 +75,13 @@ dependencies { implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' implementation "org.springframework.grpc:spring-grpc-client-spring-boot-starter" implementation "org.springframework.grpc:spring-grpc-spring-boot-starter" - implementation 'com.google.protobuf:protobuf-java-util:4.33.5' - implementation 'com.google.protobuf:protobuf-java:4.34.0-RC2' + implementation 'com.google.protobuf:protobuf-java-util:4.34.0' + implementation 'com.google.protobuf:protobuf-java:4.34.0' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' implementation 'software.amazon.awssdk:dynamodb:2.41.30' implementation 'software.amazon.awssdk:lambda:2.42.25' + implementation 'software.amazon.awssdk:translate:2.42.29' implementation "com.isums:proto-common:1.0-SNAPSHOT" implementation 'io.awspring.cloud:spring-cloud-aws-starter:4.0.0' implementation 'software.amazon.awssdk:iot:2.42.8' @@ -116,4 +135,6 @@ tasks.named('bootBuildImage') { tasks.named('test') { useJUnitPlatform() -} \ No newline at end of file +} + +tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' } diff --git a/docs/iam-policy-utility-alerts.md b/docs/iam-policy-utility-alerts.md new file mode 100644 index 0000000..41dcfff --- /dev/null +++ b/docs/iam-policy-utility-alerts.md @@ -0,0 +1,69 @@ +# IAM policy — utility-alerts endpoint + +The `GET /api/assets/utility-alerts` endpoint reads three DynamoDB +tables owned by the EIF forecast pipeline (`Z:\EIF\h2o_training`). +asset-service already has write access to `esp32_alerts` and `esp32_thresholds` +and `esp32_asset_map`; this feature only adds **read** on two more: + +- `esp32_usage_agg` — daily consumption aggregates (future: 30-day chart drawer) +- `esp32_forecast` — monthly Prophet output (`usedSoFar`, `totalEstimate`, `daysLeft`) + +## Additive policy statement + +Attach the following statement to the asset-service IAM role +(`isums-asset-service` in ECS / EC2 instance profile, or the IAM user +for dev). Do NOT replace existing statements — merge with the current +policy. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "UtilityAlertsReadEifTables", + "Effect": "Allow", + "Action": [ + "dynamodb:GetItem", + "dynamodb:Query", + "dynamodb:BatchGetItem", + "dynamodb:DescribeTable" + ], + "Resource": [ + "arn:aws:dynamodb:ap-southeast-1:*:table/esp32_usage_agg", + "arn:aws:dynamodb:ap-southeast-1:*:table/esp32_usage_agg/index/*", + "arn:aws:dynamodb:ap-southeast-1:*:table/esp32_forecast", + "arn:aws:dynamodb:ap-southeast-1:*:table/esp32_forecast/index/*" + ] + } + ] +} +``` + +## Why no write access? + +The EIF pipeline is the sole writer of both tables — asset-service +reading them directly keeps ownership clear and avoids the classic +"who invalidates the cache" ambiguity. If asset-service ever needs to +trigger a forecast refresh, use the existing `LambdaClient` invoke +against `esp32-forecast-dispatcher` (already wired in `IotForecastServiceImpl`). + +## Credential source + +asset-service resolves credentials via the standard AWS SDK provider +chain (`DefaultCredentialsProvider` in `DynamoConfig.java`), which +means: + +- **Prod (ECS)**: task role — no env vars, no rotation needed. +- **Dev (workstation)**: `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` + env vars, or `~/.aws/credentials` default profile. + +Region is pinned to `ap-southeast-1` in `DynamoConfig.java` — same +region as the EIF pipeline. + +## Verification + +After rollout, tail CloudWatch metrics for +`AWS/DynamoDB:ConsumedReadCapacityUnits` on both tables. Expected +profile: small burst on cache-miss (every 5 min per landlord), ~0 +otherwise. If reads exceed the table's on-demand baseline, either +(a) bump the cache TTL, or (b) move to a DAX accelerator. diff --git a/docs/utility-alerts-feature.md b/docs/utility-alerts-feature.md new file mode 100644 index 0000000..c05234a --- /dev/null +++ b/docs/utility-alerts-feature.md @@ -0,0 +1,193 @@ +# Utility Alerts — production feature handoff + +## What shipped + +Replaces the mock `/utilities` dashboard with a real fleet-wide alerts +surface driven by the EIF forecast pipeline + `iot_thresholds`. Landlord +and manager see every house they're responsible for, with the ones +crossing their monthly electricity/water cap surfaced first — and get +an email when a house crosses the threshold. + +## End-to-end flow + +``` +ESP32 → MQTT → AWS IoT → Lambda → esp32_raw (DDB) + │ + ▼ + EIF Xeon job (daily) → esp32_usage_agg + esp32_forecast + │ + ┌─────────────────────────┼─────────────────────────┐ + ▼ ▼ ▼ + READ PATH EMIT PATH ALERT PATH + UtilityAlertsService UtilityThresholdCrossover UtilityAlertEventListener + (on landlord hit) Scheduler (hourly) (notification-service) + │ │ │ + ▼ ▼ ▼ + Redis cache 5min Kafka utility.consumption.alert email vi/en/ja + │ │ + ▼ ▼ + FE Utilities.jsx Redis state store (48h TTL, debounce) +``` + +## Files added + +### asset-service (BE) +| File | Purpose | +|---|---| +| `domains/enums/UtilityMetric.java` | ELECTRICITY / WATER enum + forecast/threshold/unit keys | +| `domains/enums/UtilityStatus.java` | GOOD / WARNING / CRITICAL / NO_DATA | +| `domains/dtos/UtilityAlertItem.java` | One tile | +| `domains/dtos/UtilityAlertSummary.java` | Top-of-dashboard KPIs | +| `domains/dtos/UtilityAlertsResponse.java` | Envelope | +| `domains/events/UtilityThresholdExceededEvent.java` | Kafka payload | +| `infrastructures/abstracts/UtilityAlertsService.java` | Interface | +| `services/UtilityAlertsServiceImpl.java` | Read-side orchestrator + @Cacheable | +| `controllers/UtilityAlertsController.java` | `GET /api/assets/utility-alerts` | +| `schedulers/UtilityThresholdCrossoverScheduler.java` | Hourly cron + Redis debounce + Kafka emit | +| `infrastructures/grpcs/HouseGrpcImpl.java` | **extended** with listHousesByLandlord/Manager | + +### asset-service config + docs +| File | Change | +|---|---| +| `src/main/resources/application.properties` | `app.ddb.usageAggTable`, `app.ddb.forecastTable`, `app.cache.utility-alerts.ttl-seconds`, `app.kafka.topic.utility-alert`, `app.utility-alerts.scheduler.enabled` | +| `src/main/resources/messages*.properties` | `utility_alerts.retrieved` key in vi/en/ja | +| `docs/iam-policy-utility-alerts.md` | IAM read-only policy for `esp32_usage_agg` + `esp32_forecast` | + +### notification-service (BE) +| File | Purpose | +|---|---| +| `domains/events/UtilityThresholdExceededEvent.java` | Mirror of asset-service event | +| `infrastructures/listeners/UtilityAlertEventListener.java` | Kafka consumer + idempotency + email dispatch | +| `infrastructures/seeders/UtilityAlertTemplateSeeder.java` | Seeds `utility_threshold_exceeded` email templates vi/en/ja | + +### web-application (FE) +| File | Purpose | +|---|---| +| `features/utilities/api/utilities.api.js` | REST client (getUtilityAlerts, getHouseAlerts, upsertMonthlyLimit) | +| `features/utilities/hooks/useUtilityAlerts.js` | Module-scope cached hook, 60s TTL + manual refresh | +| `features/utilities/components/HouseAlertDrawer.jsx` | Per-house drawer with alerts + action strip | +| `app/layout/Utilities.jsx` | **rewritten** — real fetch, skeleton/empty/error, tiles, summary KPIs, drawer | +| `lib/api-endpoints.js` | **extended** with `UTILITY_ENDPOINTS` | +| `locales/{vi,en,ja}/common.json` | `utilitiesPage.*` extended — gas removed, drawer/status/states keys added | + +### tests +| File | Coverage | +|---|---| +| `services/UtilityAlertsServiceImplTest.java` | Role scoping (LANDLORD / MANAGER / TECH_STAFF), status bands (GOOD / WARNING / CRITICAL), NO_DATA degradation, DDB alert-count happy + failure paths | + +## Request / response + +``` +GET /api/assets/utility-alerts?metric=electricity +Authorization: Bearer + +200 OK +{ + "data": { + "metric": "ELECTRICITY", + "unit": "kWh", + "month": "2026-04", + "items": [ + { + "houseId": "…", + "houseName": "Nhà Q1 #12", + "currentUsage": 850.0, + "forecastTotal": 1020.0, + "monthlyLimit": 1000.0, + "usagePercent": 85.0, + "unit": "kWh", + "status": "WARNING", + "activeAlertCount": 2, + "daysLeft": 8, + "lastAlertAt": 1714020930000 + } + ], + "summary": { + "totalUsage": 3490.0, + "avgPerHouse": 872.5, + "houseCount": 4, + "housesOverThreshold": 2 + } + }, + "message": "Lấy danh sách cảnh báo tiện ích thành công" +} +``` + +## Metric names + +New rows in `iot_thresholds` table (one per house + utility): +- `metric = 'electricity_monthly_kwh'`, `max_val = ` +- `metric = 'water_monthly_m3'`, `max_val = ` +- `area_id = NULL` (house-level) + +Existing per-sensor thresholds (`voltage`, `current`, `power_W`, etc.) are +unaffected — they continue to power the `esp32_alerts` stream which the +drawer renders, but they're not what gates the tile status. + +## Kafka event + +**Topic**: `utility.consumption.alert` +**Key**: `houseId` (preserves ordering per house) +**Value** (JSON): +```json +{ + "eventId": "uuid-v4", + "houseId": "…", + "houseName": "Nhà Q1 #12", + "landlordUserId": "…", + "metric": "ELECTRICITY", + "previousStatus": "GOOD", + "currentStatus": "WARNING", + "currentUsage": 850.0, + "monthlyLimit": 1000.0, + "usagePercent": 85.0, + "unit": "kWh", + "month": "2026-04", + "occurredAt": 1714020930000 +} +``` + +Consumer: notification-service → email to landlord (template +`utility_threshold_exceeded`, default locale vi_VN). + +## Rollout checklist + +1. Merge branch. +2. **AWS**: attach IAM policy from `docs/iam-policy-utility-alerts.md` to + the asset-service ECS task role (or the dev IAM user). +3. **Kafka**: ensure topic `utility.consumption.alert` exists with RF=1 + (dev) or RF=3 (prod). Auto-created on first send if broker has + `auto.create.topics.enable=true`; otherwise create manually via + Kafka UI on `:9091`. +4. Restart asset-service and notification-service (IDE Run Dashboard). +5. Hard-refresh FE browser. +6. Seed a threshold via + `PUT /api/assets/houses/{id}/iot/thresholds/electricity_monthly_kwh` + with body `{"maxVal": 1000, "enabled": true, "severity": "WARNING"}`. +7. Wait ~5 minutes for Redis cache cold path; load `/utilities` in the + landlord session — tile should show real data. +8. For push notification smoke test: set `maxVal` very low (e.g. 10 kWh) + on a house that already has forecast data, wait for the `:07` past + hour cron (or call the scheduler method manually via an admin + endpoint in a future hardening pass), check the landlord's inbox + and Kafka UI for the event. + +## Observability + +- Micrometer counters: `utility_alerts_requests_total{metric}`, + `utility_alerts_scheduler_sweeps_total`, + `utility_alerts_scheduler_events_total`. +- Micrometer timer: `utility_alerts_build_duration{metric}`. +- Logs: all lines prefixed `[UtilityAlerts]` or `[UtilityAlert]` in + asset-service and notification-service respectively. + +## Known deferred items + +| Item | Why deferred | Ticket | +|---|---|---| +| `preferredLanguage` on UserResponse (proto) → landlord locale for email | Needs cross-team proto change | next sprint | +| Integration test with Testcontainers + LocalStack DDB | Requires LocalStack image pull + test infra; unit covers the orchestration logic | next sprint | +| Flyway baseline migration for `iot_thresholds` (replace `ddl-auto=update`) | Requires downtime coordination | hardening pass | +| Drawer 30-day chart | Needs a `esp32_usage_agg` → daily-series endpoint | next sprint | +| Region name enrichment (`regionName` on item) | Requires extra house-service gRPC call per row | next sprint | +| Gas metric | Not ingested today | when hardware lands | diff --git a/src/main/java/com/isums/assetservice/configurations/AwsIotConfig.java b/src/main/java/com/isums/assetservice/configurations/AwsIotConfig.java index 0fafff5..f75e23d 100644 --- a/src/main/java/com/isums/assetservice/configurations/AwsIotConfig.java +++ b/src/main/java/com/isums/assetservice/configurations/AwsIotConfig.java @@ -1,13 +1,16 @@ package com.isums.assetservice.configurations; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; -import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; -import software.amazon.awssdk.regions.Region; -import software.amazon.awssdk.services.iot.IotClient; -import software.amazon.awssdk.services.lambda.LambdaClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.iot.IotClient; +import software.amazon.awssdk.services.lambda.LambdaClient; +import org.springframework.util.StringUtils; @Configuration public class AwsIotConfig { @@ -19,20 +22,24 @@ public class AwsIotConfig { @Bean public IotClient iotClient() { - return IotClient.builder() - .region(Region.AP_SOUTHEAST_1) - .credentialsProvider(StaticCredentialsProvider.create( - AwsBasicCredentials.create(accessKey, secretKey) - )) - .build(); - } - - @Bean - public LambdaClient lambdaClient() { - return LambdaClient.builder() - .region(Region.AP_SOUTHEAST_1) - .credentialsProvider(StaticCredentialsProvider.create( - AwsBasicCredentials.create(accessKey, secretKey))) - .build(); - } -} + return IotClient.builder() + .region(Region.AP_SOUTHEAST_1) + .credentialsProvider(credentialsProvider()) + .build(); + } + + @Bean + public LambdaClient lambdaClient() { + return LambdaClient.builder() + .region(Region.AP_SOUTHEAST_1) + .credentialsProvider(credentialsProvider()) + .build(); + } + + private AwsCredentialsProvider credentialsProvider() { + if (StringUtils.hasText(accessKey) && StringUtils.hasText(secretKey)) { + return StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey)); + } + return DefaultCredentialsProvider.create(); + } +} diff --git a/src/main/java/com/isums/assetservice/configurations/AwsIotDataPlaneConfig.java b/src/main/java/com/isums/assetservice/configurations/AwsIotDataPlaneConfig.java index 4cec48c..0067b9e 100644 --- a/src/main/java/com/isums/assetservice/configurations/AwsIotDataPlaneConfig.java +++ b/src/main/java/com/isums/assetservice/configurations/AwsIotDataPlaneConfig.java @@ -4,9 +4,12 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.iotdataplane.IotDataPlaneClient; +import org.springframework.util.StringUtils; import java.net.URI; @@ -27,9 +30,14 @@ public IotDataPlaneClient iotDataPlaneClient() { return IotDataPlaneClient.builder() .region(Region.AP_SOUTHEAST_1) .endpointOverride(URI.create("https://" + mqttEndpoint)) - .credentialsProvider(StaticCredentialsProvider.create( - AwsBasicCredentials.create(accessKey, secretKey) - )) + .credentialsProvider(credentialsProvider()) .build(); } -} \ No newline at end of file + + private AwsCredentialsProvider credentialsProvider() { + if (StringUtils.hasText(accessKey) && StringUtils.hasText(secretKey)) { + return StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey)); + } + return DefaultCredentialsProvider.create(); + } +} diff --git a/src/main/java/com/isums/assetservice/configurations/DynamoConfig.java b/src/main/java/com/isums/assetservice/configurations/DynamoConfig.java index c1f3010..0bd738c 100644 --- a/src/main/java/com/isums/assetservice/configurations/DynamoConfig.java +++ b/src/main/java/com/isums/assetservice/configurations/DynamoConfig.java @@ -1,12 +1,15 @@ package com.isums.assetservice.configurations; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; -import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; -import software.amazon.awssdk.regions.Region; -import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import org.springframework.util.StringUtils; @Configuration public class DynamoConfig { @@ -19,11 +22,16 @@ public class DynamoConfig { @Bean public DynamoDbClient dynamoDbClient() { - return DynamoDbClient.builder() - .region(Region.AP_SOUTHEAST_1) - .credentialsProvider(StaticCredentialsProvider.create( - AwsBasicCredentials.create(accessKey, secretKey) - )) - .build(); - } -} + return DynamoDbClient.builder() + .region(Region.AP_SOUTHEAST_1) + .credentialsProvider(credentialsProvider()) + .build(); + } + + private AwsCredentialsProvider credentialsProvider() { + if (StringUtils.hasText(accessKey) && StringUtils.hasText(secretKey)) { + return StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey)); + } + return DefaultCredentialsProvider.create(); + } +} diff --git a/src/main/java/com/isums/assetservice/configurations/I18nConfig.java b/src/main/java/com/isums/assetservice/configurations/I18nConfig.java new file mode 100644 index 0000000..d14beff --- /dev/null +++ b/src/main/java/com/isums/assetservice/configurations/I18nConfig.java @@ -0,0 +1,38 @@ +package com.isums.assetservice.configurations; + +import org.springframework.context.MessageSource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.ResourceBundleMessageSource; +import org.springframework.web.servlet.LocaleResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver; + +import java.util.List; +import java.util.Locale; + +@Configuration +public class I18nConfig implements WebMvcConfigurer { + + @Bean + public MessageSource messageSource() { + ResourceBundleMessageSource source = new ResourceBundleMessageSource(); + source.setBasename("messages"); + source.setDefaultEncoding("UTF-8"); + source.setUseCodeAsDefaultMessage(true); + source.setFallbackToSystemLocale(false); + return source; + } + + @Bean + public LocaleResolver localeResolver() { + AcceptHeaderLocaleResolver resolver = new AcceptHeaderLocaleResolver(); + resolver.setDefaultLocale(new Locale("vi")); + resolver.setSupportedLocales(List.of( + new Locale("vi"), + Locale.ENGLISH, + Locale.JAPANESE + )); + return resolver; + } +} diff --git a/src/main/java/com/isums/assetservice/configurations/OpenApiConfig.java b/src/main/java/com/isums/assetservice/configurations/OpenApiConfig.java index a45aa4f..0a14e98 100644 --- a/src/main/java/com/isums/assetservice/configurations/OpenApiConfig.java +++ b/src/main/java/com/isums/assetservice/configurations/OpenApiConfig.java @@ -1,30 +1,70 @@ -package com.isums.assetservice.configurations; - -import io.swagger.v3.oas.models.Components; -import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.info.Info; -import io.swagger.v3.oas.models.security.SecurityRequirement; -import io.swagger.v3.oas.models.security.SecurityScheme; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class OpenApiConfig { - - private static final String BEARER_SCHEME = "bearerAuth"; - - @Bean - public OpenAPI openAPI() { - return new OpenAPI() - .info(new Info() - .title("EContract Service API") - .version("v1") - .description("Có nhiều thứ rất là khó nói vậy nên là lá đò")) - .addSecurityItem(new SecurityRequirement().addList(BEARER_SCHEME)) - .components(new Components().addSecuritySchemes( - BEARER_SCHEME, - new SecurityScheme().name(BEARER_SCHEME).type(SecurityScheme.Type.HTTP) - .scheme("bearer").bearerFormat("JWT") - )); - } -} +package com.isums.assetservice.configurations; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.parameters.Parameter; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springdoc.core.customizers.OperationCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.HandlerMethod; + +import java.util.ArrayList; + +@Configuration +public class OpenApiConfig { + + private static final String BEARER_SCHEME = "bearerAuth"; + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(new Info() + .title("EContract Service API") + .version("v1") + .description("ISUMS service API documentation")) + .addSecurityItem(new SecurityRequirement().addList(BEARER_SCHEME)) + .components(new Components().addSecuritySchemes( + BEARER_SCHEME, + new SecurityScheme().name(BEARER_SCHEME).type(SecurityScheme.Type.HTTP) + .scheme("bearer").bearerFormat("JWT") + )); + } + + @Bean + public OperationCustomizer acceptLanguageHeaderCustomizer() { + return (operation, handlerMethod) -> { + if (operation.getParameters() == null) { + operation.setParameters(new ArrayList<>()); + } + + boolean alreadyPresent = operation.getParameters().stream() + .anyMatch(parameter -> "Accept-Language".equalsIgnoreCase(parameter.getName())); + + if (!alreadyPresent && supportsLocalizedResponse(handlerMethod)) { + operation.addParametersItem(new Parameter() + .in("header") + .name("Accept-Language") + .required(false) + .description("Preferred response language. Supported: vi, en, ja. Default fallback: vi.") + .schema(new StringSchema() + ._default("vi") + .addEnumItem("vi") + .addEnumItem("en") + .addEnumItem("ja")) + .example("en")); + } + + return operation; + }; + } + + private boolean supportsLocalizedResponse(HandlerMethod handlerMethod) { + String packageName = handlerMethod.getBeanType().getPackageName(); + return packageName.startsWith("com.isums.assetservice.controllers"); + } +} + diff --git a/src/main/java/com/isums/assetservice/configurations/S3Config.java b/src/main/java/com/isums/assetservice/configurations/S3Config.java index 7029be8..8c1bf84 100644 --- a/src/main/java/com/isums/assetservice/configurations/S3Config.java +++ b/src/main/java/com/isums/assetservice/configurations/S3Config.java @@ -4,10 +4,14 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.translate.TranslateClient; +import org.springframework.util.StringUtils; @Configuration public class S3Config { @@ -25,18 +29,30 @@ public class S3Config { public S3Client s3Client() { return S3Client.builder() .region(Region.of(region)) - .credentialsProvider(StaticCredentialsProvider.create( - AwsBasicCredentials.create(accessKey, secretKey) - )).build(); + .credentialsProvider(credentialsProvider()) + .build(); } @Bean public S3Presigner s3Presigner() { return S3Presigner.builder() .region(Region.of(region)) - .credentialsProvider(StaticCredentialsProvider.create( - AwsBasicCredentials.create(accessKey, secretKey) - )) + .credentialsProvider(credentialsProvider()) + .build(); + } + + @Bean + public TranslateClient translateClient() { + return TranslateClient.builder() + .region(Region.of(region)) + .credentialsProvider(credentialsProvider()) .build(); } + + private AwsCredentialsProvider credentialsProvider() { + if (StringUtils.hasText(accessKey) && StringUtils.hasText(secretKey)) { + return StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey)); + } + return DefaultCredentialsProvider.create(); + } } diff --git a/src/main/java/com/isums/assetservice/configurations/SecurityConfig.java b/src/main/java/com/isums/assetservice/configurations/SecurityConfig.java index 92382d0..b6be520 100644 --- a/src/main/java/com/isums/assetservice/configurations/SecurityConfig.java +++ b/src/main/java/com/isums/assetservice/configurations/SecurityConfig.java @@ -29,7 +29,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/swagger-ui.html", "/v3/api-docs", "/v3/api-docs/**", - "/internal/iot/**" + "/api/assets/internal/iot/**" ).permitAll() .anyRequest().authenticated() ) diff --git a/src/main/java/com/isums/assetservice/controllers/AreaPowerController.java b/src/main/java/com/isums/assetservice/controllers/AreaPowerController.java new file mode 100644 index 0000000..b1e3937 --- /dev/null +++ b/src/main/java/com/isums/assetservice/controllers/AreaPowerController.java @@ -0,0 +1,56 @@ +package com.isums.assetservice.controllers; + +import com.isums.assetservice.domains.dtos.ApiResponse; +import com.isums.assetservice.domains.dtos.ApiResponses; +import com.isums.assetservice.domains.dtos.AreaPowerStateResponse; +import com.isums.assetservice.domains.dtos.AreaPowerToggleRequest; +import com.isums.assetservice.infrastructures.abstracts.AreaPowerService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.context.NoSuchMessageException; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +@RestController +@RequestMapping("/api/assets/houses/{houseId}/areas/{areaId}/iot") +@RequiredArgsConstructor +public class AreaPowerController { + + private final AreaPowerService areaPowerService; + private final MessageSource messageSource; + + private String msg(String code) { + try { + return messageSource.getMessage(code, null, LocaleContextHolder.getLocale()); + } catch (NoSuchMessageException e) { + return code; + } + } + + @PutMapping("/power") + public ApiResponse togglePower( + @PathVariable UUID houseId, + @PathVariable UUID areaId, + @RequestBody @Valid AreaPowerToggleRequest req, + @AuthenticationPrincipal Jwt jwt) { + + UUID requesterId = UUID.fromString(jwt.getSubject()); + AreaPowerStateResponse res = areaPowerService.toggleAreaPower(houseId, areaId, req.action(), requesterId); + return ApiResponses.ok(res, msg("iot.power_updated")); + } + + @GetMapping("/power") + public ApiResponse getPowerState( + @PathVariable UUID houseId, + @PathVariable UUID areaId) { + + AreaPowerStateResponse res = areaPowerService.getAreaPowerState(houseId, areaId); + return ApiResponses.ok(res, msg("iot.power_retrieved")); + } +} diff --git a/src/main/java/com/isums/assetservice/controllers/AssetCategoryController.java b/src/main/java/com/isums/assetservice/controllers/AssetCategoryController.java index 3fb1032..3a8f88b 100644 --- a/src/main/java/com/isums/assetservice/controllers/AssetCategoryController.java +++ b/src/main/java/com/isums/assetservice/controllers/AssetCategoryController.java @@ -8,6 +8,9 @@ import com.isums.assetservice.domains.dtos.AssetCategoryDTO.CreateAssetCategoryRequest; import com.isums.assetservice.domains.dtos.AssetCategoryDTO.UpdateAssetCategoryRequest; import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.context.NoSuchMessageException; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -18,28 +21,37 @@ @RequiredArgsConstructor public class AssetCategoryController { private final AssetCategoryService assetCategoryService; + private final MessageSource messageSource; + + private String msg(String code) { + try { + return messageSource.getMessage(code, null, LocaleContextHolder.getLocale()); + } catch (NoSuchMessageException e) { + return code; + } + } @GetMapping public ApiResponse> GetAllAssetCategories(){ List response = assetCategoryService.getAllAssetCategories(); - return ApiResponses.ok(response,"Get all categories successfully"); + return ApiResponses.ok(response, msg("category.get_all")); } @GetMapping("/{id}") public ApiResponse GetAssetCategoryById(@PathVariable UUID id){ AssetCategoryDto response = assetCategoryService.getById(id); - return ApiResponses.ok(response,"Get category by id successfully"); + return ApiResponses.ok(response, msg("category.get_by_id")); } @PostMapping public ApiResponse CreateAssetCategory(@RequestBody CreateAssetCategoryRequest request){ AssetCategoryDto response = assetCategoryService.createAssetCategory(request); - return ApiResponses.created(response,"Create category successfully"); + return ApiResponses.created(response, msg("category.create")); } @PutMapping("/{id}") public ApiResponse updateAssetCategory(@PathVariable UUID id, @RequestBody UpdateAssetCategoryRequest request){ - AssetCategoryDto response = assetCategoryService.updateAssetCategory(id,request); - return ApiResponses.ok(response,"Get all categories successfully"); + AssetCategoryDto response = assetCategoryService.updateAssetCategory(id, request); + return ApiResponses.ok(response, msg("category.get_all")); } } diff --git a/src/main/java/com/isums/assetservice/controllers/AssetEventController.java b/src/main/java/com/isums/assetservice/controllers/AssetEventController.java index 38e65bf..78c7e93 100644 --- a/src/main/java/com/isums/assetservice/controllers/AssetEventController.java +++ b/src/main/java/com/isums/assetservice/controllers/AssetEventController.java @@ -7,8 +7,12 @@ import com.isums.assetservice.domains.dtos.AssetEventDTO.AssetEventDto; import com.isums.assetservice.domains.dtos.AssetEventDTO.CreateAssetEventRequest; import lombok.RequiredArgsConstructor; - +import org.springframework.context.MessageSource; +import org.springframework.context.NoSuchMessageException; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import java.util.List; import java.util.UUID; @@ -18,29 +22,45 @@ @RequiredArgsConstructor public class AssetEventController { private final AssetEventService assetEventService; + private final MessageSource messageSource; - @PostMapping - public ApiResponse createAssetEvent(@RequestBody CreateAssetEventRequest request){ - AssetEventDto response = assetEventService.createEvent(request); - return ApiResponses.created(response,"Create Event successfully"); + private String msg(String code) { + try { + return messageSource.getMessage(code, null, LocaleContextHolder.getLocale()); + } catch (NoSuchMessageException e) { + return code; + } } @GetMapping public ApiResponse> GetAllEvents(){ List response = assetEventService.getAllAssetEvents(); - return ApiResponses.ok(response,"Get events successfully"); + return ApiResponses.ok(response, msg("event.get_all")); } @PutMapping("/{id}") public ApiResponse UpdateAssetEvent(@PathVariable UUID id, @RequestBody UpdateAssetEventRequest request){ - AssetEventDto response = assetEventService.updateEventStatus(id,request); - return ApiResponses.ok(response,"Update status successfully"); + AssetEventDto response = assetEventService.updateEventStatus(id, request); + return ApiResponses.ok(response, msg("event.update_status")); } @GetMapping("/job/{jobId}") public ApiResponse> getEventsByJob(@PathVariable UUID jobId) { List res = assetEventService.getEventsByJob(jobId); + return ApiResponses.ok(res, msg("event.get_by_job")); + } + + @GetMapping("/assets/{id}/latest-event") + public ApiResponse getLatest(@PathVariable UUID id){ + return ApiResponses.ok(assetEventService.getLatestEvent(id), msg("event.get_latest")); + } - return ApiResponses.ok(res, "Get asset events by job successfully"); + @PostMapping(value = "/{eventId}/images", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadEventImages( + @PathVariable UUID eventId, + @RequestParam("files") List files + ) { + assetEventService.uploadEventImages(eventId, files); + return ApiResponses.ok(null, msg("event.upload_images")); } } diff --git a/src/main/java/com/isums/assetservice/controllers/AssetItemController.java b/src/main/java/com/isums/assetservice/controllers/AssetItemController.java index 3f203f9..b00b45a 100644 --- a/src/main/java/com/isums/assetservice/controllers/AssetItemController.java +++ b/src/main/java/com/isums/assetservice/controllers/AssetItemController.java @@ -7,8 +7,20 @@ import com.isums.assetservice.domains.dtos.AssetItemDTO.CreateAssetItemRequest; import com.isums.assetservice.domains.dtos.AssetItemDTO.UpdateAssetItemRequest; import com.isums.assetservice.infrastructures.grpcs.GrpcUserClient; +import com.isums.observability.audit.AuditAction; import com.isums.userservice.grpc.UserResponse; +import common.paginations.dtos.PageRequestParams; +import common.paginations.dtos.PageResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.context.MessageSource; +import org.springframework.context.NoSuchMessageException; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.http.MediaType; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.oauth2.jwt.Jwt; @@ -16,6 +28,7 @@ import org.springframework.web.multipart.MultipartFile; import java.util.List; +import java.util.Map; import java.util.UUID; @RestController @@ -24,74 +37,159 @@ public class AssetItemController { private final AssetItemService assetItemService; private final GrpcUserClient grpcUserClient; + private final MessageSource messageSource; + + private String msg(String code) { + try { + return messageSource.getMessage(code, null, LocaleContextHolder.getLocale()); + } catch (NoSuchMessageException e) { + return code; + } + } @GetMapping - public ApiResponse> GetAllAssetItems() { - List response = assetItemService.GetAllAssetItems(); - return ApiResponses.ok(response,"Get all asset-items successfully"); + @AuditAction(action = "ASSET.LIST", resourceType = "ASSET") + public ApiResponse> GetAll(@ParameterObject @ModelAttribute PageRequestParams params) { + PageResponse response = assetItemService.getAll(params.toPageRequest()); + return ApiResponses.ok(response, msg("asset.get_all")); } @PostMapping + @AuditAction(action = "ASSET.CREATE", resourceType = "ASSET") + @Operation( + summary = "Tao metadata asset", + description = """ + Flow khuyen nghi khi tao asset tren Postman/Swagger: + 1. Goi POST /api/assets/items de tao metadata asset. + 2. Trong request create, field assetImages nen de [] neu anh dung API upload anh rieng. + 3. Sau khi create thanh cong va lay duoc assetId, goi POST /api/assets/items/{assetId}/images voi multipart/form-data key = files de upload anh. + + houseId + functionAreaId dung de gan asset vao dung nha / dung khu vuc. + categoryId la loai asset. + displayName la map da ngon ngu, toi thieu nen co khoa vi. + """ + ) + @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + name = "Create asset without images", + value = """ + { + "houseId": "70279423-989d-48dc-8f2e-9bd6508a6f4a", + "functionAreaId": "d7eb14d0-110e-4534-898b-d7d680cff898", + "categoryId": "f1eaaa88-1e36-4454-b345-91cd6a06a6c1", + "displayName": { + "vi": "TV phong khach" + }, + "serialNumber": "TV-SS-003", + "conditionPercent": 80, + "status": "IN_USE", + "assetImages": [] + } + """ + ) + ) + ) public ApiResponse CreateAssetItem(@RequestBody CreateAssetItemRequest request) { AssetItemDto response = assetItemService.CreateAssetItem(request); - return ApiResponses.ok(response,"Create asset-item successfully"); + return ApiResponses.ok(response, msg("asset.create")); } @PutMapping("/{id}") + @AuditAction(action = "ASSET.UPDATE", resourceType = "ASSET", resourceIdExpression = "#args[0]") public ApiResponse UpdateAssetItem(@PathVariable UUID id, @RequestBody UpdateAssetItemRequest request) { AssetItemDto response = assetItemService.UpdateAssetItem(id, request); - return ApiResponses.ok(response,"Update asset item successfully"); - + return ApiResponses.ok(response, msg("asset.update")); } @DeleteMapping("/{id}") + @AuditAction(action = "ASSET.DELETE", resourceType = "ASSET", resourceIdExpression = "#args[0]") public ApiResponse DeleteAssetItem(@PathVariable UUID id) { Boolean response = assetItemService.deleteAssetItem(id); - return ApiResponses.ok(response,"Delete asset items successfully"); - + return ApiResponses.ok(response, msg("asset.delete")); } @GetMapping("/{id}") + @AuditAction(action = "ASSET.VIEW", resourceType = "ASSET", resourceIdExpression = "#args[0]") public ApiResponse getAssetItemById(@PathVariable UUID id) { AssetItemDto res = assetItemService.getAssetItemById(id); - return ApiResponses.ok(res, "Success to get asset item"); + return ApiResponses.ok(res, msg("asset.get_by_id")); } @GetMapping("/house/{id}") + @AuditAction(action = "ASSET.LIST_BY_HOUSE", resourceType = "HOUSE", resourceIdExpression = "#args[0]") public ApiResponse> getAssetItemByHouseId(@PathVariable UUID id) { - return ApiResponses.ok(assetItemService.getAssetItemsByHouseId(id), "Success to get asset item by house id"); + return ApiResponses.ok(assetItemService.getAssetItemsByHouseId(id), msg("asset.get_by_house")); + } + + @GetMapping("/house/{houseId}/function-area-counts") + @AuditAction(action = "ASSET.COUNT_BY_HOUSE_AREA", resourceType = "HOUSE", resourceIdExpression = "#args[0]") + public ApiResponse> getAssetCountByFunctionArea(@PathVariable UUID houseId) { + return ApiResponses.ok(assetItemService.getAssetCountByFunctionArea(houseId), msg("asset.count_by_house_area")); + } + + @GetMapping("/house/{houseId}/function-area/{functionAreaId}") + @AuditAction(action = "ASSET.LIST_BY_HOUSE_AREA", resourceType = "HOUSE", resourceIdExpression = "#args[0]") + public ApiResponse> getAssetItemByHouseIdAndFunctionAreaId( + @PathVariable UUID houseId, + @PathVariable UUID functionAreaId + ) { + return ApiResponses.ok( + assetItemService.getAssetItemsByHouseIdAndFunctionAreaId(houseId, functionAreaId), + msg("asset.get_by_house_area") + ); } @PutMapping("/{id}/transfer") + @AuditAction(action = "ASSET.TRANSFER", resourceType = "ASSET", resourceIdExpression = "#args[0]") public ApiResponse updateHouseForAsset(@PathVariable UUID id, @RequestBody UpdateHouseRequest request, @AuthenticationPrincipal Jwt jwt){ UUID userId = UUID.fromString(jwt.getSubject()); - return ApiResponses.ok(assetItemService.updateHouseForAsset(id,request,userId),"Update new house for asset-item successfully"); + return ApiResponses.ok(assetItemService.updateHouseForAsset(id, request, userId), msg("asset.update_house")); } @PostMapping(value = "/{assetId}/images", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ApiResponse uploadAssetImages(@PathVariable UUID assetId, @RequestParam("files") List files) { + @AuditAction(action = "ASSET.IMAGE_UPLOAD", resourceType = "ASSET", resourceIdExpression = "#args[0]") + @Operation( + summary = "Upload anh cho asset", + description = """ + API nay upload anh sau khi asset da duoc tao. + Postman dung multipart/form-data: + - key: files + - type: File + - co the chon nhieu file cung key files + """ + ) + public ApiResponse uploadAssetImages(@PathVariable UUID assetId, @RequestParam("files") List files) { assetItemService.uploadAssetImages(assetId, files); - return ApiResponses.ok(null, "Upload images successfully"); - } - - @GetMapping("{assetId}/images") - public ApiResponse> getAssetImages(@PathVariable UUID assetId) { - List images = assetItemService.getAssetImages(assetId); - return ApiResponses.ok(images, "Get images successfully"); + return ApiResponses.ok(null, msg("asset.upload_images")); } @DeleteMapping("{assetId}/image/{imageId}") + @AuditAction(action = "ASSET.IMAGE_DELETE", resourceType = "ASSET", resourceIdExpression = "#args[0]") public ApiResponse deleteAssetImage(@PathVariable UUID assetId, @PathVariable UUID imageId) { assetItemService.deleteAssetImage(assetId, imageId); - return ApiResponses.ok(null, "Delete image successfully"); + return ApiResponses.ok(null, msg("asset.delete_image")); } @PutMapping("/maintenance/batch") - public ApiResponse batchUpdateAssetCondition(@AuthenticationPrincipal Jwt jwt,@RequestBody BatchUpdateAssetRequest request) { + @AuditAction(action = "ASSET.CONDITION_BATCH_UPDATE", resourceType = "ASSET") + public ApiResponse batchUpdateAssetCondition( + @AuthenticationPrincipal Jwt jwt, + @RequestBody BatchUpdateAssetRequest request + ) { UserResponse user = grpcUserClient.getUserIdAndRoleByKeyCloakId(jwt.getSubject()); - BatchUpdateResponse res = assetItemService.batchUpdateAssetCondition(UUID.fromString(user.getId()),request); - return ApiResponses.ok(res, "Batch update asset successfully"); + BatchUpdateResponse res = assetItemService.batchUpdateAssetCondition(UUID.fromString(user.getId()), request); + return ApiResponses.ok(res, msg("asset.batch_update")); + } + + @PutMapping("/{assetId}/manager-confirm-asset") + @AuditAction(action = "ASSET.CONFIRM", resourceType = "ASSET", resourceIdExpression = "#args[0]") + public ApiResponse confirmAsset(@PathVariable UUID assetId, @RequestBody ConfirmAssetRequest request) { + AssetItemDto res = assetItemService.confirmAsset(assetId, request.status()); + return ApiResponses.ok(res, msg("asset.confirm")); } } diff --git a/src/main/java/com/isums/assetservice/controllers/AssetTagController.java b/src/main/java/com/isums/assetservice/controllers/AssetTagController.java index da3347b..deb4b28 100644 --- a/src/main/java/com/isums/assetservice/controllers/AssetTagController.java +++ b/src/main/java/com/isums/assetservice/controllers/AssetTagController.java @@ -10,6 +10,9 @@ import com.isums.assetservice.domains.entities.AssetTag; import com.isums.assetservice.infrastructures.abstracts.AssetTagService; import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.context.NoSuchMessageException; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.web.bind.annotation.*; @RestController @@ -17,30 +20,37 @@ @RequiredArgsConstructor public class AssetTagController { private final AssetTagService assetTagService; + private final MessageSource messageSource; + + private String msg(String code) { + try { + return messageSource.getMessage(code, null, LocaleContextHolder.getLocale()); + } catch (NoSuchMessageException e) { + return code; + } + } - //attach @PostMapping public ApiResponse attachTag(@RequestBody AttachTagRequest request){ AssetTagDto res = assetTagService.attachTag(request); - return ApiResponses.created(res,"Attach tag successfully"); + return ApiResponses.created(res, msg("tag.attach")); } - //detach + @PutMapping("/detach/{tagValue}") public ApiResponse detachTag(@PathVariable String tagValue){ AssetTagDto res = assetTagService.detachTag(tagValue); - return ApiResponses.ok(res,"Detach tag successfully"); + return ApiResponses.ok(res, msg("tag.detach")); } - //transfer @PutMapping("/transfer") public ApiResponse transferTag(@RequestBody TransferTagRequest request){ AssetTagDto res = assetTagService.transferTag(request); - return ApiResponses.ok(res,"Transfer tag successfully"); + return ApiResponses.ok(res, msg("tag.transfer")); } @GetMapping("/asset/{tagValue}") public ApiResponse getAssetItemByTagValue(@PathVariable String tagValue){ AssetItemDto res = assetTagService.getAssetItemByTagValue(tagValue); - return ApiResponses.ok(res,"Get asset item successfully"); + return ApiResponses.ok(res, msg("tag.get")); } } diff --git a/src/main/java/com/isums/assetservice/controllers/AssetTagLogController.java b/src/main/java/com/isums/assetservice/controllers/AssetTagLogController.java index 5455ad8..13ab5d7 100644 --- a/src/main/java/com/isums/assetservice/controllers/AssetTagLogController.java +++ b/src/main/java/com/isums/assetservice/controllers/AssetTagLogController.java @@ -5,6 +5,9 @@ import com.isums.assetservice.domains.entities.AssetTagLog; import com.isums.assetservice.infrastructures.abstracts.AssetTagLogService; import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.context.NoSuchMessageException; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.security.core.parameters.P; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -19,16 +22,25 @@ @RequiredArgsConstructor public class AssetTagLogController { private final AssetTagLogService assetTagLogService; + private final MessageSource messageSource; + + private String msg(String code) { + try { + return messageSource.getMessage(code, null, LocaleContextHolder.getLocale()); + } catch (NoSuchMessageException e) { + return code; + } + } @GetMapping("/tag/{tagValue}") public ApiResponse> getLogsByTagValue(@PathVariable String tagValue){ List res = assetTagLogService.getLogsByTag(tagValue); - return ApiResponses.ok(res,"Get logs by tag value successfully"); + return ApiResponses.ok(res, msg("tag_log.get_by_tag")); } @GetMapping("/asset/{assetId}") public ApiResponse> getLogsByAssetId(@PathVariable UUID assetId){ List res = assetTagLogService.getLogsByAsset(assetId); - return ApiResponses.ok(res,"Get logs by asset id successfully"); + return ApiResponses.ok(res, msg("tag_log.get_by_asset")); } } diff --git a/src/main/java/com/isums/assetservice/controllers/InternalIotController.java b/src/main/java/com/isums/assetservice/controllers/InternalIotController.java index e6ef944..ad8fed9 100644 --- a/src/main/java/com/isums/assetservice/controllers/InternalIotController.java +++ b/src/main/java/com/isums/assetservice/controllers/InternalIotController.java @@ -1,18 +1,16 @@ package com.isums.assetservice.controllers; +import com.isums.assetservice.domains.dtos.SyncCapabilitiesRequest; import com.isums.assetservice.domains.dtos.ActivatedRequest; import com.isums.assetservice.domains.dtos.ApiResponse; import com.isums.assetservice.domains.dtos.ApiResponses; import com.isums.assetservice.infrastructures.abstracts.IotProvisioningService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/internal/iot") +@RequestMapping("/api/assets/internal/iot") @RequiredArgsConstructor public class InternalIotController { @@ -23,4 +21,10 @@ public ApiResponse activated(@RequestBody @Valid ActivatedRequest req) { provisioningService.activateController(req.thingName()); return ApiResponses.noContent(); } -} + + @PatchMapping("/node-capabilities") + public ApiResponse syncNodeCapabilities(@RequestBody @Valid SyncCapabilitiesRequest req) { + provisioningService.syncNodeCapabilitiesIfEmpty(req.thing(), req.capabilities()); + return ApiResponses.noContent(); + } +} \ No newline at end of file diff --git a/src/main/java/com/isums/assetservice/controllers/IotAreaThresholdController.java b/src/main/java/com/isums/assetservice/controllers/IotAreaThresholdController.java index 0399aa6..05721df 100644 --- a/src/main/java/com/isums/assetservice/controllers/IotAreaThresholdController.java +++ b/src/main/java/com/isums/assetservice/controllers/IotAreaThresholdController.java @@ -1,42 +1,54 @@ -package com.isums.assetservice.controllers; - -import com.isums.assetservice.domains.dtos.ApiResponse; -import com.isums.assetservice.domains.dtos.ApiResponses; -import com.isums.assetservice.domains.dtos.ThresholdRequest; -import com.isums.assetservice.domains.dtos.ThresholdResponse; -import com.isums.assetservice.services.IotThresholdService; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; - -import java.util.List; -import java.util.UUID; - -@RestController -@RequestMapping("/api/assets/houses/{houseId}/areas/{areaId}/iot/thresholds") -@RequiredArgsConstructor -public class IotAreaThresholdController { - private final IotThresholdService thresholdService; - - @GetMapping - @PreAuthorize("hasAnyRole('LANDLORD','ADMIN', 'TENANT')") - public ApiResponse> getAll(@PathVariable UUID houseId, @PathVariable UUID areaId) { - var res = thresholdService.getAllByArea(areaId); - return ApiResponses.ok(res, "Success to get thresholds"); - } - - @PutMapping("/{metric}") - @PreAuthorize("hasAnyRole('LANDLORD','ADMIN', 'TENANT')") - public ApiResponse upsert(@PathVariable UUID houseId, @PathVariable UUID areaId, @PathVariable String metric, @Valid @RequestBody ThresholdRequest req) { - var res = thresholdService.upsertAreaLevel(houseId, areaId, metric, req); - return ApiResponses.ok(res, "Success to upsert threshold"); - } - - @DeleteMapping("/{metric}") - @PreAuthorize("hasAnyRole('LANDLORD','ADMIN', 'TENANT')") - public ApiResponse delete(@PathVariable UUID houseId, @PathVariable UUID areaId, @PathVariable String metric) { - thresholdService.deleteAreaLevel(areaId, metric); - return ApiResponses.noContent(); - } -} +package com.isums.assetservice.controllers; + +import com.isums.assetservice.domains.dtos.ApiResponse; +import com.isums.assetservice.domains.dtos.ApiResponses; +import com.isums.assetservice.domains.dtos.ThresholdRequest; +import com.isums.assetservice.domains.dtos.ThresholdResponse; +import com.isums.assetservice.services.IotThresholdService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.context.NoSuchMessageException; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/assets/houses/{houseId}/areas/{areaId}/iot/thresholds") +@RequiredArgsConstructor +public class IotAreaThresholdController { + private final IotThresholdService thresholdService; + private final MessageSource messageSource; + + private String msg(String code) { + try { + return messageSource.getMessage(code, null, LocaleContextHolder.getLocale()); + } catch (NoSuchMessageException e) { + return code; + } + } + + @GetMapping + @PreAuthorize("hasAnyRole('LANDLORD','ADMIN', 'TENANT')") + public ApiResponse> getAll(@PathVariable UUID houseId, @PathVariable UUID areaId) { + var res = thresholdService.getAllByArea(areaId); + return ApiResponses.ok(res, msg("iot.threshold_get")); + } + + @PutMapping("/{metric}") + @PreAuthorize("hasAnyRole('LANDLORD','ADMIN', 'TENANT')") + public ApiResponse upsert(@PathVariable UUID houseId, @PathVariable UUID areaId, @PathVariable String metric, @Valid @RequestBody ThresholdRequest req) { + var res = thresholdService.upsertAreaLevel(houseId, areaId, metric, req); + return ApiResponses.ok(res, msg("iot.threshold_upsert")); + } + + @DeleteMapping("/{metric}") + @PreAuthorize("hasAnyRole('LANDLORD','ADMIN', 'TENANT')") + public ApiResponse delete(@PathVariable UUID houseId, @PathVariable UUID areaId, @PathVariable String metric) { + thresholdService.deleteAreaLevel(areaId, metric); + return ApiResponses.noContent(); + } +} diff --git a/src/main/java/com/isums/assetservice/controllers/IotDeviceController.java b/src/main/java/com/isums/assetservice/controllers/IotDeviceController.java index eca4987..9eb2f4c 100644 --- a/src/main/java/com/isums/assetservice/controllers/IotDeviceController.java +++ b/src/main/java/com/isums/assetservice/controllers/IotDeviceController.java @@ -1,34 +1,44 @@ -package com.isums.assetservice.controllers; - -import com.isums.assetservice.domains.dtos.ApiResponse; -import com.isums.assetservice.domains.dtos.ApiResponses; -import com.isums.assetservice.domains.dtos.IoTDeviceDto; -import com.isums.assetservice.domains.dtos.IotControllerDto; -import com.isums.assetservice.infrastructures.abstracts.IoTDeviceService; -import jakarta.validation.constraints.NotBlank; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import java.util.UUID; - -@RestController -@RequestMapping("/api/assets/iot-devices") -@RequiredArgsConstructor -public class IotDeviceController { - private final IoTDeviceService iotDeviceService; - - @GetMapping("/{thing}") - public ApiResponse getByThing(@PathVariable @NotBlank String thing) { - var res = iotDeviceService.getByThing(thing); - return ApiResponses.ok(res, "Success to get IoT device"); - } - - @GetMapping("/house/{houseId}") - public ApiResponse getAllIotDevices(@PathVariable UUID houseId) { - var res = iotDeviceService.getAllIotByHouse(houseId); - return ApiResponses.ok(res, "Success to get IoT devices"); - } -} +package com.isums.assetservice.controllers; + +import com.isums.assetservice.domains.dtos.ApiResponse; +import com.isums.assetservice.domains.dtos.ApiResponses; +import com.isums.assetservice.domains.dtos.IoTDeviceDto; +import com.isums.assetservice.domains.dtos.IotControllerDto; +import com.isums.assetservice.infrastructures.abstracts.IoTDeviceService; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.context.NoSuchMessageException; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +@RestController +@RequestMapping("/api/assets/iot-devices") +@RequiredArgsConstructor +public class IotDeviceController { + + private final IoTDeviceService iotDeviceService; + private final MessageSource messageSource; + + private String msg(String code) { + try { + return messageSource.getMessage(code, null, LocaleContextHolder.getLocale()); + } catch (NoSuchMessageException e) { + return code; + } + } + + @GetMapping("/{thing}") + public ApiResponse getByThing(@PathVariable @NotBlank String thing) { + var res = iotDeviceService.getByThing(thing); + return ApiResponses.ok(res, msg("iot.device_get")); + } + + @GetMapping("/house/{houseId}") + public ApiResponse getAllIotDevices(@PathVariable UUID houseId) { + var res = iotDeviceService.getAllIotByHouse(houseId); + return ApiResponses.ok(res, msg("iot.devices_get")); + } +} diff --git a/src/main/java/com/isums/assetservice/controllers/IotForecastAdminController.java b/src/main/java/com/isums/assetservice/controllers/IotForecastAdminController.java new file mode 100644 index 0000000..f7bde46 --- /dev/null +++ b/src/main/java/com/isums/assetservice/controllers/IotForecastAdminController.java @@ -0,0 +1,38 @@ +package com.isums.assetservice.controllers; + +import com.isums.assetservice.domains.dtos.ApiResponse; +import com.isums.assetservice.domains.dtos.ApiResponses; +import com.isums.assetservice.domains.dtos.ForecastTriggerResultDto; +import com.isums.assetservice.infrastructures.abstracts.IotForecastService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.context.NoSuchMessageException; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/assets/iot/forecast") +@RequiredArgsConstructor +public class IotForecastAdminController { + + private final IotForecastService forecastService; + private final MessageSource messageSource; + + private String msg(String code) { + try { + return messageSource.getMessage(code, null, LocaleContextHolder.getLocale()); + } catch (NoSuchMessageException e) { + return code; + } + } + + @PostMapping("/trigger-all") + @PreAuthorize("hasAnyRole('LANDLORD','MANAGER')") + public ApiResponse triggerAllForecasts() { + ForecastTriggerResultDto result = forecastService.triggerAllForecasts(); + return ApiResponses.ok(result, msg("iot.forecast_triggered_all")); + } +} diff --git a/src/main/java/com/isums/assetservice/controllers/IotForecastController.java b/src/main/java/com/isums/assetservice/controllers/IotForecastController.java index eac73eb..6b9ac43 100644 --- a/src/main/java/com/isums/assetservice/controllers/IotForecastController.java +++ b/src/main/java/com/isums/assetservice/controllers/IotForecastController.java @@ -3,6 +3,9 @@ import com.isums.assetservice.domains.dtos.*; import com.isums.assetservice.infrastructures.abstracts.IotForecastService; import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.context.NoSuchMessageException; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; @@ -14,12 +17,21 @@ public class IotForecastController { private final IotForecastService forecastService; + private final MessageSource messageSource; + + private String msg(String code) { + try { + return messageSource.getMessage(code, null, LocaleContextHolder.getLocale()); + } catch (NoSuchMessageException e) { + return code; + } + } @GetMapping @PreAuthorize("hasAnyRole('TENANT','LANDLORD','MANAGER')") public ApiResponse getForecastAll(@PathVariable UUID houseId, @RequestParam(required = false) String month) { ForecastAllDto result = forecastService.getForecastAll(houseId, month); - return ApiResponses.ok(result, "Success"); + return ApiResponses.ok(result, msg("iot.forecast")); } @GetMapping("/{metric}") @@ -33,13 +45,13 @@ public ApiResponse getForecast(@PathVariable UUID houseId, @Pa } ForecastScopeDto result = forecastService.getForecast(houseId, areaId, metric, month); - return ApiResponses.ok(result, "Success"); + return ApiResponses.ok(result, msg("iot.forecast")); } @PostMapping("/trigger") @PreAuthorize("hasAnyRole('LANDLORD','MANAGER')") public ApiResponse triggerForecast(@PathVariable UUID houseId) { forecastService.triggerForecast(houseId); - return ApiResponses.ok(null, "Forecast job triggered"); + return ApiResponses.ok(null, msg("iot.forecast_triggered")); } -} \ No newline at end of file +} diff --git a/src/main/java/com/isums/assetservice/controllers/IotHouseThresholdController.java b/src/main/java/com/isums/assetservice/controllers/IotHouseThresholdController.java index 75e34c0..a21e1a2 100644 --- a/src/main/java/com/isums/assetservice/controllers/IotHouseThresholdController.java +++ b/src/main/java/com/isums/assetservice/controllers/IotHouseThresholdController.java @@ -1,43 +1,55 @@ -package com.isums.assetservice.controllers; - -import com.isums.assetservice.domains.dtos.ApiResponse; -import com.isums.assetservice.domains.dtos.ApiResponses; -import com.isums.assetservice.domains.dtos.ThresholdRequest; -import com.isums.assetservice.domains.dtos.ThresholdResponse; -import com.isums.assetservice.services.IotThresholdService; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; - -import java.util.List; -import java.util.UUID; - -@RestController -@RequestMapping("/api/assets/houses/{houseId}/iot/thresholds") -@RequiredArgsConstructor -public class IotHouseThresholdController { - - private final IotThresholdService thresholdService; - - @GetMapping() - @PreAuthorize("hasAnyRole('LANDLORD','ADMIN', 'TENANT')") - public ApiResponse> getHouseThresholds(@PathVariable UUID houseId) { - var res = thresholdService.getAllByHouse(houseId); - return ApiResponses.ok(res, "Success to get thresholds"); - } - - @PutMapping("/{metric}") - @PreAuthorize("hasAnyRole('LANDLORD','ADMIN', 'TENANT')") - public ApiResponse upsertHouse(@PathVariable UUID houseId, @PathVariable String metric, @Valid @RequestBody ThresholdRequest req) { - var res = thresholdService.upsertHouseLevel(houseId, metric, req); - return ApiResponses.ok(res, "Success to upsert threshold"); - } - - @DeleteMapping("/{metric}") - @PreAuthorize("hasRole('ADMIN')") - public ApiResponse deleteHouse(@PathVariable UUID houseId, @PathVariable String metric) { - thresholdService.deleteHouseLevel(houseId, metric); - return ApiResponses.noContent(); - } -} +package com.isums.assetservice.controllers; + +import com.isums.assetservice.domains.dtos.ApiResponse; +import com.isums.assetservice.domains.dtos.ApiResponses; +import com.isums.assetservice.domains.dtos.ThresholdRequest; +import com.isums.assetservice.domains.dtos.ThresholdResponse; +import com.isums.assetservice.services.IotThresholdService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.context.NoSuchMessageException; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/assets/houses/{houseId}/iot/thresholds") +@RequiredArgsConstructor +public class IotHouseThresholdController { + + private final IotThresholdService thresholdService; + private final MessageSource messageSource; + + private String msg(String code) { + try { + return messageSource.getMessage(code, null, LocaleContextHolder.getLocale()); + } catch (NoSuchMessageException e) { + return code; + } + } + + @GetMapping() + @PreAuthorize("hasAnyRole('LANDLORD','ADMIN', 'TENANT')") + public ApiResponse> getHouseThresholds(@PathVariable UUID houseId) { + var res = thresholdService.getAllByHouse(houseId); + return ApiResponses.ok(res, msg("iot.threshold_get")); + } + + @PutMapping("/{metric}") + @PreAuthorize("hasAnyRole('LANDLORD','ADMIN', 'TENANT')") + public ApiResponse upsertHouse(@PathVariable UUID houseId, @PathVariable String metric, @Valid @RequestBody ThresholdRequest req) { + var res = thresholdService.upsertHouseLevel(houseId, metric, req); + return ApiResponses.ok(res, msg("iot.threshold_upsert")); + } + + @DeleteMapping("/{metric}") + @PreAuthorize("hasRole('ADMIN')") + public ApiResponse deleteHouse(@PathVariable UUID houseId, @PathVariable String metric) { + thresholdService.deleteHouseLevel(houseId, metric); + return ApiResponses.noContent(); + } +} diff --git a/src/main/java/com/isums/assetservice/controllers/IotNodeProvisionController.java b/src/main/java/com/isums/assetservice/controllers/IotNodeProvisionController.java index 0aa7b79..abbc356 100644 --- a/src/main/java/com/isums/assetservice/controllers/IotNodeProvisionController.java +++ b/src/main/java/com/isums/assetservice/controllers/IotNodeProvisionController.java @@ -1,33 +1,43 @@ -package com.isums.assetservice.controllers; - -import com.isums.assetservice.domains.dtos.ApiResponse; -import com.isums.assetservice.domains.dtos.ApiResponses; -import com.isums.assetservice.domains.dtos.IotNodeTokenRequest; -import com.isums.assetservice.domains.dtos.IotNodeTokenResponse; -import com.isums.assetservice.infrastructures.abstracts.IotNodeTokenService; -import lombok.RequiredArgsConstructor; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import java.time.Instant; - -@RestController -@RequestMapping("/api/assets/iot") -@RequiredArgsConstructor -public class IotNodeProvisionController { - - private final IotNodeTokenService iotNodeTokenService; - - @PostMapping("/provision-token") - @PreAuthorize("hasAnyRole('LANDLORD','ADMIN')") - public ApiResponse getProvisionToken(@RequestBody IotNodeTokenRequest req) { - - String token = iotNodeTokenService.generateToken(req.serial()); - long expiresAt = Instant.now().plusSeconds(600).toEpochMilli(); - - return ApiResponses.ok(new IotNodeTokenResponse(token, expiresAt), "Token generated, valid for 10 minutes"); - } -} +package com.isums.assetservice.controllers; + +import com.isums.assetservice.domains.dtos.ApiResponse; +import com.isums.assetservice.domains.dtos.ApiResponses; +import com.isums.assetservice.domains.dtos.IotNodeTokenRequest; +import com.isums.assetservice.domains.dtos.IotNodeTokenResponse; +import com.isums.assetservice.infrastructures.abstracts.IotNodeTokenService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.context.NoSuchMessageException; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Instant; + +@RestController +@RequestMapping("/api/assets/iot") +@RequiredArgsConstructor +public class IotNodeProvisionController { + + private final IotNodeTokenService iotNodeTokenService; + private final MessageSource messageSource; + + private String msg(String code) { + try { + return messageSource.getMessage(code, null, LocaleContextHolder.getLocale()); + } catch (NoSuchMessageException e) { + return code; + } + } + + @PostMapping("/provision-token") + @PreAuthorize("hasAnyRole('LANDLORD','ADMIN')") + public ApiResponse getProvisionToken(@RequestBody IotNodeTokenRequest req) { + String token = iotNodeTokenService.generateToken(req.serial()); + long expiresAt = Instant.now().plusSeconds(600).toEpochMilli(); + return ApiResponses.ok(new IotNodeTokenResponse(token, expiresAt), msg("iot.token_generated")); + } +} diff --git a/src/main/java/com/isums/assetservice/controllers/IotProvisioningController.java b/src/main/java/com/isums/assetservice/controllers/IotProvisioningController.java index b8e7e78..b997d72 100644 --- a/src/main/java/com/isums/assetservice/controllers/IotProvisioningController.java +++ b/src/main/java/com/isums/assetservice/controllers/IotProvisioningController.java @@ -1,105 +1,120 @@ -package com.isums.assetservice.controllers; - -import com.isums.assetservice.domains.dtos.IotProvisionResponse; -import com.isums.assetservice.domains.dtos.*; -import com.isums.assetservice.domains.enums.Severity; -import com.isums.assetservice.infrastructures.abstracts.IotProvisioningService; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; - -import java.util.UUID; - -@RestController -@RequestMapping("/api/assets/houses/{houseId}/iot") -@RequiredArgsConstructor -public class IotProvisioningController { - - private final IotProvisioningService iotProvisioningService; - - @PostMapping("/provision") - @PreAuthorize("hasRole('LANDLORD') or hasRole('MANAGER')") - public ApiResponse provision(@PathVariable UUID houseId, @RequestBody IotProvisionRequest req) { - var res = iotProvisioningService.provisionController(houseId, req.areaId(), req.deviceId()); - return ApiResponses.created(res, "IoT controller provisioned successfully"); - } - - @DeleteMapping("/deprovision") - @PreAuthorize("hasRole('LANDLORD') or hasRole('MANAGER')") - public ApiResponse deprovision(@PathVariable UUID houseId) { - iotProvisioningService.deprovisionController(houseId); - return ApiResponses.ok(null, "IoT controller deprovisioned successfully"); - } - - @GetMapping("/controller") - public ApiResponse getController(@PathVariable UUID houseId) { - var res = iotProvisioningService.getControllerByHouse(houseId); - return ApiResponses.ok(res, "IoT controller retrieved successfully"); - } - - @PutMapping("/nodes/{thing}/assign-area") - @PreAuthorize("hasAnyRole('LANDLORD','ADMIN')") - public ApiResponse assignArea(@PathVariable UUID houseId, @PathVariable String thing, @RequestBody AssignAreaRequest req) { - iotProvisioningService.assignNodeToArea(thing, req.areaId()); - return ApiResponses.noContent(); - } - - @PostMapping("/provision-node") - @PreAuthorize("hasAnyRole('LANDLORD','ADMIN')") - public ApiResponse provisionNode(@PathVariable UUID houseId, @RequestBody ProvisionNodeRequest req) { - var res = iotProvisioningService.provisionNode(houseId, req.serial(), req.token(), req.areaId()); - return ApiResponses.ok(res, "Node provisioned"); - } - - @PutMapping("/nodes/{thing}/capabilities") - @PreAuthorize("hasAnyRole('LANDLORD','ADMIN')") - public ApiResponse updateCapabilities(@PathVariable UUID houseId, @PathVariable String thing, @RequestBody @Valid UpdateCapabilitiesRequest req) { - iotProvisioningService.updateNodeCapabilities(thing, req.capabilities()); - return ApiResponses.noContent(); - } - - @GetMapping("/alerts") - @PreAuthorize("hasAnyRole('LANDLORD','ADMIN','TENANT')") - public ApiResponse> getAlerts(@PathVariable UUID houseId, @RequestParam(defaultValue = "10") int limit, @RequestParam(required = false) String cursor, - @RequestParam(required = false) String date, @RequestParam(required = false) Severity level) { - var res = iotProvisioningService.getAlerts(houseId, limit, cursor, date, level); - return ApiResponses.ok(res, "Alerts retrieved"); - } - - @PostMapping("/cmd") - @PreAuthorize("hasAnyRole('LANDLORD','MANAGER','TENANT')") - public ApiResponse sendCommand( - @PathVariable UUID houseId, - @RequestBody IotCommandRequest req) { - iotProvisioningService.sendCommand(houseId, req); - return ApiResponses.ok(null, "Command sent"); - } - - @GetMapping("/ota/upload-url") - @PreAuthorize("hasAnyRole('LANDLORD','MANAGER')") - public ApiResponse getOtaUploadUrl( - @PathVariable UUID houseId, - @RequestParam String filename) { - var res = iotProvisioningService.getOtaUploadUrl(houseId, filename); - return ApiResponses.ok(res, "Upload URL generated"); - } - - @PostMapping("/ota") - @PreAuthorize("hasAnyRole('LANDLORD','MANAGER')") - public ApiResponse triggerOta( - @PathVariable UUID houseId, - @RequestBody OtaRequest req) { - var res = iotProvisioningService.triggerOta(houseId, req); - return ApiResponses.ok(res, "OTA triggered"); - } - - @GetMapping("/ota/{jobId}") - @PreAuthorize("hasAnyRole('LANDLORD','MANAGER')") - public ApiResponse getOtaStatus( - @PathVariable UUID houseId, - @PathVariable String jobId) { - var res = iotProvisioningService.getOtaStatus(houseId, jobId); - return ApiResponses.ok(res, "OTA status"); - } -} +package com.isums.assetservice.controllers; + +import com.isums.assetservice.domains.dtos.IotProvisionResponse; +import com.isums.assetservice.domains.dtos.*; +import com.isums.assetservice.domains.enums.Severity; +import com.isums.assetservice.infrastructures.abstracts.IotProvisioningService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.context.NoSuchMessageException; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.Set; +import java.util.UUID; + +@RestController +@RequestMapping("/api/assets/houses/{houseId}/iot") +@RequiredArgsConstructor +public class IotProvisioningController { + + private final IotProvisioningService iotProvisioningService; + private final MessageSource messageSource; + + private String msg(String code) { + try { + return messageSource.getMessage(code, null, LocaleContextHolder.getLocale()); + } catch (NoSuchMessageException e) { + return code; + } + } + + @PostMapping("/provision") + @PreAuthorize("hasRole('LANDLORD') or hasRole('MANAGER')") + public ApiResponse provision(@PathVariable UUID houseId, @RequestBody IotProvisionRequest req) { + var res = iotProvisioningService.provisionController(houseId, req.areaId(), req.deviceId()); + return ApiResponses.created(res, msg("iot.controller_provisioned")); + } + + @DeleteMapping("/deprovision") + @PreAuthorize("hasRole('LANDLORD') or hasRole('MANAGER')") + public ApiResponse deprovision(@PathVariable UUID houseId) { + iotProvisioningService.deprovisionController(houseId); + return ApiResponses.ok(null, msg("iot.controller_deprovisioned")); + } + + @GetMapping("/controller") + public ApiResponse getController(@PathVariable UUID houseId) { + var res = iotProvisioningService.getControllerByHouse(houseId); + return ApiResponses.ok(res, msg("iot.controller_retrieved")); + } + + @PutMapping("/nodes/{thing}/assign-area") + @PreAuthorize("hasAnyRole('LANDLORD','ADMIN')") + public ApiResponse assignArea(@PathVariable UUID houseId, @PathVariable String thing, @RequestBody AssignAreaRequest req) { + iotProvisioningService.assignNodeToArea(thing, req.areaId()); + return ApiResponses.noContent(); + } + + @PostMapping("/provision-node") + @PreAuthorize("hasAnyRole('LANDLORD','ADMIN')") + public ApiResponse provisionNode(@PathVariable UUID houseId, @RequestBody ProvisionNodeRequest req) { + var res = iotProvisioningService.provisionNode( + houseId, req.serial(), req.token(), req.areaId(), + req.capabilities() != null ? req.capabilities() : Set.of()); + return ApiResponses.ok(res, msg("iot.node_provisioned")); + } + + @PutMapping("/nodes/{thing}/capabilities") + @PreAuthorize("hasAnyRole('LANDLORD','ADMIN')") + public ApiResponse updateCapabilities(@PathVariable UUID houseId, @PathVariable String thing, @RequestBody @Valid UpdateCapabilitiesRequest req) { + iotProvisioningService.updateNodeCapabilities(thing, req.capabilities()); + return ApiResponses.noContent(); + } + + @GetMapping("/alerts") + @PreAuthorize("hasAnyRole('LANDLORD','ADMIN','TENANT')") + public ApiResponse> getAlerts(@PathVariable UUID houseId, @RequestParam(defaultValue = "10") int limit, @RequestParam(required = false) String cursor, + @RequestParam(required = false) String date, @RequestParam(required = false) Severity level) { + var res = iotProvisioningService.getAlerts(houseId, limit, cursor, date, level); + return ApiResponses.ok(res, msg("iot.alerts_retrieved")); + } + + @PostMapping("/cmd") + @PreAuthorize("hasAnyRole('LANDLORD','MANAGER','TENANT')") + public ApiResponse sendCommand( + @PathVariable UUID houseId, + @RequestBody IotCommandRequest req) { + iotProvisioningService.sendCommand(houseId, req); + return ApiResponses.ok(null, msg("iot.command_sent")); + } + + @GetMapping("/ota/upload-url") + @PreAuthorize("hasAnyRole('LANDLORD','MANAGER')") + public ApiResponse getOtaUploadUrl( + @PathVariable UUID houseId, + @RequestParam String filename) { + var res = iotProvisioningService.getOtaUploadUrl(houseId, filename); + return ApiResponses.ok(res, msg("iot.upload_url_generated")); + } + + @PostMapping("/ota") + @PreAuthorize("hasAnyRole('LANDLORD','MANAGER')") + public ApiResponse triggerOta( + @PathVariable UUID houseId, + @RequestBody OtaRequest req) { + var res = iotProvisioningService.triggerOta(houseId, req); + return ApiResponses.ok(res, msg("iot.ota_triggered")); + } + + @GetMapping("/ota/{jobId}") + @PreAuthorize("hasAnyRole('LANDLORD','MANAGER')") + public ApiResponse getOtaStatus( + @PathVariable UUID houseId, + @PathVariable String jobId) { + var res = iotProvisioningService.getOtaStatus(houseId, jobId); + return ApiResponses.ok(res, msg("iot.ota_status")); + } +} diff --git a/src/main/java/com/isums/assetservice/controllers/IotSafetyConfigController.java b/src/main/java/com/isums/assetservice/controllers/IotSafetyConfigController.java new file mode 100644 index 0000000..4b0890d --- /dev/null +++ b/src/main/java/com/isums/assetservice/controllers/IotSafetyConfigController.java @@ -0,0 +1,88 @@ +package com.isums.assetservice.controllers; + +import com.isums.assetservice.domains.dtos.ApiResponse; +import com.isums.assetservice.domains.dtos.ApiResponses; +import com.isums.assetservice.domains.dtos.CreateIotSafetyConfigRequest; +import com.isums.assetservice.domains.dtos.IotSafetyConfigDto; +import com.isums.assetservice.domains.dtos.IotSafetyConfigVersionDto; +import com.isums.assetservice.services.IotSafetyConfigService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.UUID; + +@Tag(name = "IoT Safety Config", description = "Versioned indoor-safety scoring config (DB-backed, immutable history)") +@RestController +@RequestMapping("/api/assets/iot/safety-config") +@RequiredArgsConstructor +public class IotSafetyConfigController { + + private final IotSafetyConfigService service; + + @Operation( + summary = "Get current active safety config", + description = "Returns sensor profile, thresholds, score formula, capability gaps with citations. " + + "Mobile renders this without hardcoding any number; cached server-side via @Cacheable." + ) + @GetMapping + public ApiResponse getCurrent() { + return ApiResponses.ok(service.getCurrent(), "Success"); + } + + @Operation( + summary = "Safety config version history (admin)", + description = "All versions newest first; row with expiredAt=null is active." + ) + @GetMapping("/history") + @PreAuthorize("hasAnyRole('LANDLORD','MANAGER')") + public ApiResponse> history() { + return ApiResponses.ok(service.getHistory(), "Success"); + } + + @Operation( + summary = "Publish new safety config version (LANDLORD)", + description = "Creates immutable new version; auto-expires previously active row. " + + "Validates: scoreComponents weights must sum ≈ 1.0, thresholds[] and bands[] required." + ) + @PostMapping + @PreAuthorize("hasRole('LANDLORD')") + public ApiResponse create( + @AuthenticationPrincipal Jwt jwt, + @RequestBody @Valid CreateIotSafetyConfigRequest request) { + return ApiResponses.created( + service.createVersion(actorId(jwt), request), + "Safety config version published"); + } + + @Operation(summary = "Manually expire a version (LANDLORD)") + @PatchMapping("/{id}/expire") + @PreAuthorize("hasRole('LANDLORD')") + public ApiResponse expire( + @AuthenticationPrincipal Jwt jwt, + @PathVariable UUID id) { + return ApiResponses.ok( + service.expireVersion(id, actorId(jwt)), + "Safety config version expired"); + } + + private UUID actorId(Jwt jwt) { + try { + return UUID.fromString(jwt.getSubject()); + } catch (Exception ex) { + throw new IllegalStateException("Invalid JWT subject"); + } + } +} diff --git a/src/main/java/com/isums/assetservice/controllers/UtilityAlertsController.java b/src/main/java/com/isums/assetservice/controllers/UtilityAlertsController.java new file mode 100644 index 0000000..e68dcfa --- /dev/null +++ b/src/main/java/com/isums/assetservice/controllers/UtilityAlertsController.java @@ -0,0 +1,69 @@ +package com.isums.assetservice.controllers; + +import com.isums.assetservice.domains.dtos.ApiResponse; +import com.isums.assetservice.domains.dtos.ApiResponses; +import com.isums.assetservice.domains.dtos.UtilityAlertsResponse; +import com.isums.assetservice.domains.enums.UtilityMetric; +import com.isums.assetservice.infrastructures.abstracts.UtilityAlertsService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.context.NoSuchMessageException; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +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; +import org.springframework.web.server.ResponseStatusException; + +/** + * Utility alerts dashboard endpoint. + * + *

Path: {@code GET /api/assets/utility-alerts} + * + *

Access: LANDLORD sees their houses; MANAGER sees houses + * in their regions; ADMIN treated as LANDLORD for now (cross-tenant + * admin view TBD). TENANT and TECH_STAFF are rejected at the + * {@code @PreAuthorize} gate — TENANT has a different single-house + * endpoint; TECH_STAFF uses the job board, not the alerts fleet view. + * + *

The caller's identity comes from the JWT {@code sub} (Keycloak + * id); the service-layer maps it to the internal user id + roles via + * user-service gRPC. We don't trust the JWT roles claim for the scope + * filter — they're only trusted for the coarse role gate here, and + * the actual house ownership is resolved server-side from the DB. + */ +@RestController +@RequestMapping("/api/assets/utility-alerts") +@RequiredArgsConstructor +public class UtilityAlertsController { + + private final UtilityAlertsService utilityAlertsService; + private final MessageSource messageSource; + + private String msg(String code) { + try { + return messageSource.getMessage(code, null, LocaleContextHolder.getLocale()); + } catch (NoSuchMessageException e) { + return code; + } + } + + @GetMapping + @PreAuthorize("hasAnyRole('LANDLORD','MANAGER','ADMIN')") + public ApiResponse listAlerts( + @AuthenticationPrincipal Jwt jwt, + @RequestParam(name = "metric", defaultValue = "electricity") String metricParam + ) { + UtilityMetric metric = UtilityMetric.fromRequestParam(metricParam) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "metric must be one of: electricity, water")); + + UtilityAlertsResponse res = utilityAlertsService.listAlerts(jwt.getSubject(), metric); + return ApiResponses.ok(res, msg("utility_alerts.retrieved")); + } +} diff --git a/src/main/java/com/isums/assetservice/domains/dtos/AppAccessChangedEvent.java b/src/main/java/com/isums/assetservice/domains/dtos/AppAccessChangedEvent.java new file mode 100644 index 0000000..71f9f71 --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/dtos/AppAccessChangedEvent.java @@ -0,0 +1,12 @@ +package com.isums.assetservice.domains.dtos; + +import java.util.UUID; + +public record AppAccessChangedEvent( + UUID contractId, + UUID houseId, + UUID tenantId, + boolean restricted, + String reason, + String messageId +) {} \ No newline at end of file diff --git a/src/main/java/com/isums/assetservice/domains/dtos/AreaPowerStateResponse.java b/src/main/java/com/isums/assetservice/domains/dtos/AreaPowerStateResponse.java new file mode 100644 index 0000000..728d52f --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/dtos/AreaPowerStateResponse.java @@ -0,0 +1,17 @@ +package com.isums.assetservice.domains.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.isums.assetservice.domains.enums.PowerCutReason; + +import java.time.Instant; +import java.util.UUID; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record AreaPowerStateResponse( + UUID houseId, + UUID areaId, + boolean powered, + PowerCutReason cutReason, + String message, + Instant changedAt +) {} \ No newline at end of file diff --git a/src/main/java/com/isums/assetservice/domains/dtos/AreaPowerToggleRequest.java b/src/main/java/com/isums/assetservice/domains/dtos/AreaPowerToggleRequest.java new file mode 100644 index 0000000..7f958fd --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/dtos/AreaPowerToggleRequest.java @@ -0,0 +1,9 @@ +package com.isums.assetservice.domains.dtos; + +import com.isums.assetservice.domains.enums.PowerAction; +import jakarta.validation.constraints.NotNull; + +public record AreaPowerToggleRequest( + @NotNull(message = "action is required (ON or OFF)") + PowerAction action +) {} \ No newline at end of file diff --git a/src/main/java/com/isums/assetservice/domains/dtos/AssetCategoryDTO/AssetCategoryDto.java b/src/main/java/com/isums/assetservice/domains/dtos/AssetCategoryDTO/AssetCategoryDto.java index ab4f407..8b2de95 100644 --- a/src/main/java/com/isums/assetservice/domains/dtos/AssetCategoryDTO/AssetCategoryDto.java +++ b/src/main/java/com/isums/assetservice/domains/dtos/AssetCategoryDTO/AssetCategoryDto.java @@ -1,10 +1,10 @@ package com.isums.assetservice.domains.dtos.AssetCategoryDTO; -import jakarta.persistence.Column; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import java.util.Map; import java.util.UUID; @Data @@ -12,8 +12,14 @@ @NoArgsConstructor public class AssetCategoryDto { private UUID id; + /** Resolved name for the request locale. */ private String name; + /** Full name translations — all locales (for edit forms). */ + private Map nameTranslations; private int compensationPercent; + /** Resolved description for the request locale. */ private String description; + /** Full description translations — all locales (for edit forms). */ + private Map descriptionTranslations; private String detectionType; } diff --git a/src/main/java/com/isums/assetservice/domains/dtos/AssetCategoryDTO/CreateAssetCategoryRequest.java b/src/main/java/com/isums/assetservice/domains/dtos/AssetCategoryDTO/CreateAssetCategoryRequest.java index a38eca1..9cce2d2 100644 --- a/src/main/java/com/isums/assetservice/domains/dtos/AssetCategoryDTO/CreateAssetCategoryRequest.java +++ b/src/main/java/com/isums/assetservice/domains/dtos/AssetCategoryDTO/CreateAssetCategoryRequest.java @@ -1,12 +1,11 @@ package com.isums.assetservice.domains.dtos.AssetCategoryDTO; -import jakarta.persistence.Column; +import java.util.Map; -import java.util.UUID; +public record CreateAssetCategoryRequest( + Map name, + int compensationPercent, + Map description +) { +} -public record CreateAssetCategoryRequest ( - String name, - int compensationPercent, - String description -){ - } diff --git a/src/main/java/com/isums/assetservice/domains/dtos/AssetCategoryDTO/UpdateAssetCategoryRequest.java b/src/main/java/com/isums/assetservice/domains/dtos/AssetCategoryDTO/UpdateAssetCategoryRequest.java index aefb267..d0ed414 100644 --- a/src/main/java/com/isums/assetservice/domains/dtos/AssetCategoryDTO/UpdateAssetCategoryRequest.java +++ b/src/main/java/com/isums/assetservice/domains/dtos/AssetCategoryDTO/UpdateAssetCategoryRequest.java @@ -1,7 +1,13 @@ package com.isums.assetservice.domains.dtos.AssetCategoryDTO; +import java.util.Map; + +/** + * Null fields are ignored (existing value kept). + * Non-null {@code name}/{@code description} maps fully replace existing translations. + */ public record UpdateAssetCategoryRequest( - String name, + Map name, Integer compensationPercent, - String description + Map description ) {} diff --git a/src/main/java/com/isums/assetservice/domains/dtos/AssetCountByFunctionAreaDto.java b/src/main/java/com/isums/assetservice/domains/dtos/AssetCountByFunctionAreaDto.java new file mode 100644 index 0000000..3bf125a --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/dtos/AssetCountByFunctionAreaDto.java @@ -0,0 +1,9 @@ +package com.isums.assetservice.domains.dtos; + +import java.util.UUID; + +public record AssetCountByFunctionAreaDto( + UUID functionAreaId, + long assetCount +) { +} diff --git a/src/main/java/com/isums/assetservice/domains/dtos/AssetEventDTO/AssetEventDto.java b/src/main/java/com/isums/assetservice/domains/dtos/AssetEventDTO/AssetEventDto.java index 6133905..155c9b6 100644 --- a/src/main/java/com/isums/assetservice/domains/dtos/AssetEventDTO/AssetEventDto.java +++ b/src/main/java/com/isums/assetservice/domains/dtos/AssetEventDTO/AssetEventDto.java @@ -1,11 +1,15 @@ package com.isums.assetservice.domains.dtos.AssetEventDTO; +import com.isums.assetservice.domains.dtos.AssetEventImageDto; +import com.isums.assetservice.domains.dtos.AssetImageDto; +import com.isums.assetservice.domains.entities.AssetEventImage; import com.isums.assetservice.domains.enums.AssetEventType; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import java.time.Instant; +import java.util.List; import java.util.UUID; @Data @@ -13,10 +17,15 @@ @NoArgsConstructor public class AssetEventDto { private UUID id; + private UUID jobId; private AssetEventType eventType; - private String description; + private Integer previousCondition; + private Integer currentCondition; + private String note; private Instant createdAt; private Instant updatedAt; - private UUID createBy; private UUID assetId; + private String assetName; + private List oldImages; + private List images; } diff --git a/src/main/java/com/isums/assetservice/domains/dtos/AssetEventImageDto.java b/src/main/java/com/isums/assetservice/domains/dtos/AssetEventImageDto.java new file mode 100644 index 0000000..ca1c809 --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/dtos/AssetEventImageDto.java @@ -0,0 +1,11 @@ +package com.isums.assetservice.domains.dtos; + +import java.time.Instant; +import java.util.UUID; + +public record AssetEventImageDto( + UUID id, + String url, + Instant createdAt +) { +} diff --git a/src/main/java/com/isums/assetservice/domains/dtos/AssetImageDTO/AssetImageDto.java b/src/main/java/com/isums/assetservice/domains/dtos/AssetImageDTO/AssetImageDto.java deleted file mode 100644 index 957fefa..0000000 --- a/src/main/java/com/isums/assetservice/domains/dtos/AssetImageDTO/AssetImageDto.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.isums.assetservice.domains.dtos.AssetImageDTO; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.time.Instant; -import java.util.UUID; - -@Data -@NoArgsConstructor -@AllArgsConstructor -public class AssetImageDto { - private UUID id; - private UUID assetId; - private String imageUrl; - private String note; - private Instant createdAt; -} diff --git a/src/main/java/com/isums/assetservice/domains/dtos/AssetImageDTO/CreateAssetImageRequest.java b/src/main/java/com/isums/assetservice/domains/dtos/AssetImageDTO/CreateAssetImageRequest.java deleted file mode 100644 index 1d78902..0000000 --- a/src/main/java/com/isums/assetservice/domains/dtos/AssetImageDTO/CreateAssetImageRequest.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.isums.assetservice.domains.dtos.AssetImageDTO; - -import java.time.Instant; -import java.util.UUID; - -public record CreateAssetImageRequest( - UUID assetId, - String imageUrl, - String note -) { -} diff --git a/src/main/java/com/isums/assetservice/domains/dtos/AssetItemDTO/AssetItemDto.java b/src/main/java/com/isums/assetservice/domains/dtos/AssetItemDTO/AssetItemDto.java index 5486610..504fbe2 100644 --- a/src/main/java/com/isums/assetservice/domains/dtos/AssetItemDTO/AssetItemDto.java +++ b/src/main/java/com/isums/assetservice/domains/dtos/AssetItemDTO/AssetItemDto.java @@ -1,16 +1,17 @@ package com.isums.assetservice.domains.dtos.AssetItemDTO; +import com.isums.assetservice.domains.dtos.AssetCategoryDTO.AssetCategoryDto; import com.isums.assetservice.domains.dtos.AssetEventDTO.AssetEventDto; -import com.isums.assetservice.domains.dtos.AssetImageDTO.AssetImageDto; -import com.isums.assetservice.domains.entities.AssetCategory; +import com.isums.assetservice.domains.dtos.AssetImageDto; +import com.isums.assetservice.domains.dtos.AssetTagDto.AssetTagDto; import com.isums.assetservice.domains.enums.AssetStatus; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; -import org.hibernate.annotations.UpdateTimestamp; import java.time.Instant; import java.util.List; +import java.util.Map; import java.util.UUID; @Data @@ -21,14 +22,16 @@ public class AssetItemDto { private UUID functionAreaId; private UUID houseId; private UUID categoryId; + private AssetCategoryDto category; + /** Resolved display name for the request locale (use in list/detail views). */ private String displayName; + /** Full translation map all locales (use in edit forms). */ + private Map translations; private String serialNumber; private int conditionPercent; private AssetStatus status; private String note; private Instant updateAt; - private String nfcTag; - private String qrTag; -// private List images; -// private List events; + private List tags; + private List images; } diff --git a/src/main/java/com/isums/assetservice/domains/dtos/AssetItemDTO/CreateAssetItemRequest.java b/src/main/java/com/isums/assetservice/domains/dtos/AssetItemDTO/CreateAssetItemRequest.java index f968001..e695fc0 100644 --- a/src/main/java/com/isums/assetservice/domains/dtos/AssetItemDTO/CreateAssetItemRequest.java +++ b/src/main/java/com/isums/assetservice/domains/dtos/AssetItemDTO/CreateAssetItemRequest.java @@ -1,18 +1,38 @@ package com.isums.assetservice.domains.dtos.AssetItemDTO; import com.isums.assetservice.domains.enums.AssetStatus; +import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; +import java.util.Map; import java.util.UUID; -public record CreateAssetItemRequest ( +/** + * {@code displayName} is a multilingual map, e.g. {@code {"vi":"Ban","en":"Table"}}. + * Supply at least one entry; "vi" is the default fallback. + */ +public record CreateAssetItemRequest( + @Schema(description = "ID nha chua asset", example = "70279423-989d-48dc-8f2e-9bd6508a6f4a") UUID houseId, + @Schema(description = "ID khu vuc trong nha chua asset", example = "d7eb14d0-110e-4534-898b-d7d680cff898") UUID functionAreaId, + @Schema(description = "ID category cua asset", example = "f1eaaa88-1e36-4454-b345-91cd6a06a6c1") UUID categoryId, - String displayName, + @Schema( + description = "Ten asset dang map da ngon ngu. Toi thieu nen co khoa vi. Backend se auto-fill them translation neu thieu.", + example = "{\"vi\":\"TV phong khach\"}" + ) + Map displayName, + @Schema(description = "Serial number hoac ma quan ly noi bo cua asset", example = "TV-SS-003") String serialNumber, + @Schema(description = "Tinh trang phan tram ban dau cua asset", example = "80") int conditionPercent, + @Schema(description = "Trang thai asset khi tao", example = "IN_USE") AssetStatus status, + @Schema( + description = "Danh sach string anh/metadata cu. Voi flow Postman hien tai nen de [] va upload anh qua API /api/assets/items/{assetId}/images sau khi create.", + example = "[]" + ) List assetImages -){ +) { } diff --git a/src/main/java/com/isums/assetservice/domains/dtos/AssetItemDTO/UpdateAssetItemRequest.java b/src/main/java/com/isums/assetservice/domains/dtos/AssetItemDTO/UpdateAssetItemRequest.java index e98e31d..e2a2753 100644 --- a/src/main/java/com/isums/assetservice/domains/dtos/AssetItemDTO/UpdateAssetItemRequest.java +++ b/src/main/java/com/isums/assetservice/domains/dtos/AssetItemDTO/UpdateAssetItemRequest.java @@ -2,11 +2,12 @@ import com.isums.assetservice.domains.enums.AssetStatus; +import java.util.Map; import java.util.UUID; public record UpdateAssetItemRequest( UUID functionAreaId, - String displayName, + Map displayName, String serialNumber, String nfcId, Integer conditionPercent, diff --git a/src/main/java/com/isums/assetservice/domains/dtos/BatchUpdateAssetRequest.java b/src/main/java/com/isums/assetservice/domains/dtos/BatchUpdateAssetRequest.java index 176eec0..a8fc76e 100644 --- a/src/main/java/com/isums/assetservice/domains/dtos/BatchUpdateAssetRequest.java +++ b/src/main/java/com/isums/assetservice/domains/dtos/BatchUpdateAssetRequest.java @@ -1,17 +1,20 @@ package com.isums.assetservice.domains.dtos; +import com.isums.assetservice.domains.enums.AssetStatus; +import lombok.Data; +import org.springframework.web.multipart.MultipartFile; + import java.util.List; import java.util.UUID; public record BatchUpdateAssetRequest( UUID jobId, List updates - ) { public record AssetUpdateItem( UUID assetId, Integer conditionPercent, String note, - List assetImages + AssetStatus status ) {} -} +} \ No newline at end of file diff --git a/src/main/java/com/isums/assetservice/domains/dtos/BatchUpdateResponse.java b/src/main/java/com/isums/assetservice/domains/dtos/BatchUpdateResponse.java index 56d5129..443117b 100644 --- a/src/main/java/com/isums/assetservice/domains/dtos/BatchUpdateResponse.java +++ b/src/main/java/com/isums/assetservice/domains/dtos/BatchUpdateResponse.java @@ -1,12 +1,19 @@ package com.isums.assetservice.domains.dtos; +import com.isums.assetservice.domains.dtos.AssetEventDTO.AssetEventDto; import com.isums.assetservice.domains.dtos.AssetItemDTO.AssetItemDto; import java.util.List; +import java.util.Map; +import java.util.UUID; public record BatchUpdateResponse( int total, int success, - List assets + List events ) { -} + public record EventResult( + UUID assetId, + UUID eventId + ) {} +} \ No newline at end of file diff --git a/src/main/java/com/isums/assetservice/domains/dtos/ConfirmAssetRequest.java b/src/main/java/com/isums/assetservice/domains/dtos/ConfirmAssetRequest.java new file mode 100644 index 0000000..ae7fd0a --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/dtos/ConfirmAssetRequest.java @@ -0,0 +1,8 @@ +package com.isums.assetservice.domains.dtos; + +import com.isums.assetservice.domains.enums.AssetStatus; + +public record ConfirmAssetRequest( + AssetStatus status +) { +} diff --git a/src/main/java/com/isums/assetservice/domains/dtos/CreateIotSafetyConfigRequest.java b/src/main/java/com/isums/assetservice/domains/dtos/CreateIotSafetyConfigRequest.java new file mode 100644 index 0000000..9a946c8 --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/dtos/CreateIotSafetyConfigRequest.java @@ -0,0 +1,11 @@ +package com.isums.assetservice.domains.dtos; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import com.fasterxml.jackson.databind.JsonNode; + +public record CreateIotSafetyConfigRequest( + @NotBlank @Size(max = 80) String version, + JsonNode configJson, + @Size(max = 1000) String notes +) {} diff --git a/src/main/java/com/isums/assetservice/domains/dtos/ForecastTriggerResultDto.java b/src/main/java/com/isums/assetservice/domains/dtos/ForecastTriggerResultDto.java new file mode 100644 index 0000000..d07a6c8 --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/dtos/ForecastTriggerResultDto.java @@ -0,0 +1,9 @@ +package com.isums.assetservice.domains.dtos; + +public record ForecastTriggerResultDto( + boolean ok, + int created, + int skipped, + int total +) { +} diff --git a/src/main/java/com/isums/assetservice/domains/dtos/IotSafetyBandDto.java b/src/main/java/com/isums/assetservice/domains/dtos/IotSafetyBandDto.java new file mode 100644 index 0000000..f540983 --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/dtos/IotSafetyBandDto.java @@ -0,0 +1,9 @@ +package com.isums.assetservice.domains.dtos; + +public record IotSafetyBandDto( + String code, + String label, + String description, + Integer scoreMax, + String severity +) {} diff --git a/src/main/java/com/isums/assetservice/domains/dtos/IotSafetyCapabilityGapDto.java b/src/main/java/com/isums/assetservice/domains/dtos/IotSafetyCapabilityGapDto.java new file mode 100644 index 0000000..7468643 --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/dtos/IotSafetyCapabilityGapDto.java @@ -0,0 +1,10 @@ +package com.isums.assetservice.domains.dtos; + +public record IotSafetyCapabilityGapDto( + String metric, + String displayName, + String description, + String requiredSensor, + Long sensorPriceVndApprox, + String standardRef +) {} diff --git a/src/main/java/com/isums/assetservice/domains/dtos/IotSafetyConfigDto.java b/src/main/java/com/isums/assetservice/domains/dtos/IotSafetyConfigDto.java new file mode 100644 index 0000000..ac74d96 --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/dtos/IotSafetyConfigDto.java @@ -0,0 +1,17 @@ +package com.isums.assetservice.domains.dtos; + +import java.util.List; + +public record IotSafetyConfigDto( + List activeSensors, + List capabilityGaps, + List thresholds, + List scoreComponents, + List bands, + String disclaimer, + String scoreFormulaDescription, + String standardsApplied, + String version, + String effectiveFrom, + String notes +) {} diff --git a/src/main/java/com/isums/assetservice/domains/dtos/IotSafetyConfigVersionDto.java b/src/main/java/com/isums/assetservice/domains/dtos/IotSafetyConfigVersionDto.java new file mode 100644 index 0000000..1d04ebd --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/dtos/IotSafetyConfigVersionDto.java @@ -0,0 +1,17 @@ +package com.isums.assetservice.domains.dtos; + +import java.time.Instant; +import java.util.UUID; + +public record IotSafetyConfigVersionDto( + UUID id, + String version, + IotSafetyConfigDto config, + Instant effectiveFrom, + Instant expiredAt, + String notes, + UUID createdBy, + Instant createdAt, + UUID expiredBy, + boolean isActive +) {} diff --git a/src/main/java/com/isums/assetservice/domains/dtos/IotSafetyScoreComponentDto.java b/src/main/java/com/isums/assetservice/domains/dtos/IotSafetyScoreComponentDto.java new file mode 100644 index 0000000..66d601f --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/dtos/IotSafetyScoreComponentDto.java @@ -0,0 +1,8 @@ +package com.isums.assetservice.domains.dtos; + +public record IotSafetyScoreComponentDto( + String metric, + Double weight, + String normalizationStrategy, + Double normalizationParam +) {} diff --git a/src/main/java/com/isums/assetservice/domains/dtos/IotSafetySensorDto.java b/src/main/java/com/isums/assetservice/domains/dtos/IotSafetySensorDto.java new file mode 100644 index 0000000..ac7476a --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/dtos/IotSafetySensorDto.java @@ -0,0 +1,13 @@ +package com.isums.assetservice.domains.dtos; + +import java.util.List; + +public record IotSafetySensorDto( + String code, + String displayName, + String model, + List measures, + String accuracyNotes, + String calibrationStatus, + String datasheet +) {} diff --git a/src/main/java/com/isums/assetservice/domains/dtos/IotSafetyThresholdDto.java b/src/main/java/com/isums/assetservice/domains/dtos/IotSafetyThresholdDto.java new file mode 100644 index 0000000..00058c6 --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/dtos/IotSafetyThresholdDto.java @@ -0,0 +1,14 @@ +package com.isums.assetservice.domains.dtos; + +public record IotSafetyThresholdDto( + String metric, + String displayName, + String unit, + Double comfortMin, + Double comfortMax, + Double warningMin, + Double warningMax, + Double criticalThreshold, + Boolean criticalIsHigh, + String standardRef +) {} diff --git a/src/main/java/com/isums/assetservice/domains/dtos/PowerCutConfirmedEvent.java b/src/main/java/com/isums/assetservice/domains/dtos/PowerCutConfirmedEvent.java new file mode 100644 index 0000000..1acf828 --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/dtos/PowerCutConfirmedEvent.java @@ -0,0 +1,20 @@ +package com.isums.assetservice.domains.dtos; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class PowerCutConfirmedEvent { + private UUID contractId; + private UUID houseId; + private UUID tenantId; + private UUID confirmedBy; + private Instant executeAt; + private String messageId; +} diff --git a/src/main/java/com/isums/assetservice/domains/dtos/ProvisionNodeRequest.java b/src/main/java/com/isums/assetservice/domains/dtos/ProvisionNodeRequest.java index 6751c6e..2ac396b 100644 --- a/src/main/java/com/isums/assetservice/domains/dtos/ProvisionNodeRequest.java +++ b/src/main/java/com/isums/assetservice/domains/dtos/ProvisionNodeRequest.java @@ -1,6 +1,13 @@ package com.isums.assetservice.domains.dtos; +import com.isums.assetservice.domains.enums.NodeCapability; + +import java.util.Set; import java.util.UUID; -public record ProvisionNodeRequest(String token, String serial, UUID areaId) { -} +public record ProvisionNodeRequest( + String token, + String serial, + UUID areaId, + Set capabilities +) {} diff --git a/src/main/java/com/isums/assetservice/domains/dtos/SyncCapabilitiesRequest.java b/src/main/java/com/isums/assetservice/domains/dtos/SyncCapabilitiesRequest.java new file mode 100644 index 0000000..f7dd6f6 --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/dtos/SyncCapabilitiesRequest.java @@ -0,0 +1,12 @@ +package com.isums.assetservice.domains.dtos; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +import java.util.Set; + +public record SyncCapabilitiesRequest( + @NotBlank String thing, + @NotNull @NotEmpty Set capabilities +) {} diff --git a/src/main/java/com/isums/assetservice/domains/dtos/UtilityAlertItem.java b/src/main/java/com/isums/assetservice/domains/dtos/UtilityAlertItem.java new file mode 100644 index 0000000..04af0ac --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/dtos/UtilityAlertItem.java @@ -0,0 +1,60 @@ +package com.isums.assetservice.domains.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.isums.assetservice.domains.enums.UtilityStatus; + +import java.util.UUID; + +/** + * One row on the alerts dashboard — a single (house, utility) tile. + * + *

Shape chosen so the FE can render everything without a second + * round-trip: usage progress bar, trend chip, threshold text, drill- + * down badge, and the deep-link to the per-house detail page. + * + *

    + *
  • {@code currentUsage} — kWh / m³ consumed month-to-date, + * sourced from {@code esp32_forecast.usedSoFar}.
  • + *
  • {@code forecastTotal} — model-estimated end-of-month total + * ({@code esp32_forecast.totalEstimate}). Displayed as a + * secondary line: "forecast 10 250 kWh by 30 Apr".
  • + *
  • {@code monthlyLimit} — landlord-agreed cap from + * {@code iot_thresholds.max_val} where {@code metric} is the + * utility's {@code thresholdKey}. Null if no cap configured — + * UI then shows "Set limit →" CTA.
  • + *
  • {@code usagePercent} — {@code 100 · currentUsage / monthlyLimit}. + * Null when {@code monthlyLimit} is null.
  • + *
  • {@code status} — derived, see {@link UtilityStatus}.
  • + *
  • {@code activeAlertCount} — unresolved rows in + * {@code esp32_alerts} for this house today — not the monthly + * overage, but per-sensor instantaneous alerts (e.g. over- + * voltage spike, water leak flow). Landlord wants both signals + * on the same tile.
  • + *
  • {@code daysLeft} — days remaining in the current billing + * month, from the forecast. Helps the landlord judge urgency.
  • + *
+ * + *

Fields that can't be populated (no forecast row, no threshold, + * no alerts today) come back as {@code null} and the serialiser drops + * them via {@code NON_NULL}. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record UtilityAlertItem( + UUID houseId, + String houseName, + String regionName, + + Double currentUsage, + Double forecastTotal, + Double monthlyLimit, + Double usagePercent, + + String unit, + UtilityStatus status, + + Integer activeAlertCount, + Integer daysLeft, + + Long lastReadingAt, + Long lastAlertAt +) { } diff --git a/src/main/java/com/isums/assetservice/domains/dtos/UtilityAlertSummary.java b/src/main/java/com/isums/assetservice/domains/dtos/UtilityAlertSummary.java new file mode 100644 index 0000000..9c1c117 --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/dtos/UtilityAlertSummary.java @@ -0,0 +1,26 @@ +package com.isums.assetservice.domains.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * Aggregate row at the top of the alerts dashboard — three KPIs the + * landlord/manager looks at first before scanning individual tiles. + * + *

    + *
  • {@code totalUsage} — sum of {@code currentUsage} across all + * houses in scope. Null if no house has data yet.
  • + *
  • {@code avgPerHouse} — {@code totalUsage / houseCount}. Gives + * a baseline to eyeball which tiles are outliers.
  • + *
  • {@code housesOverThreshold} — count of tiles whose status is + * {@code WARNING} or {@code CRITICAL}. Renders as the big red + * number in the summary card; the landlord's first question is + * always "how many houses need attention right now?".
  • + *
+ */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record UtilityAlertSummary( + Double totalUsage, + Double avgPerHouse, + int houseCount, + int housesOverThreshold +) { } diff --git a/src/main/java/com/isums/assetservice/domains/dtos/UtilityAlertsResponse.java b/src/main/java/com/isums/assetservice/domains/dtos/UtilityAlertsResponse.java new file mode 100644 index 0000000..6059826 --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/dtos/UtilityAlertsResponse.java @@ -0,0 +1,31 @@ +package com.isums.assetservice.domains.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.isums.assetservice.domains.enums.UtilityMetric; + +import java.util.List; + +/** + * Envelope for {@code GET /api/assets/utility-alerts}. + * + *

One payload per (metric, month) pair — the FE issues one fetch + * per tab (Electricity / Water) and caches per-tab via React Query. + * + *

{@code metric} and {@code unit} are echoed back so the FE can + * render the unit suffix ("kWh" / "m³") without hard-coding and the + * React Query cache can use them as part of the key without risking + * stale data if the request param was coerced / normalised server- + * side. + * + *

{@code month} is {@code YYYY-MM} — the billing month, always + * computed in VN time in the service so the dashboard matches what + * the tenant sees on their invoice. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record UtilityAlertsResponse( + UtilityMetric metric, + String unit, + String month, + List items, + UtilityAlertSummary summary +) { } diff --git a/src/main/java/com/isums/assetservice/domains/entities/AreaPowerState.java b/src/main/java/com/isums/assetservice/domains/entities/AreaPowerState.java new file mode 100644 index 0000000..2741c32 --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/entities/AreaPowerState.java @@ -0,0 +1,56 @@ +package com.isums.assetservice.domains.entities; + +import com.isums.assetservice.domains.enums.PowerCutReason; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.UuidGenerator; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table( + name = "area_power_states", + uniqueConstraints = @UniqueConstraint( + name = "uq_area_power_house_area", + columnNames = {"house_id", "area_id"} + ), + indexes = { + @Index(name = "idx_aps_house", columnList = "house_id"), + @Index(name = "idx_aps_area", columnList = "area_id") + } +) +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class AreaPowerState { + + @Id + @GeneratedValue + @UuidGenerator + private UUID id; + + @Column(name = "house_id", nullable = false) + private UUID houseId; + + @Column(name = "area_id", nullable = false) + private UUID areaId; + + @Column(name = "powered", nullable = false) + @Builder.Default + private boolean powered = true; + + @Enumerated(EnumType.STRING) + @Column(name = "cut_reason") + private PowerCutReason cutReason; + + @Column(name = "power_cut_job_id") + private UUID powerCutJobId; + + @Column(name = "changed_by") + private UUID changedBy; + + @Column(name = "changed_at", nullable = false) + private Instant changedAt; +} diff --git a/src/main/java/com/isums/assetservice/domains/entities/AssetCategory.java b/src/main/java/com/isums/assetservice/domains/entities/AssetCategory.java index 39151c6..e510ef1 100644 --- a/src/main/java/com/isums/assetservice/domains/entities/AssetCategory.java +++ b/src/main/java/com/isums/assetservice/domains/entities/AssetCategory.java @@ -2,6 +2,8 @@ import com.fasterxml.jackson.annotation.JsonIgnore; +import common.i18n.TranslationMap; +import common.i18n.TranslationMapConverter; import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.UuidGenerator; @@ -24,7 +26,8 @@ public class AssetCategory { private UUID id; @Column(columnDefinition = "text") - private String name; + @Convert(converter = TranslationMapConverter.class) + private TranslationMap name; private String code; @@ -32,7 +35,8 @@ public class AssetCategory { private int compensationPercent; @Column(columnDefinition = "text") - private String description; + @Convert(converter = TranslationMapConverter.class) + private TranslationMap description; @ToString.Exclude @EqualsAndHashCode.Exclude diff --git a/src/main/java/com/isums/assetservice/domains/entities/AssetEvent.java b/src/main/java/com/isums/assetservice/domains/entities/AssetEvent.java index 080c355..02e8e21 100644 --- a/src/main/java/com/isums/assetservice/domains/entities/AssetEvent.java +++ b/src/main/java/com/isums/assetservice/domains/entities/AssetEvent.java @@ -2,10 +2,7 @@ import com.isums.assetservice.domains.enums.AssetEventType; import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.*; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; import org.hibernate.annotations.UuidGenerator; @@ -31,8 +28,16 @@ public class AssetEvent { @Enumerated(EnumType.STRING) private AssetEventType eventType; + private Integer previousCondition; + + private Integer currentCondition; + @Column(columnDefinition = "text") - private String description; + private String note; + + @Column(name = "note_translations", columnDefinition = "text") + @Convert(converter = com.isums.common.i18n.TranslationMapConverter.class) + private com.isums.common.i18n.TranslationMap noteTranslations; @CreationTimestamp private Instant createdAt; @@ -42,8 +47,8 @@ public class AssetEvent { private UUID createBy; - @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "asset_id") private AssetItem assetItem; + } diff --git a/src/main/java/com/isums/assetservice/domains/entities/AssetEventImage.java b/src/main/java/com/isums/assetservice/domains/entities/AssetEventImage.java new file mode 100644 index 0000000..b2c41b5 --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/entities/AssetEventImage.java @@ -0,0 +1,46 @@ +package com.isums.assetservice.domains.entities; + +import com.isums.assetservice.domains.enums.AssetEventImageType; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.UuidGenerator; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "asset_event_images") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class AssetEventImage { + + @Id + @GeneratedValue + @UuidGenerator + private UUID id; + + @Column(nullable = false) + private String key; + + @ManyToOne + @JoinColumn(name = "event_id") + private AssetEvent event; + + /** + * {@code BEFORE} = snapshot of the asset image at event creation + * time; {@code AFTER} = photo uploaded after the event was + * processed. Nullable for backward-compat with rows inserted + * before this discriminator existed — the read side treats them + * as AFTER by convention (closest match to legacy semantics). + */ + @Enumerated(EnumType.STRING) + @Column(length = 10) + private AssetEventImageType type; + + private Instant createdAt; +} diff --git a/src/main/java/com/isums/assetservice/domains/entities/AssetItem.java b/src/main/java/com/isums/assetservice/domains/entities/AssetItem.java index 53a2145..6f46924 100644 --- a/src/main/java/com/isums/assetservice/domains/entities/AssetItem.java +++ b/src/main/java/com/isums/assetservice/domains/entities/AssetItem.java @@ -1,8 +1,11 @@ package com.isums.assetservice.domains.entities; import com.isums.assetservice.domains.enums.AssetStatus; +import common.i18n.TranslationMap; +import common.i18n.TranslationMapConverter; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; import org.hibernate.annotations.UuidGenerator; @@ -37,7 +40,8 @@ public class AssetItem { private AssetCategory category; @Column(name = "display_name", columnDefinition = "text") - private String displayName; + @Convert(converter = TranslationMapConverter.class) + private TranslationMap displayName; @Column(name = "serial_number", columnDefinition = "text") private String serialNumber; @@ -47,9 +51,16 @@ public class AssetItem { private String note; + @Column(name = "note_translations", columnDefinition = "text") + @Convert(converter = TranslationMapConverter.class) + private TranslationMap noteTranslations; + @UpdateTimestamp private Instant updateAt; + @CreationTimestamp + private Instant createdAt; + @Enumerated(EnumType.STRING) private AssetStatus status; diff --git a/src/main/java/com/isums/assetservice/domains/entities/IotController.java b/src/main/java/com/isums/assetservice/domains/entities/IotController.java index e292b34..2df4ede 100644 --- a/src/main/java/com/isums/assetservice/domains/entities/IotController.java +++ b/src/main/java/com/isums/assetservice/domains/entities/IotController.java @@ -44,6 +44,13 @@ public class IotController { @Column(name = "activated_at") private Instant activatedAt; + @Column(name = "payment_cut_active", nullable = false) + @Builder.Default + private boolean paymentCutActive = false; + + @Column(name = "active_payment_cut_job_id") + private UUID activePaymentCutJobId; + @PrePersist void prePersist() { createdAt = Instant.now(); } } \ No newline at end of file diff --git a/src/main/java/com/isums/assetservice/domains/entities/IotSafetyConfigVersion.java b/src/main/java/com/isums/assetservice/domains/entities/IotSafetyConfigVersion.java new file mode 100644 index 0000000..0258ce2 --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/entities/IotSafetyConfigVersion.java @@ -0,0 +1,48 @@ +package com.isums.assetservice.domains.entities; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "iot_safety_config_version") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class IotSafetyConfigVersion { + + @Id + @Column(name = "id") + private UUID id; + + @Column(name = "version", nullable = false, length = 80) + private String version; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "config_json", nullable = false, columnDefinition = "jsonb") + private String configJson; + + @Column(name = "effective_from", nullable = false) + private Instant effectiveFrom; + + @Column(name = "expired_at") + private Instant expiredAt; + + @Column(name = "notes", length = 1000) + private String notes; + + @Column(name = "created_by", nullable = false) + private UUID createdBy; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "expired_by") + private UUID expiredBy; +} diff --git a/src/main/java/com/isums/assetservice/domains/entities/PowerCutJob.java b/src/main/java/com/isums/assetservice/domains/entities/PowerCutJob.java new file mode 100644 index 0000000..a2ecb14 --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/entities/PowerCutJob.java @@ -0,0 +1,41 @@ +package com.isums.assetservice.domains.entities; + +import com.isums.assetservice.domains.enums.PowerCutJobStatus; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UuidGenerator; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "power_cut_jobs") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PowerCutJob { + @Id + @GeneratedValue + @UuidGenerator + private UUID id; + + @Column(name = "house_id", nullable = false) + private UUID houseId; + + @Column(name = "contract_id", nullable = false) + private UUID contractId; + + @Column(name = "execute_at", nullable = false) + private Instant executeAt; + + @Enumerated(EnumType.STRING) + private PowerCutJobStatus status; + + @CreationTimestamp + private Instant createdAt; +} \ No newline at end of file diff --git a/src/main/java/com/isums/assetservice/domains/enums/AssetEventImageType.java b/src/main/java/com/isums/assetservice/domains/enums/AssetEventImageType.java new file mode 100644 index 0000000..d582c61 --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/enums/AssetEventImageType.java @@ -0,0 +1,25 @@ +package com.isums.assetservice.domains.enums; + +/** + * Discriminator for rows in {@code asset_event_images}. + * + *

One maintenance event typically produces TWO sets of photos: + *

    + *
  • {@link #BEFORE} — snapshot of the asset's images captured at + * the moment the event was created (what it looked like when the + * technician arrived).
  • + *
  • {@link #AFTER} — photos uploaded after the technician + * finished the work (proof of completion).
  • + *
+ * + *

The previous schema stored both kinds in the same column without a + * discriminator, which made it impossible to render a "before / after" + * comparison view without joining heuristically against the preceding + * event. Adding this enum + the {@code type} column on + * {@code AssetEventImage} lets the read side return them as two + * separate arrays cheaply. + */ +public enum AssetEventImageType { + BEFORE, + AFTER +} diff --git a/src/main/java/com/isums/assetservice/domains/enums/AssetStatus.java b/src/main/java/com/isums/assetservice/domains/enums/AssetStatus.java index 4dda0a0..4112968 100644 --- a/src/main/java/com/isums/assetservice/domains/enums/AssetStatus.java +++ b/src/main/java/com/isums/assetservice/domains/enums/AssetStatus.java @@ -1,6 +1,7 @@ package com.isums.assetservice.domains.enums; public enum AssetStatus { + WAITING_MANAGER_CONFIRM, AVAILABLE, IN_USE, ACTIVE, diff --git a/src/main/java/com/isums/assetservice/domains/enums/PowerAction.java b/src/main/java/com/isums/assetservice/domains/enums/PowerAction.java new file mode 100644 index 0000000..8113db3 --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/enums/PowerAction.java @@ -0,0 +1,6 @@ +package com.isums.assetservice.domains.enums; + +public enum PowerAction { + ON, + OFF +} \ No newline at end of file diff --git a/src/main/java/com/isums/assetservice/domains/enums/PowerCutJobStatus.java b/src/main/java/com/isums/assetservice/domains/enums/PowerCutJobStatus.java new file mode 100644 index 0000000..55af463 --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/enums/PowerCutJobStatus.java @@ -0,0 +1,7 @@ +package com.isums.assetservice.domains.enums; + +public enum PowerCutJobStatus { + PENDING, + EXECUTED, + CANCELLED +} diff --git a/src/main/java/com/isums/assetservice/domains/enums/PowerCutReason.java b/src/main/java/com/isums/assetservice/domains/enums/PowerCutReason.java new file mode 100644 index 0000000..5c598f0 --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/enums/PowerCutReason.java @@ -0,0 +1,6 @@ +package com.isums.assetservice.domains.enums; + +public enum PowerCutReason { + MANUAL, + PAYMENT_DUE +} \ No newline at end of file diff --git a/src/main/java/com/isums/assetservice/domains/enums/UtilityMetric.java b/src/main/java/com/isums/assetservice/domains/enums/UtilityMetric.java new file mode 100644 index 0000000..42eb0da --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/enums/UtilityMetric.java @@ -0,0 +1,66 @@ +package com.isums.assetservice.domains.enums; + +import java.util.Locale; +import java.util.Optional; + +/** + * The set of utility types surfaced on the landlord/manager alerts + * dashboard. Each enum value pins: + * + *

    + *
  • {@link #forecastKey} — the value written to DynamoDB + * {@code esp32_forecast.metric} by the EIF Prophet pipeline + * ({@code Z:\EIF\h2o_training\prophet_forecast.py}). Has to + * match exactly or the forecast lookup misses.
  • + *
  • {@link #thresholdKey} — the value written to + * {@code iot_thresholds.metric} that represents a monthly + * consumption cap for this utility. The per-sensor + * thresholds already in the table (voltage, current, power_W, + * w_lpm, …) are for instantaneous readings and deliberately + * kept separate; this new row per (house, utility) carries the + * monthly kWh / m³ ceiling the landlord agreed to with the + * tenant in the lease contract.
  • + *
  • {@link #unit} — the SI unit for the monthly total. Never + * translated — kWh / m³ are international.
  • + *
+ * + *

Gas intentionally not included. The ISUMS pipeline doesn't + * ingest gas consumption today, per product decision (2026-04). If it + * lands later, add a third enum value; the service / controller / FE + * all read from this enum so no other change is needed beyond a new + * locale string. + */ +public enum UtilityMetric { + ELECTRICITY("electricity", "electricity_monthly_kwh", "kWh"), + WATER ("water", "water_monthly_m3", "m³"); + + private final String forecastKey; + private final String thresholdKey; + private final String unit; + + UtilityMetric(String forecastKey, String thresholdKey, String unit) { + this.forecastKey = forecastKey; + this.thresholdKey = thresholdKey; + this.unit = unit; + } + + public String forecastKey() { return forecastKey; } + public String thresholdKey() { return thresholdKey; } + public String unit() { return unit; } + + /** + * Accepts either the lowercased forecast key ("electricity") + * or the enum name ("ELECTRICITY"). Returns empty for unknown + * values so the controller can 400 instead of 500. + */ + public static Optional fromRequestParam(String raw) { + if (raw == null || raw.isBlank()) return Optional.empty(); + String key = raw.trim().toLowerCase(Locale.ROOT); + for (UtilityMetric m : values()) { + if (m.forecastKey.equals(key) || m.name().toLowerCase(Locale.ROOT).equals(key)) { + return Optional.of(m); + } + } + return Optional.empty(); + } +} diff --git a/src/main/java/com/isums/assetservice/domains/enums/UtilityStatus.java b/src/main/java/com/isums/assetservice/domains/enums/UtilityStatus.java new file mode 100644 index 0000000..ad7dc09 --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/enums/UtilityStatus.java @@ -0,0 +1,32 @@ +package com.isums.assetservice.domains.enums; + +/** + * Derived status for a single (house, utility) tile on the alerts + * dashboard. Thresholds are expressed as a fraction of + * {@code currentUsage / monthlyLimit}: + * + * + * + * + * + * + * + *
Status Condition UI
{@link #GOOD} used < 0.80 · limit green
{@link #WARNING} 0.80 ≤ used < 1.00 amber
{@link #CRITICAL} used ≥ limit red
{@link #NO_DATA} no forecast or no thresholdgrey
+ * + *

The 80% warn / 100% critical split mirrors the product SLO — + * landlord wants advance notice early enough to call the tenant, + * but not so early that the dashboard is flooded with amber + * (→ alert fatigue). + * + *

{@link #NO_DATA} is intentionally a distinct tile state rather + * than an error: if a newly-provisioned house has no sensor rollup + * yet, or the landlord hasn't configured a monthly cap, we want to + * show the house on the board (so the landlord knows they + * need to act) rather than hide it. + */ +public enum UtilityStatus { + GOOD, + WARNING, + CRITICAL, + NO_DATA +} diff --git a/src/main/java/com/isums/assetservice/domains/events/UtilityThresholdExceededEvent.java b/src/main/java/com/isums/assetservice/domains/events/UtilityThresholdExceededEvent.java new file mode 100644 index 0000000..18b1b1d --- /dev/null +++ b/src/main/java/com/isums/assetservice/domains/events/UtilityThresholdExceededEvent.java @@ -0,0 +1,78 @@ +package com.isums.assetservice.domains.events; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.isums.assetservice.domains.enums.UtilityMetric; +import com.isums.assetservice.domains.enums.UtilityStatus; + +import java.time.Instant; + +/** + * Event emitted by asset-service when a (house, utility) tile + * transitions into a worse status band on the alerts dashboard — + * e.g. {@code GOOD → WARNING} or {@code WARNING → CRITICAL}. + * + *

Consumed by notification-service to deliver push + email alerts + * to the landlord/manager. We fire only on state transitions, + * not every check — the scheduler keeps the previous-state vector in + * Redis so that a house stuck at 85% doesn't hammer the landlord's + * inbox every hour. + * + *

Topic: {@code utility.consumption.alert}. Key: {@code houseId} + * so all events for a given house land on the same partition and + * preserve ordering. Value: JSON serialisation of this record. + * + *

Schema evolution: add fields at the end; keep existing fields + * nullable-compatible. Consumers should ignore unknown fields. If a + * breaking change is unavoidable, bump the topic name to + * {@code utility.consumption.alert.v2} rather than try to version + * the payload. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record UtilityThresholdExceededEvent( + String eventId, // UUID v4 — client-side dedup key + String houseId, + String houseName, // snapshot for notification convenience + String landlordUserId, // legacy recipient field; kept for backwards-compatible consumers + String tenantUserId, // current renter/tenant internal users.id for SMS/voice dispatch + UtilityMetric metric, + UtilityStatus previousStatus, + UtilityStatus currentStatus, + Double currentUsage, + Double monthlyLimit, + Double usagePercent, + String unit, + String month, // yyyy-MM + Long occurredAt // epoch millis, VN tz +) { + public static UtilityThresholdExceededEvent now( + String eventId, + String houseId, + String houseName, + String landlordUserId, + String tenantUserId, + UtilityMetric metric, + UtilityStatus previous, + UtilityStatus current, + Double currentUsage, + Double monthlyLimit, + Double usagePercent, + String month + ) { + return new UtilityThresholdExceededEvent( + eventId, + houseId, + houseName, + landlordUserId, + tenantUserId, + metric, + previous, + current, + currentUsage, + monthlyLimit, + usagePercent, + metric.unit(), + month, + Instant.now().toEpochMilli() + ); + } +} diff --git a/src/main/java/com/isums/assetservice/exceptions/GlobalExceptionHandler.java b/src/main/java/com/isums/assetservice/exceptions/GlobalExceptionHandler.java index 4c1c7d9..b20c393 100644 --- a/src/main/java/com/isums/assetservice/exceptions/GlobalExceptionHandler.java +++ b/src/main/java/com/isums/assetservice/exceptions/GlobalExceptionHandler.java @@ -3,26 +3,53 @@ import com.isums.assetservice.domains.dtos.ApiError; import com.isums.assetservice.domains.dtos.ApiResponse; import com.isums.assetservice.domains.dtos.ApiResponses; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.MessageSource; +import org.springframework.context.NoSuchMessageException; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.dao.DataAccessException; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.server.ResponseStatusException; import java.util.List; +import java.util.Objects; @RestControllerAdvice +@RequiredArgsConstructor +@Slf4j public class GlobalExceptionHandler { + private final MessageSource messageSource; + + private String msg(String code) { + try { + return messageSource.getMessage(code, null, LocaleContextHolder.getLocale()); + } catch (NoSuchMessageException e) { + return code; + } + } + + private String msgOrRaw(String raw) { + try { + return messageSource.getMessage(raw, null, LocaleContextHolder.getLocale()); + } catch (NoSuchMessageException e) { + return raw; + } + } + @ExceptionHandler(DataAccessException.class) public ResponseEntity> handleDb(DataAccessException ex) { - ex.getMostSpecificCause(); String detail = ex.getMostSpecificCause().getMessage(); ApiResponse res = ApiResponses.fail( HttpStatus.INTERNAL_SERVER_ERROR, - "Database error", + msg("error.database"), List.of(ApiError.builder() .code("DB_ERROR") .message(detail) @@ -36,10 +63,10 @@ public ResponseEntity> handleDb(DataAccessException ex) { public ResponseEntity> handleBadRequest(IllegalArgumentException ex) { ApiResponse res = ApiResponses.fail( HttpStatus.BAD_REQUEST, - ex.getMessage(), + msgOrRaw(ex.getMessage()), List.of(ApiError.builder() .code("BAD_REQUEST") - .message(ex.getMessage()) + .message(msgOrRaw(ex.getMessage())) .build()) ); @@ -48,12 +75,13 @@ public ResponseEntity> handleBadRequest(IllegalArgumentExcepti @ExceptionHandler(Exception.class) public ResponseEntity> handleGeneric(Exception ex) { + log.error("Unhandled asset-service exception", ex); ApiResponse res = ApiResponses.fail( HttpStatus.INTERNAL_SERVER_ERROR, - "Unexpected error", + msg("error.unexpected"), List.of(ApiError.builder() .code("INTERNAL_ERROR") - .message(ex.getMessage()) + .message(msg("error.unexpected")) .build()) ); @@ -62,9 +90,9 @@ public ResponseEntity> handleGeneric(Exception ex) { @ExceptionHandler(DataIntegrityViolationException.class) public ResponseEntity> handleDataIntegrityViolation(DataIntegrityViolationException ex) { - String message = "Data integrity violation"; + String message = msg("error.data_integrity"); if (ex.getMessage() != null && ex.getMessage().contains("serial_number")) { - message = "Serial number already exists"; + message = msg("error.serial_exists"); } return ResponseEntity.status(HttpStatus.CONFLICT) .body(ApiResponses.fail(HttpStatus.CONFLICT, message)); @@ -73,19 +101,42 @@ public ResponseEntity> handleDataIntegrityViolation(DataIntegrity @ExceptionHandler(ConflictException.class) public ResponseEntity> handleConflictException(ConflictException ex) { return ResponseEntity.status(HttpStatus.CONFLICT) - .body(ApiResponses.fail(HttpStatus.CONFLICT, ex.getMessage())); + .body(ApiResponses.fail(HttpStatus.CONFLICT, msgOrRaw(ex.getMessage()))); } @ExceptionHandler(NotFoundException.class) public ResponseEntity> handleNotFound(NotFoundException ex) { ApiResponse res = ApiResponses.fail( HttpStatus.NOT_FOUND, - ex.getMessage(), + msgOrRaw(ex.getMessage()), List.of(ApiError.builder() .code("NOT_FOUND") - .message(ex.getMessage()) + .message(msgOrRaw(ex.getMessage())) .build()) ); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(res); } + + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity> handleResponseStatus(ResponseStatusException ex) { + HttpStatusCode statusCode = ex.getStatusCode(); + String reason = ex.getReason() != null ? msgOrRaw(ex.getReason()) : ex.getMessage(); + + String errorCode = statusCode.value() == 402 ? "PAYMENT_REQUIRED" + : statusCode.value() == 503 ? "SERVICE_UNAVAILABLE" + : statusCode.value() == 502 ? "BAD_GATEWAY" + : "HTTP_" + statusCode.value(); + + ApiResponse res = ApiResponses.fail( + HttpStatus.resolve(statusCode.value()) != null + ? Objects.requireNonNull(HttpStatus.resolve(statusCode.value())) + : HttpStatus.INTERNAL_SERVER_ERROR, + reason, + List.of(ApiError.builder() + .code(errorCode) + .message(reason) + .build()) + ); + return ResponseEntity.status(statusCode).body(res); + } } diff --git a/src/main/java/com/isums/assetservice/exceptions/NotFoundException.java b/src/main/java/com/isums/assetservice/exceptions/NotFoundException.java index 19d6298..6244b0b 100644 --- a/src/main/java/com/isums/assetservice/exceptions/NotFoundException.java +++ b/src/main/java/com/isums/assetservice/exceptions/NotFoundException.java @@ -1,5 +1,9 @@ package com.isums.assetservice.exceptions; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.NOT_FOUND) public class NotFoundException extends RuntimeException { public NotFoundException(String message) { super(message); diff --git a/src/main/java/com/isums/assetservice/infrastructures/abstracts/AreaPowerService.java b/src/main/java/com/isums/assetservice/infrastructures/abstracts/AreaPowerService.java new file mode 100644 index 0000000..6820ae5 --- /dev/null +++ b/src/main/java/com/isums/assetservice/infrastructures/abstracts/AreaPowerService.java @@ -0,0 +1,13 @@ +package com.isums.assetservice.infrastructures.abstracts; + +import com.isums.assetservice.domains.dtos.AreaPowerStateResponse; +import com.isums.assetservice.domains.enums.PowerAction; + +import java.util.UUID; + +public interface AreaPowerService { + + AreaPowerStateResponse toggleAreaPower(UUID houseId, UUID areaId, PowerAction action, UUID requesterId); + + AreaPowerStateResponse getAreaPowerState(UUID houseId, UUID areaId); +} \ No newline at end of file diff --git a/src/main/java/com/isums/assetservice/infrastructures/abstracts/AssetEventService.java b/src/main/java/com/isums/assetservice/infrastructures/abstracts/AssetEventService.java index d1fbd11..3a7bac4 100644 --- a/src/main/java/com/isums/assetservice/infrastructures/abstracts/AssetEventService.java +++ b/src/main/java/com/isums/assetservice/infrastructures/abstracts/AssetEventService.java @@ -4,17 +4,17 @@ import com.isums.assetservice.domains.dtos.AssetEventDTO.AssetEventDto; import com.isums.assetservice.domains.dtos.AssetEventDTO.CreateAssetEventRequest; import com.isums.assetservice.domains.dtos.AssetEventDTO.UpdateAssetEventRequest; -import com.isums.assetservice.domains.dtos.AssetImageDTO.AssetImageDto; -import com.isums.assetservice.domains.dtos.AssetImageDTO.CreateAssetImageRequest; import com.isums.assetservice.domains.entities.AssetEvent; +import org.springframework.web.multipart.MultipartFile; import java.util.List; import java.util.UUID; public interface AssetEventService { - AssetEventDto createEvent(CreateAssetEventRequest request); + //AssetEventDto createEvent(CreateAssetEventRequest request); List getAllAssetEvents(); AssetEventDto updateEventStatus(UUID id, UpdateAssetEventRequest request); List getEventsByJob(UUID jobId); - + AssetEventDto getLatestEvent(UUID assetId); + void uploadEventImages(UUID eventId, List files); } diff --git a/src/main/java/com/isums/assetservice/infrastructures/abstracts/AssetItemService.java b/src/main/java/com/isums/assetservice/infrastructures/abstracts/AssetItemService.java index 152b2d7..b53f19a 100644 --- a/src/main/java/com/isums/assetservice/infrastructures/abstracts/AssetItemService.java +++ b/src/main/java/com/isums/assetservice/infrastructures/abstracts/AssetItemService.java @@ -1,28 +1,38 @@ package com.isums.assetservice.infrastructures.abstracts; import com.isums.assetservice.domains.dtos.AssetImageDto; +import com.isums.assetservice.domains.dtos.AssetCountByFunctionAreaDto; import com.isums.assetservice.domains.dtos.AssetItemDTO.AssetItemDto; import com.isums.assetservice.domains.dtos.AssetItemDTO.CreateAssetItemRequest; import com.isums.assetservice.domains.dtos.AssetItemDTO.UpdateHouseRequest; import com.isums.assetservice.domains.dtos.AssetItemDTO.UpdateAssetItemRequest; import com.isums.assetservice.domains.dtos.BatchUpdateAssetRequest; import com.isums.assetservice.domains.dtos.BatchUpdateResponse; +import com.isums.assetservice.domains.dtos.ConfirmAssetRequest; +import com.isums.assetservice.domains.enums.AssetStatus; +import common.paginations.dtos.PageRequest; +import common.paginations.dtos.PageResponse; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.multipart.MultipartFile; import java.util.List; +import java.util.Map; import java.util.UUID; public interface AssetItemService { AssetItemDto CreateAssetItem(CreateAssetItemRequest request); - List GetAllAssetItems(); + PageResponse getAll(PageRequest request); AssetItemDto UpdateAssetItem(UUID id,UpdateAssetItemRequest request); Boolean deleteAssetItem(UUID id); AssetItemDto getAssetItemById(UUID id); List getAssetItemsByHouseId(UUID houseId); + List getAssetItemsByHouseIdAndFunctionAreaId(UUID houseId, UUID functionAreaId); + List getAssetCountByFunctionArea(UUID houseId); AssetItemDto updateHouseForAsset(UUID assetId, UpdateHouseRequest request, UUID userId); void updateCondition(UUID assetId, Integer conditionScore); - void uploadAssetImages(UUID assetId, List files); - List getAssetImages(UUID assetId); + void uploadAssetImages(UUID assetId, List files); void deleteAssetImage(UUID assetId, UUID imageId); BatchUpdateResponse batchUpdateAssetCondition(UUID staffId, BatchUpdateAssetRequest request); + AssetItemDto confirmAsset(UUID assetId, AssetStatus newStatus); } diff --git a/src/main/java/com/isums/assetservice/infrastructures/abstracts/IoTDeviceService.java b/src/main/java/com/isums/assetservice/infrastructures/abstracts/IoTDeviceService.java index 7a09a7d..5f7aeed 100644 --- a/src/main/java/com/isums/assetservice/infrastructures/abstracts/IoTDeviceService.java +++ b/src/main/java/com/isums/assetservice/infrastructures/abstracts/IoTDeviceService.java @@ -1,19 +1,19 @@ -package com.isums.assetservice.infrastructures.abstracts; - -import com.isums.assetservice.domains.dtos.CreateIoTDeviceRequest; -import com.isums.assetservice.domains.dtos.IoTDeviceDto; -import com.isums.assetservice.domains.dtos.IotControllerDto; -import com.isums.assetservice.domains.entities.IoTDevice; - -import java.util.UUID; - -public interface IoTDeviceService { - - public IoTDeviceDto getByThing(String thing); - - public void upsetToDynamoDB(IoTDevice device, String areaName); - - public void createIoTDevice(CreateIoTDeviceRequest request); - - public IotControllerDto getAllIotByHouse(UUID houseId); -} +package com.isums.assetservice.infrastructures.abstracts; + +import com.isums.assetservice.domains.dtos.CreateIoTDeviceRequest; +import com.isums.assetservice.domains.dtos.IoTDeviceDto; +import com.isums.assetservice.domains.dtos.IotControllerDto; +import com.isums.assetservice.domains.entities.IoTDevice; + +import java.util.UUID; + +public interface IoTDeviceService { + + public IoTDeviceDto getByThing(String thing); + + public void upsetToDynamoDB(IoTDevice device, String areaName); + + public void createIoTDevice(CreateIoTDeviceRequest request); + + public IotControllerDto getAllIotByHouse(UUID houseId); +} \ No newline at end of file diff --git a/src/main/java/com/isums/assetservice/infrastructures/abstracts/IotForecastService.java b/src/main/java/com/isums/assetservice/infrastructures/abstracts/IotForecastService.java index caaa1ef..48dfb82 100644 --- a/src/main/java/com/isums/assetservice/infrastructures/abstracts/IotForecastService.java +++ b/src/main/java/com/isums/assetservice/infrastructures/abstracts/IotForecastService.java @@ -2,6 +2,7 @@ import com.isums.assetservice.domains.dtos.ForecastAllDto; import com.isums.assetservice.domains.dtos.ForecastScopeDto; +import com.isums.assetservice.domains.dtos.ForecastTriggerResultDto; import java.util.UUID; @@ -12,4 +13,6 @@ public interface IotForecastService { ForecastScopeDto getForecast(UUID houseId, UUID areaId, String metric, String month); void triggerForecast(UUID houseId); + + ForecastTriggerResultDto triggerAllForecasts(); } diff --git a/src/main/java/com/isums/assetservice/infrastructures/abstracts/IotProvisioningService.java b/src/main/java/com/isums/assetservice/infrastructures/abstracts/IotProvisioningService.java index 4e26bf7..b34b6cf 100644 --- a/src/main/java/com/isums/assetservice/infrastructures/abstracts/IotProvisioningService.java +++ b/src/main/java/com/isums/assetservice/infrastructures/abstracts/IotProvisioningService.java @@ -1,35 +1,40 @@ -package com.isums.assetservice.infrastructures.abstracts; - -import com.isums.assetservice.domains.dtos.*; -import com.isums.assetservice.domains.enums.NodeCapability; -import com.isums.assetservice.domains.enums.Severity; - -import java.util.Set; -import java.util.UUID; - -public interface IotProvisioningService { - - IotProvisionResponse provisionController(UUID houseId, UUID areaId, String deviceId); - - void activateController(String thingName); - - void deprovisionController(UUID houseId); - - ControllerInfoResponse getControllerByHouse(UUID houseId); - - void assignNodeToArea(String thing, UUID areaId); - - NodeProvisionResponse provisionNode(UUID houseId, String serial, String token, UUID areaId); - - void updateNodeCapabilities(String thing, Set capabilities); - - PagedResponse getAlerts(UUID houseId, int limit, String cursor, String date, Severity level); - - void sendCommand(UUID houseId, IotCommandRequest req); - - OtaUploadUrlResponse getOtaUploadUrl(UUID houseId, String filename); - - OtaJobResponse triggerOta(UUID houseId, OtaRequest req); - - OtaStatusResponse getOtaStatus(UUID houseId, String jobId); -} +package com.isums.assetservice.infrastructures.abstracts; + +import com.isums.assetservice.domains.dtos.*; +import com.isums.assetservice.domains.enums.NodeCapability; +import com.isums.assetservice.domains.enums.Severity; + +import java.util.Set; +import java.util.UUID; + +public interface IotProvisioningService { + + IotProvisionResponse provisionController(UUID houseId, UUID areaId, String deviceId); + + void activateController(String thingName); + + void deprovisionController(UUID houseId); + + ControllerInfoResponse getControllerByHouse(UUID houseId); + + void assignNodeToArea(String thing, UUID areaId); + + NodeProvisionResponse provisionNode(UUID houseId, String serial, String token, + UUID areaId, Set capabilities); + + void updateNodeCapabilities(String thing, Set capabilities); + + void syncNodeCapabilitiesIfEmpty(String thing, Set capabilities); + + PagedResponse getAlerts(UUID houseId, int limit, String cursor, String date, Severity level); + + void sendCommand(UUID houseId, IotCommandRequest req); + + OtaUploadUrlResponse getOtaUploadUrl(UUID houseId, String filename); + + OtaJobResponse triggerOta(UUID houseId, OtaRequest req); + + OtaStatusResponse getOtaStatus(UUID houseId, String jobId); + + void sendPowerCutCommand(UUID houseId); +} diff --git a/src/main/java/com/isums/assetservice/infrastructures/abstracts/UtilityAlertsService.java b/src/main/java/com/isums/assetservice/infrastructures/abstracts/UtilityAlertsService.java new file mode 100644 index 0000000..61557e8 --- /dev/null +++ b/src/main/java/com/isums/assetservice/infrastructures/abstracts/UtilityAlertsService.java @@ -0,0 +1,45 @@ +package com.isums.assetservice.infrastructures.abstracts; + +import com.isums.assetservice.domains.dtos.UtilityAlertsResponse; +import com.isums.assetservice.domains.enums.UtilityMetric; + +/** + * Read-side service for the landlord/manager utility alerts dashboard. + * + *

Responsibility: given the caller (by Keycloak id) and a utility + * metric, return one {@link UtilityAlertsResponse} envelope scoped to + * the houses that user is authorised to see — LANDLORD sees theirs, + * MANAGER sees everything in their region(s), TECH_STAFF sees their + * N:M assignments. TENANT is handled by a different endpoint because + * they need a single-house detail view rather than a fleet roll-up. + * + *

The service composes three data sources: + *

    + *
  1. {@code esp32_forecast} (DynamoDB) — usedSoFar + totalEstimate + * + daysLeft, populated by the EIF Prophet pipeline. Served via + * the existing {@link IotForecastService}.
  2. + *
  3. {@code iot_thresholds} (Postgres) — monthly consumption cap + * (landlord-configured in the lease contract).
  4. + *
  5. {@code esp32_alerts} (DynamoDB) — per-sensor instantaneous + * alerts, sampled for "active unresolved today" count.
  6. + *
+ * + *

No write side in Phase 1 (the dashboard is read-only). Phase 2 + * adds a scheduled crossover detector that emits Kafka events when a + * tile transitions GOOD → WARNING → CRITICAL — see + * {@code ThresholdCrossoverScheduler}. + */ +public interface UtilityAlertsService { + + /** + * Build the alerts envelope for the caller. + * + * @param keycloakId {@code jwt.getSubject()} — used to look up the + * internal user id + roles via user-service. + * @param metric which utility tab the FE is asking for. + * @return the envelope, possibly with empty items if the caller + * has no houses in scope (UI shows "no houses" empty + * state rather than erroring). + */ + UtilityAlertsResponse listAlerts(String keycloakId, UtilityMetric metric); +} diff --git a/src/main/java/com/isums/assetservice/infrastructures/grpcs/HouseGrpcImpl.java b/src/main/java/com/isums/assetservice/infrastructures/grpcs/HouseGrpcImpl.java index 4df27ea..34f7db2 100644 --- a/src/main/java/com/isums/assetservice/infrastructures/grpcs/HouseGrpcImpl.java +++ b/src/main/java/com/isums/assetservice/infrastructures/grpcs/HouseGrpcImpl.java @@ -1,15 +1,22 @@ package com.isums.assetservice.infrastructures.grpcs; +import com.isums.houseservice.grpc.GetAllHousesRequest; +import com.isums.houseservice.grpc.GetHouseByUserRequest; import com.isums.houseservice.grpc.GetHouseRequest; import com.isums.houseservice.grpc.HouseResponse; import com.isums.houseservice.grpc.HouseServiceGrpc; +import com.isums.houseservice.grpc.ListHouseResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import java.util.Collections; +import java.util.List; import java.util.UUID; @Service @RequiredArgsConstructor +@Slf4j public class HouseGrpcImpl extends HouseServiceGrpc.HouseServiceImplBase { private final HouseServiceGrpc.HouseServiceBlockingStub houseStub; @@ -17,4 +24,32 @@ public HouseResponse getHouseById(UUID houseId) { GetHouseRequest request = GetHouseRequest.newBuilder().setHouseId(houseId.toString()).build(); return houseStub.getHouseById(request); } + + public List listHousesByLandlord(UUID landlordUserId) { + try { + GetAllHousesRequest req = GetAllHousesRequest.newBuilder().build(); + ListHouseResponse res = houseStub.getAllHouses(req); + log.debug("[HouseGrpc] listHousesByLandlord userId={} → {} houses", + landlordUserId, res.getHouseCount()); + return res.getHouseList(); + } catch (Exception e) { + log.warn("[HouseGrpc] listHousesByLandlord failed userId={}: {}", + landlordUserId, e.getMessage()); + return Collections.emptyList(); + } + } + + public List listHousesByManager(UUID managerUserId) { + try { + GetHouseByUserRequest req = GetHouseByUserRequest.newBuilder() + .setUserId(managerUserId.toString()) + .build(); + ListHouseResponse res = houseStub.getHousesByManagerRegion(req); + return res.getHouseList(); + } catch (Exception e) { + log.warn("[HouseGrpc] listHousesByManager failed userId={}: {}", managerUserId, e.getMessage()); + return Collections.emptyList(); + } + } } + diff --git a/src/main/java/com/isums/assetservice/infrastructures/listeners/AssetConditionEventListener.java b/src/main/java/com/isums/assetservice/infrastructures/listeners/AssetConditionEventListener.java index e6e74b7..e8a067f 100644 --- a/src/main/java/com/isums/assetservice/infrastructures/listeners/AssetConditionEventListener.java +++ b/src/main/java/com/isums/assetservice/infrastructures/listeners/AssetConditionEventListener.java @@ -1,5 +1,8 @@ package com.isums.assetservice.infrastructures.listeners; +import com.isums.assetservice.domains.dtos.PowerCutConfirmedEvent; +import com.isums.assetservice.domains.entities.PowerCutJob; +import com.isums.assetservice.domains.enums.PowerCutJobStatus; import com.isums.assetservice.domains.events.AssetConditionEvent; import com.isums.assetservice.infrastructures.abstracts.AssetItemService; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/isums/assetservice/infrastructures/listeners/ContractEventListeners.java b/src/main/java/com/isums/assetservice/infrastructures/listeners/ContractEventListeners.java new file mode 100644 index 0000000..fd46c2e --- /dev/null +++ b/src/main/java/com/isums/assetservice/infrastructures/listeners/ContractEventListeners.java @@ -0,0 +1,80 @@ +package com.isums.assetservice.infrastructures.listeners; + +import com.isums.assetservice.domains.dtos.AppAccessChangedEvent; +import com.isums.assetservice.domains.dtos.PowerCutConfirmedEvent; +import com.isums.assetservice.domains.entities.PowerCutJob; +import com.isums.assetservice.domains.enums.PowerCutJobStatus; +import com.isums.assetservice.infrastructures.repositories.IotControllerRepository; +import com.isums.assetservice.infrastructures.repositories.PowerCutJobRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ContractEventListeners { + + private final ObjectMapper objectMapper; + private final PowerCutJobRepository powerCutJobRepository; + private final IotControllerRepository controllerRepository; + + @KafkaListener(topics = "contract.power-cut-confirmed", groupId = "asset-group") + public void handlePowerCutConfirmed( + ConsumerRecord record, Acknowledgment ack) { + try { + PowerCutConfirmedEvent event = objectMapper.readValue( + record.value(), PowerCutConfirmedEvent.class); + + powerCutJobRepository.save(PowerCutJob.builder() + .houseId(event.getHouseId()) + .contractId(event.getContractId()) + .executeAt(event.getExecuteAt()) + .status(PowerCutJobStatus.PENDING) + .build()); + + log.info("[Asset] PowerCutJob saved houseId={} executeAt={}", + event.getHouseId(), event.getExecuteAt()); + ack.acknowledge(); + } catch (Exception e) { + log.error("[Asset] handlePowerCutConfirmed failed: {}", e.getMessage(), e); + throw new RuntimeException(e); + } + } + + @KafkaListener(topics = "payment.app-access-changed", groupId = "asset-group") + public void handleAppAccessChanged( + ConsumerRecord record, Acknowledgment ack) { + try { + AppAccessChangedEvent event = objectMapper.readValue( + record.value(), AppAccessChangedEvent.class); + + if (event.restricted()) { + log.debug("[Asset] AppAccessChanged restricted=true houseId={} — ignored", event.houseId()); + ack.acknowledge(); + return; + } + + controllerRepository.findByHouseId(event.houseId()).ifPresentOrElse(ctrl -> { + if (!ctrl.isPaymentCutActive()) { + log.debug("[Asset] paymentCutActive already false houseId={} — skip", event.houseId()); + return; + } + ctrl.setPaymentCutActive(false); + ctrl.setActivePaymentCutJobId(null); + controllerRepository.save(ctrl); + log.info("[Asset] paymentCutActive=false houseId={} contractId={} reason={} — power can now be restored manually", + event.houseId(), event.contractId(), event.reason()); + }, () -> log.warn("[Asset] handleAppAccessChanged — no controller found for houseId={}", event.houseId())); + + ack.acknowledge(); + } catch (Exception e) { + log.error("[Asset] handleAppAccessChanged failed: {}", e.getMessage(), e); + throw new RuntimeException(e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/isums/assetservice/infrastructures/mapper/AssetGrpcMapper.java b/src/main/java/com/isums/assetservice/infrastructures/mapper/AssetGrpcMapper.java index c0568ec..dff8282 100644 --- a/src/main/java/com/isums/assetservice/infrastructures/mapper/AssetGrpcMapper.java +++ b/src/main/java/com/isums/assetservice/infrastructures/mapper/AssetGrpcMapper.java @@ -11,7 +11,9 @@ import com.isums.assetservice.grpc.AssetEventDto; import com.isums.assetservice.grpc.AssetItemDto; import com.isums.assetservice.grpc.*; +import common.i18n.TranslationMap; import lombok.RequiredArgsConstructor; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.stereotype.Component; import java.time.Instant; @@ -28,11 +30,21 @@ public AssetItemDto toDto(AssetItem item) { AssetItemDto.Builder b = AssetItemDto.newBuilder() .setId(uuid(item.getId())) .setHouseId(uuid(item.getHouseId())) - .setDisplayName(str(item.getDisplayName())) + .setDisplayName(resolveTranslation(item.getDisplayName())) .setSerialNumber(str(item.getSerialNumber())) .setConditionPercent(item.getConditionPercent()) .setStatus(mapStatus(item.getStatus())); + // Expose the full translation map so downstream services (contract + // template rendering) can pick the right locale without a second + // round-trip. Empty map if no translations stored. + if (item.getDisplayName() != null) { + java.util.Map trs = item.getDisplayName().getTranslations(); + if (trs != null && !trs.isEmpty()) { + b.putAllTranslations(trs); + } + } + if (item.getCategory() != null) { b.setCategory(toCategoryDto(item.getCategory())); } @@ -55,9 +67,9 @@ public AssetItemDto toDto(AssetItem item) { private AssetCategoryDto toCategoryDto(AssetCategory c) { return AssetCategoryDto.newBuilder() .setId(uuid(c.getId())) - .setName(str(c.getName())) + .setName(resolveTranslation(c.getName())) .setCompensationPercent(c.getCompensationPercent()) - .setDescription(str(c.getDescription())) + .setDescription(resolveTranslation(c.getDescription())) .build(); } @@ -73,7 +85,7 @@ private AssetEventDto toEventDto(AssetEvent e) { return AssetEventDto.newBuilder() .setId(uuid(e.getId())) .setEventType(mapEventType(e.getEventType())) - .setDescription(str(e.getDescription())) + .setDescription(str(e.getNote())) .setCreatedAt(ts(e.getCreatedAt())) .setCreatedBy(uuid(e.getCreateBy())) .build(); @@ -88,6 +100,7 @@ private com.isums.assetservice.grpc.AssetStatus mapStatus(AssetStatus s) { return switch (s) { case AVAILABLE -> com.isums.assetservice.grpc.AssetStatus.ASSET_STATUS_AVAILABLE; + case WAITING_MANAGER_CONFIRM -> com.isums.assetservice.grpc.AssetStatus.ASSET_STATUS_WAITING_MANAGER_CONFIRM; case IN_USE -> com.isums.assetservice.grpc.AssetStatus.ASSET_STATUS_IN_USE; case ACTIVE -> com.isums.assetservice.grpc.AssetStatus.ASSET_STATUS_UNSPECIFIED; case BROKEN -> com.isums.assetservice.grpc.AssetStatus.ASSET_STATUS_BROKEN; @@ -114,6 +127,17 @@ private com.isums.assetservice.grpc.AssetEventType mapEventType(AssetEventType t }; } + /** + * Resolve a TranslationMap to a plain string for gRPC responses. + * gRPC calls may have no HTTP request context, so we use the locale from + * LocaleContextHolder if available, falling back to the "vi" default. + */ + private static String resolveTranslation(TranslationMap tm) { + if (tm == null) return ""; + String resolved = tm.resolve(); + return resolved == null ? "" : resolved; + } + private static String str(String v) { return v == null ? "" : v; } private static String uuid(UUID v) { return v == null ? "" : v.toString(); } diff --git a/src/main/java/com/isums/assetservice/infrastructures/mapper/AssetMapper.java b/src/main/java/com/isums/assetservice/infrastructures/mapper/AssetMapper.java index e0b9e36..d9bf2d6 100644 --- a/src/main/java/com/isums/assetservice/infrastructures/mapper/AssetMapper.java +++ b/src/main/java/com/isums/assetservice/infrastructures/mapper/AssetMapper.java @@ -2,60 +2,99 @@ import com.isums.assetservice.domains.dtos.AssetCategoryDTO.AssetCategoryDto; import com.isums.assetservice.domains.dtos.AssetEventDTO.AssetEventDto; -import com.isums.assetservice.domains.dtos.AssetImageDTO.AssetImageDto; +import com.isums.assetservice.domains.dtos.AssetImageDto; import com.isums.assetservice.domains.dtos.AssetItemDTO.AssetItemDto; import com.isums.assetservice.domains.dtos.AssetTagDto.AssetTagDto; import com.isums.assetservice.domains.entities.*; -import com.isums.assetservice.domains.enums.TagType; -import org.mapstruct.AfterMapping; +import common.i18n.TranslationMap; import org.mapstruct.Mapper; import org.mapstruct.Mapping; -import org.mapstruct.MappingTarget; +import org.mapstruct.Named; +import org.springframework.context.i18n.LocaleContextHolder; import java.util.Collection; import java.util.List; -import java.util.Set; +import java.util.Map; +import java.util.stream.Collectors; @Mapper(componentModel = "spring") public interface AssetMapper { + + // ── Translation helpers ─────────────────────────────────────────────────── + + /** Resolve a TranslationMap to a single string using the current request locale. */ + @Named("resolve") + default String resolve(TranslationMap tm) { + return tm == null ? null : tm.resolve(); + } + + @Named("resolveText") + default String resolveText(String source, TranslationMap tm) { + if (tm == null) return source; + String resolved = tm.resolve(); + return resolved != null && !resolved.isBlank() ? resolved : source; + } + + /** + * Returns translations for all locales EXCEPT the currently active one, + * since the resolved value is already exposed as {@code displayName}/{@code name}. + * E.g. with Accept-Language: vi → {"en":"...", "ja":"..."} + */ + @Named("toMap") + default Map toMap(TranslationMap tm) { + if (tm == null) return Map.of(); + String current = LocaleContextHolder.getLocale().getLanguage(); + return tm.getTranslations().entrySet().stream() + .filter(e -> !e.getKey().equals(current)) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + // ── AssetItem ───────────────────────────────────────────────────────────── + @Mapping(source = "category.id", target = "categoryId") + @Mapping(source = "category", target = "category") + @Mapping(target = "tags", ignore = true) + @Mapping(target = "images", ignore = true) + @Mapping(source = "displayName", target = "displayName", qualifiedByName = "resolve") + @Mapping(target = "note", expression = "java(resolveText(assetItem.getNote(), assetItem.getNoteTranslations()))") + @Mapping(source = "displayName", target = "translations", qualifiedByName = "toMap") AssetItemDto mapAssetItem(AssetItem assetItem); + List mapAssetItems(List assetItems); + // ── AssetCategory ───────────────────────────────────────────────────────── + + @Mapping(target = "detectionType", ignore = true) + @Mapping(source = "name", target = "name", qualifiedByName = "resolve") + @Mapping(source = "name", target = "nameTranslations", qualifiedByName = "toMap") + @Mapping(source = "description", target = "description", qualifiedByName = "resolve") + @Mapping(source = "description", target = "descriptionTranslations", qualifiedByName = "toMap") AssetCategoryDto mapAssetCategory(AssetCategory assetCategory); + List mapAssetCategories(List assetCategories); - @Mapping(source = "assetItem.id", target = "assetId") - @Mapping(source = "createdAt", target = "createdAt") + // ── AssetImage ──────────────────────────────────────────────────────────── + + @Mapping(target = "url", ignore = true) AssetImageDto mapAssetImage(AssetImage assetImage); + List maAssetImages(Collection assetImages); + // ── AssetEvent ──────────────────────────────────────────────────────────── + @Mapping(source = "assetItem.id", target = "assetId") + @Mapping(source = "assetItem.displayName", target = "assetName", qualifiedByName = "resolve") + @Mapping(target = "oldImages", ignore = true) + @Mapping(target = "images", ignore = true) AssetEventDto mapAssetEvent(AssetEvent assetEvent); + List maAssetEvents(Collection assetEvents); + // ── AssetTag ────────────────────────────────────────────────────────────── + @Mapping(source = "assetItem.houseId", target = "houseId") @Mapping(source = "assetItem.id", target = "assetId") AssetTagDto tagDto(AssetTag assetTag); - List tagDtos(List assetTagDtos); - - @Mapping(source = "assetItem.category.id", target = "categoryId") - AssetItemDto mapAssetItem(AssetItem assetItem, List tags); - @AfterMapping - default void setTags( - AssetItem assetItem, - List tags, - @MappingTarget AssetItemDto dto - ) { - if (tags == null) return; - - for (AssetTag tag : tags) { - if (tag.getTagType() == TagType.NFC) { - dto.setNfcTag(tag.getTagValue()); - } - if (tag.getTagType() == TagType.QR_CODE) { - dto.setQrTag(tag.getTagValue()); - } - } - } -} \ No newline at end of file + + List tagDtos(List assetTagDtos); +} diff --git a/src/main/java/com/isums/assetservice/infrastructures/mapper/IoTControllerMapper.java b/src/main/java/com/isums/assetservice/infrastructures/mapper/IoTControllerMapper.java index 577add6..8505a51 100644 --- a/src/main/java/com/isums/assetservice/infrastructures/mapper/IoTControllerMapper.java +++ b/src/main/java/com/isums/assetservice/infrastructures/mapper/IoTControllerMapper.java @@ -6,10 +6,13 @@ import com.isums.assetservice.domains.entities.IotController; import org.mapstruct.Mapper; import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; -@Mapper(componentModel = "spring") +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) public interface IoTControllerMapper { - @Mapping(target = "devices", ignore = true) + @Mapping(target = "devices", ignore = true) + @Mapping(target = "houseName", ignore = true) + @Mapping(target = "areaName", ignore = true) IotControllerDto toIotControllerDto(IotController controller); } diff --git a/src/main/java/com/isums/assetservice/infrastructures/mapper/IoTDeviceMapper.java b/src/main/java/com/isums/assetservice/infrastructures/mapper/IoTDeviceMapper.java index 35400d6..cbb4ac1 100644 --- a/src/main/java/com/isums/assetservice/infrastructures/mapper/IoTDeviceMapper.java +++ b/src/main/java/com/isums/assetservice/infrastructures/mapper/IoTDeviceMapper.java @@ -3,6 +3,7 @@ import com.isums.assetservice.domains.dtos.IoTDeviceDto; import com.isums.assetservice.domains.dtos.IoTDeviceMapControllerDto; import com.isums.assetservice.domains.entities.IoTDevice; +import common.i18n.TranslationMap; import org.mapstruct.*; import java.util.List; @@ -17,7 +18,7 @@ public interface IoTDeviceMapper { List toIoTDeviceDtoList(List iotDevices); @Mapping(source = "assetItem.id", target = "assetId") - @Mapping(source = "assetItem.displayName", target = "displayName") + @Mapping(source = "assetItem.displayName", target = "displayName", qualifiedByName = "resolveTranslation") @Mapping(source = "assetItem.category.code", target = "categoryCode") @Mapping(source = "assetItem.status", target = "status") @Mapping(source = "assetItem.functionAreaId", target = "areaName", qualifiedByName = "mapAreaName") @@ -31,6 +32,11 @@ List toIoTDeviceMapControllerDtoList( @Context Map areaNameMap ); + @Named("resolveTranslation") + default String resolveTranslation(TranslationMap tm) { + return tm == null ? null : tm.resolve(); + } + @Named("mapAreaName") default String mapAreaName(UUID functionAreaId, @Context Map areaNameMap) { if (functionAreaId == null) return null; diff --git a/src/main/java/com/isums/assetservice/infrastructures/repositories/AreaPowerStateRepository.java b/src/main/java/com/isums/assetservice/infrastructures/repositories/AreaPowerStateRepository.java new file mode 100644 index 0000000..43c473b --- /dev/null +++ b/src/main/java/com/isums/assetservice/infrastructures/repositories/AreaPowerStateRepository.java @@ -0,0 +1,30 @@ +package com.isums.assetservice.infrastructures.repositories; + +import com.isums.assetservice.domains.entities.AreaPowerState; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface AreaPowerStateRepository extends JpaRepository { + + Optional findByHouseIdAndAreaId(UUID houseId, UUID areaId); + + List findAllByHouseId(UUID houseId); + + @Modifying + @Query(""" + UPDATE AreaPowerState s + SET s.powered = false, + s.cutReason = com.isums.assetservice.domains.enums.PowerCutReason.PAYMENT_DUE, + s.powerCutJobId = :jobId, + s.changedBy = null, + s.changedAt = CURRENT_TIMESTAMP + WHERE s.houseId = :houseId + """) + int markAllAreasPaymentDue(@Param("houseId") UUID houseId, @Param("jobId") UUID jobId); +} \ No newline at end of file diff --git a/src/main/java/com/isums/assetservice/infrastructures/repositories/AssetEventImageRepository.java b/src/main/java/com/isums/assetservice/infrastructures/repositories/AssetEventImageRepository.java new file mode 100644 index 0000000..9f9f6d3 --- /dev/null +++ b/src/main/java/com/isums/assetservice/infrastructures/repositories/AssetEventImageRepository.java @@ -0,0 +1,19 @@ +package com.isums.assetservice.infrastructures.repositories; + +import com.isums.assetservice.domains.entities.AssetEventImage; +import com.isums.assetservice.domains.enums.AssetEventImageType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface AssetEventImageRepository extends JpaRepository { + List findByEventId(UUID eventId); + + List findByEventIdOrderByCreatedAtAsc(UUID eventId); + + /** All images of a given type for this event, oldest first. Used by + * the read path to return BEFORE and AFTER arrays separately. */ + List findByEventIdAndTypeOrderByCreatedAtAsc(UUID eventId, AssetEventImageType type); +} + diff --git a/src/main/java/com/isums/assetservice/infrastructures/repositories/AssetEventRepository.java b/src/main/java/com/isums/assetservice/infrastructures/repositories/AssetEventRepository.java index b431dcc..f976514 100644 --- a/src/main/java/com/isums/assetservice/infrastructures/repositories/AssetEventRepository.java +++ b/src/main/java/com/isums/assetservice/infrastructures/repositories/AssetEventRepository.java @@ -2,10 +2,58 @@ import com.isums.assetservice.domains.entities.AssetEvent; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.time.Instant; import java.util.List; +import java.util.Optional; import java.util.UUID; public interface AssetEventRepository extends JpaRepository { - List findByJobId(UUID jobId); + @Query(""" + SELECT e FROM AssetEvent e + JOIN FETCH e.assetItem a + WHERE e.jobId = :jobId + ORDER BY e.createdAt DESC + """) + List findByJobIdWithAsset(UUID jobId); + + @Query(""" + SELECT e FROM AssetEvent e + WHERE e.assetItem.id = :assetId + ORDER BY e.createdAt DESC +""") + List findLatestEvent(UUID assetId); + + + @Query(""" + SELECT e FROM AssetEvent e + WHERE e.assetItem.id = :assetId + AND e.createdAt < ( + SELECT MAX(e2.createdAt) FROM AssetEvent e2 + WHERE e2.assetItem.id = :assetId + ) + ORDER BY e.createdAt DESC + """) + List findPreviousEvent(@Param("assetId") UUID assetId); + + Optional findTopByAssetItemIdOrderByCreatedAtDesc(UUID assetId); + + @Query(""" + SELECT e FROM AssetEvent e + WHERE e.assetItem.id = :assetId + AND e.id <> :excludeEventId + AND e.jobId IS NULL + AND e.eventType IS NULL + AND e.createdAt BETWEEN :from AND :to + ORDER BY e.createdAt DESC + """) + List findRecentUnscopedImageReplacementEvents( + @Param("assetId") UUID assetId, + @Param("excludeEventId") UUID excludeEventId, + @Param("from") Instant from, + @Param("to") Instant to + ); + } diff --git a/src/main/java/com/isums/assetservice/infrastructures/repositories/AssetItemRepository.java b/src/main/java/com/isums/assetservice/infrastructures/repositories/AssetItemRepository.java index 21f220e..d26b054 100644 --- a/src/main/java/com/isums/assetservice/infrastructures/repositories/AssetItemRepository.java +++ b/src/main/java/com/isums/assetservice/infrastructures/repositories/AssetItemRepository.java @@ -1,14 +1,32 @@ package com.isums.assetservice.infrastructures.repositories; +import com.isums.assetservice.domains.dtos.AssetCountByFunctionAreaDto; import com.isums.assetservice.domains.entities.AssetItem; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; import java.util.List; import java.util.UUID; -public interface AssetItemRepository extends JpaRepository { +public interface AssetItemRepository extends JpaRepository, JpaSpecificationExecutor { @EntityGraph(attributePaths = {"category", "images", "events"}) List findByHouseId(UUID houseId); + + @EntityGraph(attributePaths = {"category", "images", "events"}) + List findByHouseIdAndFunctionAreaId(UUID houseId, UUID functionAreaId); + + @Query(""" + select new com.isums.assetservice.domains.dtos.AssetCountByFunctionAreaDto( + a.functionAreaId, + count(a.id) + ) + from AssetItem a + where a.houseId = :houseId + and a.functionAreaId is not null + group by a.functionAreaId + """) + List countByHouseIdGroupByFunctionAreaId(UUID houseId); } diff --git a/src/main/java/com/isums/assetservice/infrastructures/repositories/IoTDeviceRepository.java b/src/main/java/com/isums/assetservice/infrastructures/repositories/IoTDeviceRepository.java index e3a799b..e9aef4d 100644 --- a/src/main/java/com/isums/assetservice/infrastructures/repositories/IoTDeviceRepository.java +++ b/src/main/java/com/isums/assetservice/infrastructures/repositories/IoTDeviceRepository.java @@ -34,4 +34,15 @@ public interface IoTDeviceRepository extends JpaRepository { Optional findBySerialNumber(String serialNumber); List findByAssetItem_HouseId(UUID houseId); + + @Query(""" + SELECT d FROM IoTDevice d + JOIN d.assetItem ai + WHERE ai.functionAreaId = :areaId + AND :capability MEMBER OF d.capabilities + """) + List findByAreaIdAndCapability( + @Param("areaId") UUID areaId, + @Param("capability") String capability + ); } diff --git a/src/main/java/com/isums/assetservice/infrastructures/repositories/IotSafetyConfigVersionRepository.java b/src/main/java/com/isums/assetservice/infrastructures/repositories/IotSafetyConfigVersionRepository.java new file mode 100644 index 0000000..8bf64f8 --- /dev/null +++ b/src/main/java/com/isums/assetservice/infrastructures/repositories/IotSafetyConfigVersionRepository.java @@ -0,0 +1,26 @@ +package com.isums.assetservice.infrastructures.repositories; + +import com.isums.assetservice.domains.entities.IotSafetyConfigVersion; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface IotSafetyConfigVersionRepository + extends JpaRepository { + + @Query(""" + SELECT v FROM IotSafetyConfigVersion v + WHERE v.expiredAt IS NULL + ORDER BY v.effectiveFrom DESC + """) + Optional findActive(); + + @Query(""" + SELECT v FROM IotSafetyConfigVersion v + ORDER BY v.effectiveFrom DESC + """) + List findHistory(); +} diff --git a/src/main/java/com/isums/assetservice/infrastructures/repositories/PowerCutJobRepository.java b/src/main/java/com/isums/assetservice/infrastructures/repositories/PowerCutJobRepository.java new file mode 100644 index 0000000..e5b04da --- /dev/null +++ b/src/main/java/com/isums/assetservice/infrastructures/repositories/PowerCutJobRepository.java @@ -0,0 +1,13 @@ +package com.isums.assetservice.infrastructures.repositories; + +import com.isums.assetservice.domains.entities.PowerCutJob; +import com.isums.assetservice.domains.enums.PowerCutJobStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public interface PowerCutJobRepository extends JpaRepository { + List findByStatusAndExecuteAtBefore(PowerCutJobStatus status, Instant executeAt); +} diff --git a/src/main/java/com/isums/assetservice/schedulers/PowerCutScheduler.java b/src/main/java/com/isums/assetservice/schedulers/PowerCutScheduler.java new file mode 100644 index 0000000..14c23c7 --- /dev/null +++ b/src/main/java/com/isums/assetservice/schedulers/PowerCutScheduler.java @@ -0,0 +1,63 @@ +package com.isums.assetservice.schedulers; + +import com.isums.assetservice.domains.entities.PowerCutJob; +import com.isums.assetservice.domains.enums.PowerCutJobStatus; +import com.isums.assetservice.infrastructures.abstracts.IotProvisioningService; +import com.isums.assetservice.infrastructures.repositories.AreaPowerStateRepository; +import com.isums.assetservice.infrastructures.repositories.IotControllerRepository; +import com.isums.assetservice.infrastructures.repositories.PowerCutJobRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; + +@Component +@RequiredArgsConstructor +@Slf4j +public class PowerCutScheduler { + + private final PowerCutJobRepository powerCutJobRepo; + private final IotProvisioningService iotProvisioningService; + private final IotControllerRepository controllerRepository; + private final AreaPowerStateRepository areaPowerStateRepository; + + @Scheduled(cron = "0 * * * * *", zone = "Asia/Ho_Chi_Minh") + @Transactional + public void executePendingPowerCuts() { + Instant now = Instant.now(); + + List jobs = powerCutJobRepo.findByStatusAndExecuteAtBefore(PowerCutJobStatus.PENDING, now); + + for (PowerCutJob job : jobs) { + try { + iotProvisioningService.sendPowerCutCommand(job.getHouseId()); + + controllerRepository.findByHouseId(job.getHouseId()).ifPresent(ctrl -> { + ctrl.setPaymentCutActive(true); + ctrl.setActivePaymentCutJobId(job.getId()); + controllerRepository.save(ctrl); + log.info("[PowerCut] paymentCutActive=true houseId={} jobId={}", + job.getHouseId(), job.getId()); + }); + + int updated = areaPowerStateRepository.markAllAreasPaymentDue(job.getHouseId(), job.getId()); + if (updated > 0) { + log.info("[PowerCut] Marked {} areas as PAYMENT_DUE houseId={}", updated, job.getHouseId()); + } + + job.setStatus(PowerCutJobStatus.EXECUTED); + powerCutJobRepo.save(job); + + log.info("[PowerCut] Executed houseId={} contractId={}", + job.getHouseId(), job.getContractId()); + + } catch (Exception e) { + log.error("[PowerCut] Failed houseId={}: {}", job.getHouseId(), e.getMessage(), e); + } + } + } +} diff --git a/src/main/java/com/isums/assetservice/schedulers/UtilityThresholdCrossoverScheduler.java b/src/main/java/com/isums/assetservice/schedulers/UtilityThresholdCrossoverScheduler.java new file mode 100644 index 0000000..711b8f8 --- /dev/null +++ b/src/main/java/com/isums/assetservice/schedulers/UtilityThresholdCrossoverScheduler.java @@ -0,0 +1,202 @@ +package com.isums.assetservice.schedulers; + +import com.isums.assetservice.domains.entities.IotController; +import com.isums.assetservice.domains.entities.IotThreshold; +import com.isums.assetservice.domains.enums.UtilityMetric; +import com.isums.assetservice.domains.enums.UtilityStatus; +import com.isums.assetservice.domains.events.UtilityThresholdExceededEvent; +import com.isums.assetservice.infrastructures.abstracts.IotForecastService; +import com.isums.assetservice.infrastructures.grpcs.HouseGrpcImpl; +import com.isums.assetservice.infrastructures.repositories.IotControllerRepository; +import com.isums.assetservice.infrastructures.repositories.IotThresholdRepository; +import com.isums.houseservice.grpc.HouseResponse; +import io.micrometer.core.instrument.MeterRegistry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +@Slf4j +public class UtilityThresholdCrossoverScheduler { + + private static final ZoneId VN = ZoneId.of("Asia/Ho_Chi_Minh"); + private static final DateTimeFormatter MONTH_FMT = DateTimeFormatter.ofPattern("yyyy-MM"); + private static final Duration STATE_TTL = Duration.ofHours(48); + + private static final double WARN_RATIO = 0.80; + private static final double CRIT_RATIO = 1.00; + + private final IotControllerRepository controllerRepository; + private final IotThresholdRepository thresholdRepository; + private final IotForecastService iotForecastService; + private final HouseGrpcImpl houseGrpc; + private final KafkaTemplate kafkaTemplate; + private final StringRedisTemplate redis; + private final ObjectMapper objectMapper; + private final MeterRegistry meterRegistry; + + @Value("${app.kafka.topic.utility-alert:utility.consumption.alert}") + private String topic; + + @Value("${app.utility-alerts.scheduler.enabled:true}") + private boolean enabled; + + @Scheduled(cron = "0 7 * * * *", zone = "Asia/Ho_Chi_Minh") + public void scan() { + if (!enabled) { + log.debug("[UtilityAlerts] scheduler disabled by flag, skipping"); + return; + } + long started = System.currentTimeMillis(); + int emitted = 0; + int checked = 0; + + String month = LocalDate.now(VN).format(MONTH_FMT); + List controllers = controllerRepository.findAll(); + + for (IotController ctrl : controllers) { + UUID houseId = ctrl.getHouseId(); + for (UtilityMetric metric : UtilityMetric.values()) { + try { + checked++; + emitted += checkAndMaybeEmit(houseId, metric, month) ? 1 : 0; + } catch (Exception e) { + + log.warn("[UtilityAlerts] scan failed houseId={} metric={}: {}", + houseId, metric, e.getMessage()); + } + } + } + + long elapsed = System.currentTimeMillis() - started; + log.info("[UtilityAlerts] sweep done controllers={} checked={} emitted={} elapsedMs={}", + controllers.size(), checked, emitted, elapsed); + meterRegistry.counter("utility_alerts_scheduler_sweeps_total").increment(); + meterRegistry.counter("utility_alerts_scheduler_events_total").increment(emitted); + } + + private boolean checkAndMaybeEmit(UUID houseId, UtilityMetric metric, String month) { + Double limit = thresholdRepository + .findByHouseIdAndAreaIdIsNullAndMetric(houseId, metric.thresholdKey()) + .filter(t -> Boolean.TRUE.equals(t.getEnabled())) + .map(IotThreshold::getMaxVal) + .filter(v -> v != null && v > 0) + .orElse(null); + if (limit == null) return false; + + var fc = iotForecastService.getForecast(houseId, null, metric.forecastKey(), month); + if (fc == null) return false; + + double used = fc.usedSoFar(); + UtilityStatus current = deriveStatus(used, limit); + if (current == UtilityStatus.GOOD || current == UtilityStatus.NO_DATA) { + + persistState(houseId, metric, current); + return false; + } + + UtilityStatus previous = loadState(houseId, metric); + if (previous == current) { + + return false; + } + if (previous == UtilityStatus.CRITICAL && current == UtilityStatus.WARNING) { + + persistState(houseId, metric, current); + return false; + } + + HouseResponse houseMeta = safeHouseMeta(houseId); + String tenantUserId = houseMeta != null ? houseMeta.getUserRentalId() : null; + // Legacy field name kept so older notification-service builds can still + // consume the event. house.user_rental_id is the current renter/tenant. + String landlordUserId = tenantUserId; + String houseName = houseMeta != null ? houseMeta.getName() : houseId.toString(); + + UtilityThresholdExceededEvent event = UtilityThresholdExceededEvent.now( + UUID.randomUUID().toString(), + houseId.toString(), + houseName, + landlordUserId, + tenantUserId, + metric, + previous == null ? UtilityStatus.GOOD : previous, + current, + round(used, 2), + round(limit, 2), + round(100.0 * used / limit, 1), + month + ); + + try { + String json = objectMapper.writeValueAsString(event); + kafkaTemplate.send(topic, houseId.toString(), json); + persistState(houseId, metric, current); + log.info("[UtilityAlerts] emitted houseId={} metric={} {}→{} usage={}%", + houseId, metric, previous, current, round(100.0 * used / limit, 1)); + return true; + } catch (Exception e) { + log.error("[UtilityAlerts] kafka emit failed houseId={} metric={}: {}", + houseId, metric, e.getMessage(), e); + + return false; + } + } + + private HouseResponse safeHouseMeta(UUID houseId) { + try { return houseGrpc.getHouseById(houseId); } + catch (Exception e) { + log.warn("[UtilityAlerts] houseGrpc.getHouseById failed houseId={}: {}", houseId, e.getMessage()); + return null; + } + } + + private UtilityStatus deriveStatus(double used, double limit) { + if (limit <= 0) return UtilityStatus.NO_DATA; + double r = used / limit; + if (r >= CRIT_RATIO) return UtilityStatus.CRITICAL; + if (r >= WARN_RATIO) return UtilityStatus.WARNING; + return UtilityStatus.GOOD; + } + + private String stateKey(UUID houseId, UtilityMetric metric) { + return "utility-alert-state:" + houseId + ":" + metric.name(); + } + + private UtilityStatus loadState(UUID houseId, UtilityMetric metric) { + try { + String v = redis.opsForValue().get(stateKey(houseId, metric)); + if (v == null) return null; + return UtilityStatus.valueOf(v); + } catch (Exception e) { + log.warn("[UtilityAlerts] redis read failed: {}", e.getMessage()); + return null; + } + } + + private void persistState(UUID houseId, UtilityMetric metric, UtilityStatus status) { + try { + redis.opsForValue().set(stateKey(houseId, metric), status.name(), STATE_TTL); + } catch (Exception e) { + log.warn("[UtilityAlerts] redis write failed: {}", e.getMessage()); + } + } + + private static double round(double v, int scale) { + double f = Math.pow(10, scale); + return Math.round(v * f) / f; + } +} diff --git a/src/main/java/com/isums/assetservice/services/AreaPowerServiceImpl.java b/src/main/java/com/isums/assetservice/services/AreaPowerServiceImpl.java new file mode 100644 index 0000000..4a24d89 --- /dev/null +++ b/src/main/java/com/isums/assetservice/services/AreaPowerServiceImpl.java @@ -0,0 +1,170 @@ +package com.isums.assetservice.services; + +import com.isums.assetservice.domains.dtos.AreaPowerStateResponse; +import com.isums.assetservice.domains.entities.AreaPowerState; +import com.isums.assetservice.domains.entities.IoTDevice; +import com.isums.assetservice.domains.entities.IotController; +import com.isums.assetservice.domains.enums.IotControllerStatus; +import com.isums.assetservice.domains.enums.NodeCapability; +import com.isums.assetservice.domains.enums.PowerAction; +import com.isums.assetservice.domains.enums.PowerCutReason; +import com.isums.assetservice.exceptions.NotFoundException; +import com.isums.assetservice.infrastructures.abstracts.AreaPowerService; +import com.isums.assetservice.infrastructures.repositories.AreaPowerStateRepository; +import com.isums.assetservice.infrastructures.repositories.IoTDeviceRepository; +import com.isums.assetservice.infrastructures.repositories.IotControllerRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.iotdataplane.IotDataPlaneClient; +import tools.jackson.databind.ObjectMapper; + +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AreaPowerServiceImpl implements AreaPowerService { + + private final IotControllerRepository controllerRepository; + private final IoTDeviceRepository ioTDeviceRepository; + private final AreaPowerStateRepository areaPowerStateRepository; + private final IotDataPlaneClient iotDataPlaneClient; + private final ObjectMapper objectMapper; + + private static final String MQTT_CMD_POWER_OFF = "relay_off"; + private static final String MQTT_CMD_POWER_ON = "relay_on"; + + @Override + @Transactional + public AreaPowerStateResponse toggleAreaPower(UUID houseId, UUID areaId, PowerAction action, UUID requesterId) { + IotController ctrl = getActiveController(houseId); + + if (ctrl.isPaymentCutActive()) { + log.warn("[AreaPower] toggle blocked — paymentCutActive houseId={} areaId={} by={}", + houseId, areaId, requesterId); + throw new ResponseStatusException(HttpStatus.PAYMENT_REQUIRED, + "Power is locked due to overdue payment. Please settle your dues before controlling power."); + } + + List relayNodes = ioTDeviceRepository.findByAreaIdAndCapability(areaId, NodeCapability.RELAY.name()); + + if (relayNodes.isEmpty()) { + throw new NotFoundException("No RELAY-capable devices found in area " + + areaId + ". Ensure a node with RELAY capability is assigned to this area."); + } + + String mqttCmd = (action == PowerAction.OFF) ? MQTT_CMD_POWER_OFF : MQTT_CMD_POWER_ON; + String topic = "esp32/" + ctrl.getThingName() + "/cmd"; + + int successCount = sendRelayCommands(topic, mqttCmd, relayNodes, houseId, areaId); + if (successCount == 0) { + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Failed to send relay command to any device in area " + areaId); + } + + AreaPowerState state = areaPowerStateRepository.findByHouseIdAndAreaId(houseId, areaId) + .orElse(AreaPowerState.builder().houseId(houseId).areaId(areaId).build()); + + state.setPowered(action == PowerAction.ON); + state.setCutReason(action == PowerAction.OFF ? PowerCutReason.MANUAL : null); + state.setPowerCutJobId(null); + state.setChangedBy(requesterId); + state.setChangedAt(Instant.now()); + AreaPowerState saved = areaPowerStateRepository.save(state); + + log.info("[AreaPower] toggled houseId={} areaId={} action={} nodes={}/{} by={}", + houseId, areaId, action, successCount, relayNodes.size(), requesterId); + + return buildResponse(saved); + } + + @Override + @Transactional(readOnly = true) + public AreaPowerStateResponse getAreaPowerState(UUID houseId, UUID areaId) { + IotController ctrl = controllerRepository.findByHouseId(houseId) + .orElseThrow(() -> new NotFoundException("No IoT controller found for house " + houseId)); + + if (ctrl.isPaymentCutActive()) { + return new AreaPowerStateResponse( + houseId, areaId, false, + PowerCutReason.PAYMENT_DUE, + "Power is locked due to overdue payment. Please settle your dues to restore power.", + Instant.now() + ); + } + + return areaPowerStateRepository.findByHouseIdAndAreaId(houseId, areaId) + .map(this::buildResponse) + .orElseGet(() -> new AreaPowerStateResponse( + houseId, areaId, true, null, + "Power is on", null + )); + } + + private IotController getActiveController(UUID houseId) { + IotController ctrl = controllerRepository.findByHouseId(houseId) + .orElseThrow(() -> new NotFoundException( + "No IoT controller found for house " + houseId)); + + if (ctrl.getStatus() != IotControllerStatus.ACTIVE) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "IoT controller is not active (status: " + ctrl.getStatus() + + "). Please ensure the device is online."); + } + return ctrl; + } + + private int sendRelayCommands(String topic, String cmd, List nodes, UUID houseId, UUID areaId) { + int success = 0; + for (IoTDevice node : nodes) { + try { + Map payload = new HashMap<>(); + payload.put("cmd", cmd); + payload.put("serial", node.getSerialNumber()); + + String json = objectMapper.writeValueAsString(payload); + iotDataPlaneClient.publish(r -> r + .topic(topic) + .qos(1) + .payload(SdkBytes.fromUtf8String(json)) + ); + success++; + log.info("[AreaPower] MQTT sent cmd={} serial={} topic={}", cmd, node.getSerialNumber(), topic); + } catch (Exception e) { + log.error("[AreaPower] MQTT failed cmd={} serial={} houseId={} areaId={}: {}", + cmd, node.getSerialNumber(), houseId, areaId, e.getMessage()); + } + } + return success; + } + + private AreaPowerStateResponse buildResponse(AreaPowerState state) { + String message; + if (state.isPowered()) { + message = "Power is on"; + } else if (state.getCutReason() == PowerCutReason.PAYMENT_DUE) { + message = "Power is locked due to overdue payment. Please settle your dues to restore power."; + } else if (state.getCutReason() == PowerCutReason.MANUAL) { + message = "Power was manually turned off"; + } else { + message = "Power is off"; + } + + return new AreaPowerStateResponse( + state.getHouseId(), + state.getAreaId(), + state.isPowered(), + state.getCutReason(), + message, + state.getChangedAt() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/isums/assetservice/services/AssetCategoryServiceImpl.java b/src/main/java/com/isums/assetservice/services/AssetCategoryServiceImpl.java index 9c7f24c..4f6a2da 100644 --- a/src/main/java/com/isums/assetservice/services/AssetCategoryServiceImpl.java +++ b/src/main/java/com/isums/assetservice/services/AssetCategoryServiceImpl.java @@ -7,6 +7,7 @@ import com.isums.assetservice.domains.entities.AssetCategory; import com.isums.assetservice.infrastructures.mapper.AssetMapper; import com.isums.assetservice.infrastructures.repositories.AssetCategoryRepository; +import common.i18n.TranslationMap; import lombok.RequiredArgsConstructor; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; @@ -19,14 +20,15 @@ public class AssetCategoryServiceImpl implements AssetCategoryService { private final AssetCategoryRepository categoryRepository; private final AssetMapper assetMapper; + private final TranslationAutoFillService translationAutoFillService; @Override public AssetCategoryDto createAssetCategory(CreateAssetCategoryRequest request) { try { AssetCategory assetCategory = AssetCategory.builder() - .name(request.name()) + .name(translationAutoFillService.complete(request.name())) .compensationPercent(request.compensationPercent()) - .description(request.description()) + .description(translationAutoFillService.complete(request.description())) .build(); AssetCategory created = categoryRepository.save(assetCategory); @@ -62,13 +64,14 @@ public AssetCategoryDto getById(UUID id) { public AssetCategoryDto updateAssetCategory(UUID id, UpdateAssetCategoryRequest request) { try { AssetCategory assetCategory = categoryRepository.findById(id) - .orElseThrow(()-> new RuntimeException("Id not found")); - //Validation name - if (request.name() != null && request.name().isBlank()) { + .orElseThrow(() -> new RuntimeException("Id not found")); + + // Validate: non-null name map must not be empty + if (request.name() != null && request.name().isEmpty()) { throw new RuntimeException("Name isn't correct form"); } - //Validation CompensationPercent + // Validate compensationPercent range if (request.compensationPercent() != null) { if (request.compensationPercent() < 0 || request.compensationPercent() > 100) { throw new RuntimeException("compensation must be in 0 - 100"); @@ -76,24 +79,20 @@ public AssetCategoryDto updateAssetCategory(UUID id, UpdateAssetCategoryRequest } if (request.name() != null) { - assetCategory.setName(request.name()); - + assetCategory.setName(translationAutoFillService.complete(request.name())); } if (request.compensationPercent() != null) { assetCategory.setCompensationPercent(request.compensationPercent()); - } if (request.description() != null) { - assetCategory.setDescription(request.description()); - + assetCategory.setDescription(translationAutoFillService.complete(request.description())); } - AssetCategory updated = categoryRepository.save(assetCategory); + categoryRepository.save(assetCategory); return assetMapper.mapAssetCategory(assetCategory); - } catch (Exception ex) { - throw new RuntimeException("Error to update asset item: " + ex.getMessage()); + throw new RuntimeException("Error to update asset category: " + ex.getMessage()); } } diff --git a/src/main/java/com/isums/assetservice/services/AssetEventServiceImpl.java b/src/main/java/com/isums/assetservice/services/AssetEventServiceImpl.java index 32badf1..f624550 100644 --- a/src/main/java/com/isums/assetservice/services/AssetEventServiceImpl.java +++ b/src/main/java/com/isums/assetservice/services/AssetEventServiceImpl.java @@ -1,61 +1,45 @@ package com.isums.assetservice.services; -import com.isums.assetservice.domains.dtos.AssetEventDTO.UpdateAssetEventRequest; -import com.isums.assetservice.infrastructures.abstracts.AssetEventService; -import com.isums.assetservice.domains.dtos.ApiResponse; -import com.isums.assetservice.domains.dtos.ApiResponses; import com.isums.assetservice.domains.dtos.AssetEventDTO.AssetEventDto; -import com.isums.assetservice.domains.dtos.AssetEventDTO.CreateAssetEventRequest; +import com.isums.assetservice.domains.dtos.AssetEventDTO.UpdateAssetEventRequest; +import com.isums.assetservice.domains.dtos.AssetEventImageDto; import com.isums.assetservice.domains.entities.AssetEvent; -import com.isums.assetservice.domains.entities.AssetItem; -import com.isums.assetservice.domains.enums.AssetEventType; +import com.isums.assetservice.domains.entities.AssetEventImage; +import com.isums.assetservice.domains.entities.AssetImage; +import com.isums.assetservice.domains.enums.AssetEventImageType; +import com.isums.assetservice.infrastructures.abstracts.AssetEventService; import com.isums.assetservice.infrastructures.mapper.AssetMapper; +import com.isums.assetservice.infrastructures.repositories.AssetEventImageRepository; import com.isums.assetservice.infrastructures.repositories.AssetEventRepository; -import com.isums.assetservice.infrastructures.repositories.AssetItemRepository; +import com.isums.assetservice.infrastructures.repositories.AssetImageRepository; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import java.time.Instant; +import java.util.ArrayList; import java.util.List; +import java.util.Objects; +import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor public class AssetEventServiceImpl implements AssetEventService { private final AssetEventRepository assetEventRepository; - private final AssetItemRepository assetItemRepository; + private final AssetEventImageRepository assetEventImageRepository; private final AssetMapper assetMapper; + private final S3ServiceImpl s3; + private final AssetImageRepository assetImageRepository; @Override - public AssetEventDto createEvent(CreateAssetEventRequest request) { - try{ - AssetItem assetItem = assetItemRepository - .findById(request.assetId()) - .orElseThrow(() -> new RuntimeException("ItemId not found")); - - AssetEvent assetEvent = AssetEvent.builder() - .assetItem(assetItem) - .description(request.description()) - .eventType(AssetEventType.CREATED) - .createdAt(Instant.now()) - .createBy(request.createBy()) - .build(); - - AssetEvent create = assetEventRepository.save(assetEvent); - return assetMapper.mapAssetEvent(create); - - } catch (Exception ex) { - throw new RuntimeException("Error to get asset item: " + ex.getMessage()); - } - } - - @Override + @Transactional(readOnly = true) public List getAllAssetEvents() { - try{ - List assetEventDto = assetEventRepository.findAll(); - - return assetMapper.maAssetEvents(assetEventDto); + try { + List assetEvents = assetEventRepository.findAll(); + return assetMapper.maAssetEvents(assetEvents); } catch (Exception ex) { throw new RuntimeException("Error to get asset item: " + ex.getMessage()); } @@ -63,33 +47,157 @@ public List getAllAssetEvents() { @Override public AssetEventDto updateEventStatus(UUID id, UpdateAssetEventRequest request) { - try{ + try { AssetEvent event = assetEventRepository.findById(id) .orElseThrow(() -> new RuntimeException("Id not found")); - if(request.status() == null){ + if (request.status() == null) { throw new RuntimeException("Status is required"); } event.setEventType(request.status()); - AssetEvent updated = assetEventRepository.save(event); - return assetMapper.mapAssetEvent(updated); - } catch (Exception ex) { throw new RuntimeException("Error to get asset item: " + ex.getMessage()); } } @Override + @Transactional(readOnly = true) public List getEventsByJob(UUID jobId) { - try { + return assetEventRepository.findByJobIdWithAsset(jobId).stream() + .map(event -> { + AssetEventDto dto = assetMapper.mapAssetEvent(event); + ImageGroups imageGroups = resolveImageGroups(event); + dto.setOldImages(toImageDtos(imageGroups.beforeImages())); + dto.setImages(toImageDtos(imageGroups.afterImages())); + return dto; + }) + .toList(); + } - List events = assetEventRepository.findByJobId(jobId); + private ImageGroups resolveImageGroups(AssetEvent event) { + List beforeImages = getEventImageEntitiesByType(event.getId(), AssetEventImageType.BEFORE); + List afterImages = getAfterImageEntities(event.getId()); + return new ImageGroups(beforeImages, afterImages); + } - return assetMapper.maAssetEvents(events); - } catch (Exception ex) { - throw new RuntimeException("Cannot get events by job: " + ex.getMessage()); + private List getAfterImageEntities(UUID eventId) { + List afterImages = getEventImageEntitiesByType(eventId, AssetEventImageType.AFTER); + if (!afterImages.isEmpty()) { + return afterImages; + } + + return assetEventImageRepository.findByEventIdOrderByCreatedAtAsc(eventId).stream() + .filter(img -> img.getType() == null) + .toList(); + } + + private List toImageDtos(List images) { + return images.stream().map(this::toImageDto).toList(); + } + + private List getEventImageEntitiesByType(UUID eventId, AssetEventImageType type) { + return assetEventImageRepository.findByEventIdAndTypeOrderByCreatedAtAsc(eventId, type); + } + + private AssetEventImageDto toImageDto(AssetEventImage img) { + return new AssetEventImageDto(img.getId(), s3.getImageUrl(img.getKey()), img.getCreatedAt()); + } + + private record ImageGroups(List beforeImages, List afterImages) { + } + + @Override + @Transactional + public AssetEventDto getLatestEvent(UUID assetId) { + List events = assetEventRepository.findLatestEvent(assetId); + AssetEvent event = events.getFirst(); + ImageGroups imageGroups = resolveImageGroups(event); + + return new AssetEventDto( + event.getId(), + event.getJobId(), + event.getEventType(), + event.getPreviousCondition(), + event.getCurrentCondition(), + event.getNote(), + event.getCreatedAt(), + event.getUpdatedAt(), + event.getAssetItem().getId(), + event.getAssetItem().getDisplayName() != null ? event.getAssetItem().getDisplayName().resolve() : null, + toImageDtos(imageGroups.beforeImages()), + toImageDtos(imageGroups.afterImages()) + ); + } + + @Override + @Transactional + public void uploadEventImages(UUID eventId, List files) { + AssetEvent event = assetEventRepository.findById(eventId) + .orElseThrow(() -> new RuntimeException("Event not found")); + + UUID assetId = event.getAssetItem().getId(); + List currentAssetImages = assetImageRepository.findByAssetItemId(assetId); + + snapshotBeforeImages(event, currentAssetImages); + + Set existingAfterKeys = assetEventImageRepository + .findByEventIdAndTypeOrderByCreatedAtAsc(eventId, AssetEventImageType.AFTER) + .stream() + .map(AssetEventImage::getKey) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + List currentImagesToDelete = currentAssetImages.stream() + .filter(image -> !existingAfterKeys.contains(image.getKey())) + .toList(); + if (!currentImagesToDelete.isEmpty()) { + assetImageRepository.deleteAll(currentImagesToDelete); + } + + for (MultipartFile file : files) { + Instant now = Instant.now(); + String key = s3.upload(file, "asset/" + assetId); + + assetImageRepository.save( + AssetImage.builder() + .assetItem(event.getAssetItem()) + .key(key) + .createdAt(now) + .build() + ); + + assetEventImageRepository.save( + AssetEventImage.builder() + .event(event) + .key(key) + .type(AssetEventImageType.AFTER) + .createdAt(now) + .build() + ); + } + } + + private void snapshotBeforeImages(AssetEvent event, List currentAssetImages) { + boolean alreadySnapshotted = !assetEventImageRepository + .findByEventIdAndTypeOrderByCreatedAtAsc(event.getId(), AssetEventImageType.BEFORE) + .isEmpty(); + if (alreadySnapshotted) { + return; + } + + List snapshots = new ArrayList<>(); + for (AssetImage img : currentAssetImages) { + snapshots.add(AssetEventImage.builder() + .event(event) + .key(img.getKey()) + .type(AssetEventImageType.BEFORE) + .createdAt(img.getCreatedAt() != null ? img.getCreatedAt() : Instant.now()) + .build()); + } + if (!snapshots.isEmpty()) { + assetEventImageRepository.saveAll(snapshots); } } } diff --git a/src/main/java/com/isums/assetservice/services/AssetItemServiceImpl.java b/src/main/java/com/isums/assetservice/services/AssetItemServiceImpl.java index 4852c02..a7734b4 100644 --- a/src/main/java/com/isums/assetservice/services/AssetItemServiceImpl.java +++ b/src/main/java/com/isums/assetservice/services/AssetItemServiceImpl.java @@ -1,10 +1,11 @@ package com.isums.assetservice.services; -import com.isums.assetservice.domains.dtos.AssetImageDto; +import com.isums.assetservice.domains.dtos.*; +import com.isums.assetservice.domains.dtos.AssetEventDTO.AssetEventDto; import com.isums.assetservice.domains.dtos.AssetItemDTO.UpdateHouseRequest; -import com.isums.assetservice.domains.dtos.BatchUpdateAssetRequest; -import com.isums.assetservice.domains.dtos.BatchUpdateResponse; +import com.isums.assetservice.domains.dtos.AssetTagDto.AssetTagDto; import com.isums.assetservice.domains.entities.*; +import com.isums.assetservice.domains.enums.AssetEventImageType; import com.isums.assetservice.domains.enums.AssetEventType; import com.isums.assetservice.exceptions.NotFoundException; import com.isums.assetservice.infrastructures.abstracts.AssetItemService; @@ -12,16 +13,34 @@ import com.isums.assetservice.domains.dtos.AssetItemDTO.CreateAssetItemRequest; import com.isums.assetservice.domains.dtos.AssetItemDTO.UpdateAssetItemRequest; import com.isums.assetservice.domains.enums.AssetStatus; +import common.i18n.TranslationMap; import com.isums.assetservice.infrastructures.abstracts.IoTDeviceService; +import com.isums.assetservice.infrastructures.grpcs.GrpcUserClient; import com.isums.assetservice.infrastructures.mapper.AssetMapper; import com.isums.assetservice.infrastructures.repositories.*; import com.isums.houseservice.grpc.HouseServiceGrpc; +import com.isums.userservice.grpc.UserResponse; +import common.paginations.cache.CachedPageService; +import common.paginations.converters.SpringPageConverter; +import common.paginations.dtos.PageRequest; +import common.paginations.dtos.PageResponse; +import common.paginations.specifications.SpecificationBuilder; +import common.statics.Roles; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.Page; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.multipart.MultipartHttpServletRequest; +import tools.jackson.core.type.TypeReference; +import java.time.Duration; import java.time.Instant; import java.util.*; import java.util.stream.Collectors; @@ -29,6 +48,7 @@ @Transactional @Service @RequiredArgsConstructor +@Slf4j public class AssetItemServiceImpl implements AssetItemService { private final AssetCategoryRepository assetCategoryRepository; private final AssetEventRepository assetEventRepository; @@ -37,7 +57,12 @@ public class AssetItemServiceImpl implements AssetItemService { private final AssetTagRepository assetTagRepository; private final S3ServiceImpl s3; private final AssetImageRepository assetImageRepository; + private final GrpcUserClient grpcUserClient; + private final CachedPageService cachedPageService; + private final AssetEventImageRepository assetEventImageRepository; + private final TranslationAutoFillService translationAutoFillService; + private static final String PAGE_NS = "assets"; @Override public AssetItemDto CreateAssetItem(CreateAssetItemRequest request) { try { @@ -49,14 +74,23 @@ public AssetItemDto CreateAssetItem(CreateAssetItemRequest request) { .houseId(request.houseId()) .functionAreaId(request.functionAreaId()) .category(assetCategory) - .displayName((request.displayName())) + .displayName(translationAutoFillService.complete(request.displayName())) .serialNumber((request.serialNumber())) .conditionPercent((request.conditionPercent())) - .status(request.status()) + .status(AssetStatus.WAITING_MANAGER_CONFIRM) .build(); AssetItem created = assetItemRepository.save(assetItem); + assetItem.getEvents().add( + AssetEvent.builder() + .eventType(AssetEventType.CREATED) + .note("Asset created, waiting for manager approval") + .createdAt(Instant.now()) + .assetItem(assetItem) + .build() + ); + return assetMapper.mapAssetItem(created); } catch (Exception ex) { @@ -65,43 +99,20 @@ public AssetItemDto CreateAssetItem(CreateAssetItemRequest request) { } @Override - public List GetAllAssetItems() { - try { - List items = assetItemRepository.findAll(); - - List assetIds = items.stream() - .map(AssetItem::getId) - .toList(); - - List tags = assetTagRepository.findByAssetItemIdInAndIsActiveTrue(assetIds); - - Map> tagMap = - tags.stream() - .collect(Collectors.groupingBy( - tag -> tag.getAssetItem().getId() - )); - - return items.stream() - .map(asset -> assetMapper.mapAssetItem( - asset, - tagMap.get(asset.getId()) - )) - .toList(); - - - } catch (Exception ex) { - throw new RuntimeException("Error to get asset item: " + ex.getMessage()); - } + @Transactional(readOnly = true) + public PageResponse getAll(PageRequest request) { + return cachedPageService.getOrLoad(PAGE_NS, request, new TypeReference<>() { + }, + () -> loadPage(request) + ); } - @Override public AssetItemDto UpdateAssetItem(UUID id, UpdateAssetItemRequest request) { try { AssetItem assetItem = assetItemRepository.findById(id) .orElseThrow(() -> new RuntimeException("Id not found")); - if (request.conditionPercent() != null) { if (request.conditionPercent() < 0 || request.conditionPercent() > 100) { throw new RuntimeException("condition must be in 0-100"); } @@ -112,7 +123,7 @@ public AssetItemDto UpdateAssetItem(UUID id, UpdateAssetItemRequest request) { } if (request.displayName() != null) - assetItem.setDisplayName(request.displayName()); + assetItem.setDisplayName(translationAutoFillService.complete(request.displayName())); if (request.serialNumber() != null) assetItem.setSerialNumber(request.serialNumber()); @@ -122,6 +133,7 @@ public AssetItemDto UpdateAssetItem(UUID id, UpdateAssetItemRequest request) { if(request.note() != null){ assetItem.setNote(request.note()); + assetItem.setNoteTranslations(translationAutoFillService.complete(Map.of("vi", request.note()))); } if (request.status() != null) @@ -142,7 +154,6 @@ public Boolean deleteAssetItem(UUID id) { AssetItem assetItem = assetItemRepository.findById(id) .orElseThrow(()-> new RuntimeException("Id not found")); - assetItem.setStatus(AssetStatus.DISPOSED); assetItemRepository.save(assetItem); @@ -159,7 +170,16 @@ public AssetItemDto getAssetItemById(UUID id) { AssetItem assetItem = assetItemRepository.findById(id) .orElseThrow(() -> new RuntimeException("AssetItem with ID: " + id + " not found")); - return assetMapper.mapAssetItem(assetItem); + AssetItemDto dto = assetMapper.mapAssetItem(assetItem); + + List tags = assetTagRepository + .findByAssetItemIdAndIsActiveTrue(id); + + dto.setTags(assetMapper.tagDtos(tags)); + + dto.setImages(getAssetImages(id)); + + return dto; } catch (Exception ex) { throw new RuntimeException("Error to get asset item: " + ex.getMessage()); @@ -170,28 +190,31 @@ public AssetItemDto getAssetItemById(UUID id) { public List getAssetItemsByHouseId(UUID houseId) { try { List assetItems = assetItemRepository.findByHouseId(houseId); - List assetIds = assetItems.stream() - .map(AssetItem::getId) - .toList(); + return hydrateAssetItems(assetItems); - List tags = assetTagRepository.findByAssetItemIdInAndIsActiveTrue(assetIds); - - Map> tagMap = - tags.stream() - .collect(Collectors.groupingBy( - tag -> tag.getAssetItem().getId() - )); - return assetItems.stream() - .map(asset -> assetMapper.mapAssetItem( - asset, - tagMap.get(asset.getId()) - )) - .toList(); } catch (Exception ex) { throw new RuntimeException("Error to get asset items: " + ex.getMessage()); } } + @Override + public List getAssetItemsByHouseIdAndFunctionAreaId(UUID houseId, UUID functionAreaId) { + try { + List assetItems = assetItemRepository.findByHouseIdAndFunctionAreaId(houseId, functionAreaId); + return hydrateAssetItems(assetItems); + } catch (Exception ex) { + throw new RuntimeException("Error to get asset items by function area: " + ex.getMessage()); + } + } + + @Override + public List getAssetCountByFunctionArea(UUID houseId) { + try { + return assetItemRepository.countByHouseIdGroupByFunctionAreaId(houseId); + } catch (Exception ex) { + throw new RuntimeException("Error to count asset items by function area: " + ex.getMessage()); + } + } @Transactional @Override public AssetItemDto updateHouseForAsset(UUID assetId, UpdateHouseRequest request, UUID userId) { @@ -210,7 +233,7 @@ public AssetItemDto updateHouseForAsset(UUID assetId, UpdateHouseRequest request AssetEvent event = AssetEvent.builder() .assetItem(item) .eventType(AssetEventType.TRANSFERRED) - .description("Transfer form " + oldHouseId + " to " + request.newHouseId()) + .note("Transfer form " + oldHouseId + " to " + request.newHouseId()) .createdAt(Instant.now()) .createBy(userId) .build(); @@ -243,20 +266,63 @@ public void uploadAssetImages(UUID assetId, List files) { AssetItem item = assetItemRepository.getReferenceById(assetId); - files.forEach(file -> { - String key = s3.upload(file,"asset/" + assetId); + AssetEvent event = assetEventRepository.save( + AssetEvent.builder() + .assetItem(item) + .createdAt(Instant.now()) + .build() + ); + + List currentAssetImages = assetImageRepository.findByAssetItemId(assetId); + for (AssetImage prev : currentAssetImages) { + assetEventImageRepository.save( + AssetEventImage.builder() + .event(event) + .key(prev.getKey()) + .type(com.isums.assetservice.domains.enums.AssetEventImageType.BEFORE) + .createdAt(prev.getCreatedAt() != null ? prev.getCreatedAt() : Instant.now()) + .build() + ); + } - AssetImage image = AssetImage.builder() - .assetItem(item) - .key(key) - .build(); + assetImageRepository.deleteAll(currentAssetImages); - assetImageRepository.save(image); - }); + List imageDtos = new ArrayList<>(); + + for (MultipartFile file : files) { + + String key = s3.upload(file, "asset/" + assetId); + + AssetImage saved = assetImageRepository.save( + AssetImage.builder() + .assetItem(item) + .key(key) + .createdAt(Instant.now()) + .build() + ); + + assetEventImageRepository.save( + AssetEventImage.builder() + .event(event) + .key(key) + .type(com.isums.assetservice.domains.enums.AssetEventImageType.AFTER) + .createdAt(Instant.now()) + .build() + ); + + imageDtos.add(new AssetImageDto( + saved.getId(), + s3.getImageUrl(key), + saved.getCreatedAt() + )); + + } + + AssetItemDto dto = assetMapper.mapAssetItem(item); + dto.setImages(imageDtos); } - @Override - public List getAssetImages(UUID assetId) { + private List getAssetImages(UUID assetId) { List images = assetImageRepository.findByAssetItemId(assetId); List imageDto = new ArrayList<>(); @@ -268,6 +334,28 @@ public List getAssetImages(UUID assetId) { return imageDto; } + private List hydrateAssetItems(List assetItems) { + List assetIds = assetItems.stream() + .map(AssetItem::getId) + .toList(); + + List tags = assetIds.isEmpty() + ? List.of() + : assetTagRepository.findByAssetItemIdInAndIsActiveTrue(assetIds); + + Map> tagMap = tags.stream() + .collect(Collectors.groupingBy(tag -> tag.getAssetItem().getId())); + + return assetItems.stream() + .map(asset -> { + AssetItemDto dto = assetMapper.mapAssetItem(asset); + dto.setTags(assetMapper.tagDtos(tagMap.getOrDefault(asset.getId(), List.of()))); + dto.setImages(getAssetImages(asset.getId())); + return dto; + }) + .toList(); + } + @Override public void deleteAssetImage(UUID assetId, UUID imageId) { AssetImage image = assetImageRepository.findById(imageId) @@ -278,9 +366,11 @@ public void deleteAssetImage(UUID assetId, UUID imageId) { @Override @Transactional - public BatchUpdateResponse batchUpdateAssetCondition(UUID staffId, BatchUpdateAssetRequest request) { + public BatchUpdateResponse batchUpdateAssetCondition( + UUID staffId, + BatchUpdateAssetRequest request + ) { try { - List ids = request.updates().stream() .map(BatchUpdateAssetRequest.AssetUpdateItem::assetId) .toList(); @@ -291,47 +381,81 @@ public BatchUpdateResponse batchUpdateAssetCondition(UUID staffId, BatchUpdateAs throw new RuntimeException("Some assets not found"); } - Map map = request.updates().stream() - .collect(Collectors.toMap(BatchUpdateAssetRequest.AssetUpdateItem::assetId, a -> a)); + Map map = + request.updates().stream() + .collect(Collectors.toMap( + BatchUpdateAssetRequest.AssetUpdateItem::assetId, + a -> a + )); UUID jobId = request.jobId(); - if (request.jobId() == null) { + if (jobId == null) { throw new RuntimeException("jobId is required"); } + + List results = new ArrayList<>(); + for (AssetItem asset : assets) { - BatchUpdateAssetRequest.AssetUpdateItem update = map.get(asset.getId()); + var update = map.get(asset.getId()); - if (update.conditionPercent() != null) { - if (update.conditionPercent() < 0 || update.conditionPercent() > 100) { - throw new RuntimeException("condition must be 0-100"); - } - asset.setConditionPercent(update.conditionPercent()); + Integer oldCondition = asset.getConditionPercent(); + Integer newCondition = update.conditionPercent(); + AssetStatus oldStatus = asset.getStatus(); + AssetStatus newStatus = update.status(); + + boolean hasChange = false; + + if (newCondition != null && !newCondition.equals(oldCondition)) { + asset.setConditionPercent(newCondition); + hasChange = true; } - if (update.note() != null) { + if (update.note() != null && !update.note().equals(asset.getNote())) { asset.setNote(update.note()); + asset.setNoteTranslations(translationAutoFillService.complete(Map.of("vi", update.note()))); + hasChange = true; } + if (newStatus != null) { + if (newStatus != AssetStatus.BROKEN && newStatus != oldStatus) { + throw new RuntimeException("Staff can only set BROKEN or keep current"); + } + if (newStatus == AssetStatus.BROKEN && oldStatus != AssetStatus.BROKEN) { + asset.setStatus(AssetStatus.BROKEN); + hasChange = true; + } + } - asset.getEvents().add(AssetEvent.builder() - .eventType(AssetEventType.MAINTENANCE) - .description("Updated condition to " + update.conditionPercent() + ", note: " + update.note()) - .createdAt(Instant.now()) - .createBy(staffId) - .jobId(jobId) - .assetItem(asset) - .build() - ); + if (hasChange) { + AssetEvent event = AssetEvent.builder() + .eventType(AssetEventType.MAINTENANCE) + .previousCondition(oldCondition) + .currentCondition(asset.getConditionPercent()) + .note(asset.getNote()) + .createdAt(Instant.now()) + .createBy(staffId) + .jobId(jobId) + .assetItem(asset) + .build(); + + AssetEvent savedEvent = assetEventRepository.save(event); + snapshotCurrentImagesForMaintenance(savedEvent); + + results.add(new BatchUpdateResponse.EventResult( + asset.getId(), + event.getId() + )); + } } - List saved = assetItemRepository.saveAll(assets); + assetItemRepository.saveAll(assets); return new BatchUpdateResponse( request.updates().size(), - saved.size(), - assetMapper.mapAssetItems(saved) + assets.size(), + results ); } catch (Exception ex) { @@ -339,4 +463,146 @@ public BatchUpdateResponse batchUpdateAssetCondition(UUID staffId, BatchUpdateAs } } + private void snapshotCurrentImagesForMaintenance(AssetEvent maintenanceEvent) { + if (maintenanceEvent.getAssetItem() == null) { + return; + } + + UUID assetId = maintenanceEvent.getAssetItem().getId(); + List currentImages = assetImageRepository.findByAssetItemId(assetId); + + if (currentImages.isEmpty()) { + return; + } + + List snapshots = currentImages.stream() + .filter(image -> image.getKey() != null) + .map(image -> AssetEventImage.builder() + .event(maintenanceEvent) + .key(image.getKey()) + .type(AssetEventImageType.BEFORE) + .createdAt(image.getCreatedAt() != null ? image.getCreatedAt() : Instant.now()) + .build()) + .toList(); + + if (!snapshots.isEmpty()) { + assetEventImageRepository.saveAll(snapshots); + } + } + + @Override + public AssetItemDto confirmAsset(UUID assetId, AssetStatus newStatus) { + try { + + Jwt jwt = (Jwt) SecurityContextHolder.getContext() + .getAuthentication() + .getPrincipal(); + + String keycloakId = jwt.getSubject(); + + UserResponse userProfile = grpcUserClient + .getUserIdAndRoleByKeyCloakId(keycloakId); + + var roles = userProfile.getRolesList(); + + if (!roles.contains(Roles.MANAGER) && !roles.contains(Roles.LANDLORD)) { + throw new RuntimeException("Permission denied"); + } + + AssetItem asset = assetItemRepository.findById(assetId) + .orElseThrow(() -> new RuntimeException("Asset not found")); + + if (asset.getStatus() != AssetStatus.WAITING_MANAGER_CONFIRM) { + throw new RuntimeException("Asset is not in waiting state"); + } + + if (newStatus == AssetStatus.IN_USE) { + asset.setStatus(AssetStatus.IN_USE); + + } else if (newStatus == AssetStatus.DELETED) { + asset.setStatus(AssetStatus.DELETED); + + } else { + throw new RuntimeException("Invalid status"); + } + + asset.setUpdateAt(Instant.now()); + + AssetItem saved = assetItemRepository.save(asset); + + return assetMapper.mapAssetItem(saved); + + } catch (Exception ex) { + throw new RuntimeException("Error confirm asset: " + ex.getMessage()); + } + + } + + private PageResponse loadPage(PageRequest request) { + AssetStatus statusFilter = request.filterValue("status") + .map(s -> { + try { + return AssetStatus.valueOf(s.toUpperCase().trim()); + } catch (IllegalArgumentException e) { + return null; + } + }) + .orElse(null); + + String statusesRaw = request.filterValue("statuses").orElse(null); + + String houseIdRaw = request.filterValue("houseId").orElse(null); + UUID houseIdFilter = houseIdRaw != null ? UUID.fromString(houseIdRaw) : null; + + var spec = SpecificationBuilder.create() + .keywordLike(request.keyword(), "name", "address") + .enumEq("status", statusFilter) + .enumInRaw("status", statusesRaw, AssetStatus.class) + .eq("houseId", houseIdFilter) + .build(); + + var pageable = SpringPageConverter.toPageable(request); + + Page page = assetItemRepository.findAll(spec, pageable); + + List items = page.getContent(); + + List assetIds = items.stream() + .map(AssetItem::getId) + .toList(); + + List tags = assetTagRepository.findByAssetItemIdInAndIsActiveTrue(assetIds); + + Map> tagMap = tags.stream().collect(Collectors.groupingBy(tag -> tag.getAssetItem().getId())); + + List dtos = items.stream() + .map(asset -> { + + AssetItemDto dto = assetMapper.mapAssetItem(asset); + + dto.setTags( + assetMapper.tagDtos(tagMap.getOrDefault(asset.getId(), List.of())) + ); + + dto.setImages(getAssetImages(asset.getId())); + + return dto; + }) + .toList(); + + return PageResponse.of( + dtos, + page.hasNext(), + page.getTotalElements(), + page.getTotalPages(), + page.getNumber(), + page.getSize() + ); + } + + private Duration contractEndBuffer() { + return Duration.ofDays(1); + } + } + diff --git a/src/main/java/com/isums/assetservice/services/AssetTagServiceImpl.java b/src/main/java/com/isums/assetservice/services/AssetTagServiceImpl.java index 33576fb..fc547a2 100644 --- a/src/main/java/com/isums/assetservice/services/AssetTagServiceImpl.java +++ b/src/main/java/com/isums/assetservice/services/AssetTagServiceImpl.java @@ -1,15 +1,18 @@ package com.isums.assetservice.services; +import com.isums.assetservice.domains.dtos.AssetImageDto; import com.isums.assetservice.domains.dtos.AssetItemDTO.AssetItemDto; import com.isums.assetservice.domains.dtos.AssetTagDto.AssetTagDto; import com.isums.assetservice.domains.dtos.AssetTagDto.AttachTagRequest; import com.isums.assetservice.domains.dtos.AssetTagDto.TransferTagRequest; +import com.isums.assetservice.domains.entities.AssetImage; import com.isums.assetservice.domains.entities.AssetItem; import com.isums.assetservice.domains.entities.AssetTag; import com.isums.assetservice.domains.entities.AssetTagLog; import com.isums.assetservice.domains.enums.TagAction; import com.isums.assetservice.infrastructures.abstracts.AssetTagService; import com.isums.assetservice.infrastructures.mapper.AssetMapper; +import com.isums.assetservice.infrastructures.repositories.AssetImageRepository; import com.isums.assetservice.infrastructures.repositories.AssetItemRepository; import com.isums.assetservice.infrastructures.repositories.AssetTagLogRepository; import com.isums.assetservice.infrastructures.repositories.AssetTagRepository; @@ -18,7 +21,9 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Instant; +import java.util.ArrayList; import java.util.List; +import java.util.UUID; @Service @RequiredArgsConstructor @@ -26,8 +31,9 @@ public class AssetTagServiceImpl implements AssetTagService { private final AssetTagRepository assetTagRepository; private final AssetItemRepository assetItemRepository; private final AssetTagLogRepository assetTagLogRepository; + private final AssetImageRepository assetImageRepository; private final AssetMapper assetMapper; - + private final S3ServiceImpl s3; @Override public AssetTagDto attachTag(AttachTagRequest request) { @@ -68,7 +74,6 @@ public AssetTagDto attachTag(AttachTagRequest request) { return assetMapper.tagDto(created); - } catch (Exception ex) { throw new RuntimeException("Error to attach tag" + ex.getMessage()); } @@ -125,7 +130,6 @@ public AssetTagDto transferTag(TransferTagRequest request) { throw new RuntimeException("Tag already attached to this asset"); } - //deactivated tag cũ currentTag.setIsActive(false); currentTag.setDeactivatedAt(Instant.now()); assetTagRepository.save(currentTag); @@ -135,7 +139,6 @@ public AssetTagDto transferTag(TransferTagRequest request) { throw new RuntimeException("Asset already has active tag of this type"); } - //attach tag mới AssetTag newTag = AssetTag.builder() .tagValue(currentTag.getTagValue()) .tagType(currentTag.getTagType()) @@ -146,7 +149,6 @@ public AssetTagDto transferTag(TransferTagRequest request) { assetTagRepository.save(newTag); - //log new tag AssetTagLog newTagLog = AssetTagLog.builder() .tagValue(newTag.getTagValue()) .tagAction(TagAction.TRANSFERRED) @@ -168,20 +170,43 @@ public AssetTagDto transferTag(TransferTagRequest request) { @Transactional @Override public AssetItemDto getAssetItemByTagValue(String tagValue) { - try{ - if(tagValue == null || tagValue.isBlank()){ + try { + if (tagValue == null || tagValue.isBlank()) { throw new IllegalArgumentException("Tag value must not be empty"); } - AssetTag tag = assetTagRepository.findByTagValueAndIsActiveTrue(tagValue) + AssetTag tag = assetTagRepository + .findByTagValueAndIsActiveTrue(tagValue) .orElseThrow(() -> new RuntimeException("Tag not found or not active")); AssetItem item = tag.getAssetItem(); - List tags = assetTagRepository.findByAssetItemIdAndIsActiveTrue(item.getId()); - return assetMapper.mapAssetItem(item,tags); + List tags = assetTagRepository + .findByAssetItemIdAndIsActiveTrue(item.getId()); + + AssetItemDto dto = assetMapper.mapAssetItem(item); + + dto.setTags(assetMapper.tagDtos(tags)); + + dto.setImages(getAssetImages(item.getId())); + + return dto; + } catch (Exception ex) { - throw new RuntimeException("Error to get asset item information" +ex.getMessage()); + throw new RuntimeException("Error to get asset item information: " + ex.getMessage()); } } + + private List getAssetImages(UUID assetId) { + List images = assetImageRepository.findByAssetItemId(assetId); + + List imageDto = new ArrayList<>(); + images.forEach(image ->{ + String url = s3.getImageUrl(image.getKey()); + imageDto.add(new AssetImageDto(image.getId(),url,image.getCreatedAt())); + }); + + return imageDto; + } } + diff --git a/src/main/java/com/isums/assetservice/services/IoTDeviceServiceImpl.java b/src/main/java/com/isums/assetservice/services/IoTDeviceServiceImpl.java index 829a9cb..858dc9f 100644 --- a/src/main/java/com/isums/assetservice/services/IoTDeviceServiceImpl.java +++ b/src/main/java/com/isums/assetservice/services/IoTDeviceServiceImpl.java @@ -19,7 +19,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; @@ -76,7 +75,8 @@ public void upsetToDynamoDB(IoTDevice device, String areaName) { return; } - String houseId = asset.getHouseId().toString(); + UUID houseUuid = asset.getHouseId(); + String houseId = houseUuid.toString(); String categoryCode = asset.getCategory().getCode(); UUID areaId = asset.getFunctionAreaId(); @@ -89,10 +89,6 @@ public void upsetToDynamoDB(IoTDevice device, String areaName) { item.put("status", av("ACTIVE")); item.put("updatedAt", avn(String.valueOf(System.currentTimeMillis()))); - if (areaId != null) { - item.put("areaId", av(areaId.toString())); - } - if (areaId != null) { item.put("areaId", av(areaId.toString())); } @@ -100,6 +96,21 @@ public void upsetToDynamoDB(IoTDevice device, String areaName) { item.put("areaName", av(areaName)); } + // Denormalise the tenant user id so esp32-threshold-checker / + // esp32-eif-score can route multi-channel notifications without a + // second service hop. HouseResponse.user_rental_id is the current + // renter despite the name suggesting otherwise (see house.proto). + // When the house has no active tenant yet the Lambda silently + // skips external channels (push WS still fires). + try { + HouseResponse house = houseGrpc.getHouseById(houseUuid); + if (house.getUserRentalId() != null && !house.getUserRentalId().isBlank()) { + item.put("tenantUserId", av(house.getUserRentalId())); + } + } catch (Exception e) { + log.warn("Failed to resolve tenantUserId houseId={}: {}", houseId, e.getMessage()); + } + Set capabilities = device.getCapabilities(); if (capabilities != null && !capabilities.isEmpty()) { item.put("capabilities", AttributeValue.builder() @@ -112,7 +123,8 @@ public void upsetToDynamoDB(IoTDevice device, String areaName) { .item(item) .build()); - log.info("Synced node {} to DynamoDB houseId={} areaId={}", thing, houseId, areaId); + log.info("Synced node {} to DynamoDB houseId={} areaId={} tenantPresent={}", + thing, houseId, areaId, item.containsKey("tenantUserId")); } private AttributeValue av(String v) { @@ -143,6 +155,7 @@ public IotControllerDto getAllIotByHouse(UUID houseId) { .orElseThrow(() -> new NotFoundException("IoT controller not found for house: " + houseId)); HouseResponse house = houseGrpc.getHouseById(houseId); + log.info("=== got house: {}", house.getName()); String areaName = null; if (controller.getAreaId() != null) { @@ -152,6 +165,7 @@ public IotControllerDto getAllIotByHouse(UUID houseId) { } IotControllerDto controllerDto = ioTControllerMapper.toIotControllerDto(controller); + log.info("=== mapped controller"); controllerDto.setAreaName(areaName); controllerDto.setHouseName(house.getName()); @@ -162,8 +176,10 @@ public IotControllerDto getAllIotByHouse(UUID houseId) { )); List devices = iotDeviceRepository.findByAssetItem_HouseId(houseId); + log.info("=== got devices: {}", devices.size()); List deviceDtos = ioTDeviceMapper.toIoTDeviceMapControllerDtoList(devices, areaNameMap); + log.info("=== mapped devices"); controllerDto.setDevices(deviceDtos); return controllerDto; diff --git a/src/main/java/com/isums/assetservice/services/IotForecastServiceImpl.java b/src/main/java/com/isums/assetservice/services/IotForecastServiceImpl.java index 8f5bdb0..150f92a 100644 --- a/src/main/java/com/isums/assetservice/services/IotForecastServiceImpl.java +++ b/src/main/java/com/isums/assetservice/services/IotForecastServiceImpl.java @@ -3,6 +3,7 @@ import com.isums.assetservice.domains.dtos.ForecastAllDto; import com.isums.assetservice.domains.dtos.ForecastDailyPoint; import com.isums.assetservice.domains.dtos.ForecastScopeDto; +import com.isums.assetservice.domains.dtos.ForecastTriggerResultDto; import com.isums.assetservice.infrastructures.abstracts.IotForecastService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -15,6 +16,8 @@ import software.amazon.awssdk.services.lambda.LambdaClient; import software.amazon.awssdk.services.lambda.model.InvocationType; import software.amazon.awssdk.services.lambda.model.InvokeRequest; +import software.amazon.awssdk.services.lambda.model.InvokeResponse; +import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; import java.nio.charset.StandardCharsets; @@ -72,22 +75,48 @@ public ForecastScopeDto getForecast(UUID houseId, UUID areaId, String metric, St @Override public void triggerForecast(UUID houseId) { try { - String payload = objectMapper.writeValueAsString( - Map.of("house_ids", List.of(houseId.toString())) - ); - - lambdaClient.invoke(InvokeRequest.builder() - .functionName(forecastDispatcherArn) - .invocationType(InvocationType.EVENT) - .payload(SdkBytes.fromString(payload, StandardCharsets.UTF_8)) - .build()); - + invokeDispatcher(Map.of("house_ids", List.of(houseId.toString())), InvocationType.EVENT); log.info("[Forecast] Triggered for houseId={}", houseId); } catch (Exception e) { log.error("[Forecast] Trigger failed houseId={}: {}", houseId, e.getMessage(), e); } } + @Override + public ForecastTriggerResultDto triggerAllForecasts() { + try { + InvokeResponse response = invokeDispatcher(Map.of("triggered_by", "manual"), InvocationType.REQUEST_RESPONSE); + if (response.functionError() != null && !response.functionError().isBlank()) { + throw new IllegalStateException("Forecast dispatcher error: " + response.functionError()); + } + + String payload = response.payload() == null ? "{}" : response.payload().asUtf8String(); + JsonNode root = objectMapper.readTree(payload); + ForecastTriggerResultDto result = new ForecastTriggerResultDto( + root.path("ok").asBoolean(false), + root.path("created").asInt(0), + root.path("skipped").asInt(0), + root.path("total").asInt(0) + ); + + log.info("[Forecast] Triggered all active houses: created={} skipped={} total={}", + result.created(), result.skipped(), result.total()); + return result; + } catch (Exception e) { + log.error("[Forecast] Trigger all failed: {}", e.getMessage(), e); + throw new IllegalStateException("Forecast trigger all failed", e); + } + } + + private InvokeResponse invokeDispatcher(Map payload, InvocationType invocationType) throws Exception { + String json = objectMapper.writeValueAsString(payload); + return lambdaClient.invoke(InvokeRequest.builder() + .functionName(forecastDispatcherArn) + .invocationType(invocationType) + .payload(SdkBytes.fromString(json, StandardCharsets.UTF_8)) + .build()); + } + private ForecastAllDto.ForecastMetricDto buildMetricDto(UUID houseId, String month, String metric) { String housePk = houseId + "#" + metric; ForecastScopeDto house = getFromDynamoDB(housePk, month); @@ -215,4 +244,4 @@ private long numLong(Map m, String key) { if (v.n() != null) return Long.parseLong(v.n()); return 0L; } -} \ No newline at end of file +} diff --git a/src/main/java/com/isums/assetservice/services/IotNodeTokenServiceImpl.java b/src/main/java/com/isums/assetservice/services/IotNodeTokenServiceImpl.java index 9e80290..85e85a2 100644 --- a/src/main/java/com/isums/assetservice/services/IotNodeTokenServiceImpl.java +++ b/src/main/java/com/isums/assetservice/services/IotNodeTokenServiceImpl.java @@ -1,52 +1,86 @@ -package com.isums.assetservice.services; - -import com.isums.assetservice.infrastructures.abstracts.IotNodeTokenService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Service; - -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import java.time.Duration; -import java.util.HexFormat; - -@Service -@RequiredArgsConstructor -@Slf4j -public class IotNodeTokenServiceImpl implements IotNodeTokenService { - private static final String SECRET_KEY = "isums-secret-2024"; - private static final Duration TTL = Duration.ofMinutes(10); - - private final StringRedisTemplate redis; - - public String generateToken(String serial) { - try { - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(SECRET_KEY.getBytes(), "HmacSHA256")); - - byte[] hmac = mac.doFinal(serial.getBytes()); - String token = HexFormat.of().formatHex(hmac); - - String key = "iot-token:" + serial; - redis.opsForValue().set(key, token, TTL); - - log.info("Generated token for serial={}", serial); - - return token; - } catch (Exception e) { - throw new RuntimeException("Token generation failed", e); - } - } - - public boolean isTokenValid(String serial, String token) { - String key = "iot-token:" + serial; - String storedToken = redis.opsForValue().get(key); - return storedToken != null && storedToken.equals(token); - } - - public void revokeToken(String serial) { - String key = "iot-token:" + serial; - redis.delete(key); - } -} +package com.isums.assetservice.services; + +import com.isums.assetservice.infrastructures.abstracts.IotNodeTokenService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.HexFormat; + +@Service +@RequiredArgsConstructor +@Slf4j +public class IotNodeTokenServiceImpl implements IotNodeTokenService { + private static final Duration TTL = Duration.ofMinutes(10); + + private final StringRedisTemplate redis; + + @Value("${iot.node-token.secret}") + private String secretKey; + + private byte[] signingKey; + + @PostConstruct + void initSigningKey() { + String configured = secretKey == null ? "" : secretKey.trim(); + if (configured.isBlank()) { + byte[] generated = new byte[32]; + new SecureRandom().nextBytes(generated); + signingKey = generated; + log.warn("iot.node-token.secret / IOT_NODE_TOKEN_SECRET is not configured; " + + "using an ephemeral runtime key for node provision tokens. " + + "Set IOT_NODE_TOKEN_SECRET in production."); + return; + } + signingKey = configured.getBytes(StandardCharsets.UTF_8); + if (signingKey.length < 32) { + log.warn("iot.node-token.secret is shorter than 32 bytes; use a 32+ byte random secret in production."); + } + } + + public String generateToken(String serial) { + if (serial == null || serial.isBlank()) { + throw new IllegalArgumentException("Node serial is required"); + } + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(signingKey, "HmacSHA256")); + + byte[] hmac = mac.doFinal(serial.trim().getBytes(StandardCharsets.UTF_8)); + String token = HexFormat.of().formatHex(hmac); + + String key = "iot-token:" + serial.trim(); + redis.opsForValue().set(key, token, TTL); + + log.info("Generated token for serial={}", serial); + + return token; + } catch (Exception e) { + throw new RuntimeException("Token generation failed", e); + } + } + + public boolean isTokenValid(String serial, String token) { + if (serial == null || serial.isBlank() || token == null) return false; + String key = "iot-token:" + serial.trim(); + String storedToken = redis.opsForValue().get(key); + if (storedToken == null) return false; + return MessageDigest.isEqual( + storedToken.getBytes(StandardCharsets.UTF_8), + token.getBytes(StandardCharsets.UTF_8)); + } + + public void revokeToken(String serial) { + String key = "iot-token:" + serial; + redis.delete(key); + } +} diff --git a/src/main/java/com/isums/assetservice/services/IotProvisioningServiceImpl.java b/src/main/java/com/isums/assetservice/services/IotProvisioningServiceImpl.java index 1b79033..edcf87f 100644 --- a/src/main/java/com/isums/assetservice/services/IotProvisioningServiceImpl.java +++ b/src/main/java/com/isums/assetservice/services/IotProvisioningServiceImpl.java @@ -3,6 +3,7 @@ import com.isums.assetservice.domains.dtos.*; import com.isums.assetservice.domains.entities.AssetCategory; import com.isums.assetservice.domains.entities.AssetItem; +import common.i18n.TranslationMap; import com.isums.assetservice.domains.entities.IoTDevice; import com.isums.assetservice.domains.entities.IotController; import com.isums.assetservice.domains.enums.AssetStatus; @@ -18,7 +19,7 @@ import com.isums.assetservice.infrastructures.repositories.IoTDeviceRepository; import com.isums.assetservice.infrastructures.repositories.IotControllerRepository; import com.isums.houseservice.grpc.FunctionalAreaResponse; -import jakarta.ws.rs.NotFoundException; +import com.isums.assetservice.exceptions.NotFoundException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -82,21 +83,23 @@ public class IotProvisioningServiceImpl implements IotProvisioningService { private String otaJobsTable; @Override - public IotProvisionResponse provisionController(UUID houseId, UUID areaId, String deviceId) { - var deviceExisted = controllerRepository.findByDeviceId(deviceId); - if (deviceExisted.isPresent()) { - var ctrl = deviceExisted.get(); - log.info("Device {} already provisioned for house {}", deviceId, ctrl.getHouseId()); + public IotProvisionResponse provisionController(UUID houseId, UUID areaId, String deviceId) { + var deviceExisted = controllerRepository.findByDeviceId(deviceId); + if (deviceExisted.isPresent()) { + var ctrl = deviceExisted.get(); + log.info("Device {} already provisioned for house {}", deviceId, ctrl.getHouseId()); if (ctrl.getStatus() != IotControllerStatus.DEPROVISIONED) { throw new ConflictException( - "Device " + deviceId + " already provisioned for house " + ctrl.getHouseId()); - } - } - - String thingName = "ctrl-" + deviceId.replace(":", "").toLowerCase(); - try { - iotClient.createThing(r -> r.thingName(thingName)); - log.info("Created thing {}", thingName); + "Device " + deviceId + " already provisioned for house " + ctrl.getHouseId()); + } + } + + validateAreaBelongsToHouse(houseId, areaId); + + String thingName = "ctrl-" + deviceId.replace(":", "").toLowerCase(); + try { + iotClient.createThing(r -> r.thingName(thingName)); + log.info("Created thing {}", thingName); var certResult = iotClient.createKeysAndCertificate(r -> r.setAsActive(true)); log.info("Created certificate for thing {}", thingName); @@ -242,16 +245,17 @@ public ControllerInfoResponse getControllerByHouse(UUID houseId) { } @Override - public void assignNodeToArea(String thing, UUID areaId) { - IoTDevice device = ioTDeviceRepository.findByThing(thing) - .orElseThrow(() -> new NotFoundException("IoT device not found: " + thing)); - - AssetItem asset = device.getAssetItem(); - UUID houseId = asset.getHouseId(); - asset.setFunctionAreaId(areaId); - assetItemRepository.save(asset); - - String areaName = getAreaName(houseId, areaId); + public void assignNodeToArea(String thing, UUID areaId) { + IoTDevice device = ioTDeviceRepository.findByThing(thing) + .orElseThrow(() -> new NotFoundException("IoT device not found: " + thing)); + + AssetItem asset = device.getAssetItem(); + UUID houseId = asset.getHouseId(); + validateAreaBelongsToHouse(houseId, areaId); + asset.setFunctionAreaId(areaId); + assetItemRepository.save(asset); + + String areaName = getAreaName(houseId, areaId); ioTDeviceService.upsetToDynamoDB(device, areaName); @@ -260,15 +264,18 @@ public void assignNodeToArea(String thing, UUID areaId) { @Override @Transactional - public NodeProvisionResponse provisionNode(UUID houseId, String serial, String token, UUID areaId) { - if (!iotNodeTokenService.isTokenValid(serial, token)) { - log.warn("Invalid token for serial={}", serial); - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid or expired token"); - } - - ioTDeviceRepository.findBySerialNumber(serial).ifPresent(d -> { - throw new ConflictException("Node " + serial + " already provisioned"); - }); + public NodeProvisionResponse provisionNode(UUID houseId, String serial, String token, + UUID areaId, Set capabilities) { + if (!iotNodeTokenService.isTokenValid(serial, token)) { + log.warn("Invalid token for serial={}", serial); + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid or expired token"); + } + + validateAreaBelongsToHouse(houseId, areaId); + + ioTDeviceRepository.findBySerialNumber(serial).ifPresent(d -> { + throw new ConflictException("Node " + serial + " already provisioned"); + }); IotController controller = controllerRepository.findByHouseId(houseId) .orElseThrow(() -> new ResponseStatusException( @@ -286,17 +293,22 @@ public NodeProvisionResponse provisionNode(UUID houseId, String serial, String t .houseId(houseId) .functionAreaId(areaId) .category(category) - .displayName("Node " + serial.substring(Math.max(0, serial.length() - 8))) + .displayName(TranslationMap.ofDefault("Node " + serial.substring(Math.max(0, serial.length() - 8)))) .serialNumber(serial) .conditionPercent(100) .status(AssetStatus.IN_USE) .build(); AssetItem savedAsset = assetItemRepository.save(assetItem); + Set capStrings = (capabilities != null && !capabilities.isEmpty()) + ? capabilities.stream().map(Enum::name).collect(Collectors.toSet()) + : new HashSet<>(); + IoTDevice device = IoTDevice.builder() .thing(serial) .serialNumber(serial) .assetItem(savedAsset) + .capabilities(capStrings) .build(); IoTDevice savedDevice = ioTDeviceRepository.save(device); @@ -347,6 +359,28 @@ public void updateNodeCapabilities(String thing, Set capabilitie log.info("Updated capabilities thing={} caps={}", thing, capabilities); } + @Override + @Transactional + public void syncNodeCapabilitiesIfEmpty(String thing, Set capabilities) { + IoTDevice device = ioTDeviceRepository.findByThing(thing).orElse(null); + if (device == null) { + log.warn("syncNodeCapabilities: device not found thing={}", thing); + return; + } + if (!device.getCapabilities().isEmpty()) { + log.debug("syncNodeCapabilities: skip thing={} already has caps={}", thing, device.getCapabilities()); + return; + } + device.setCapabilities(capabilities); + ioTDeviceRepository.save(device); + + UUID houseId = device.getAssetItem().getHouseId(); + UUID areaId = device.getAssetItem().getFunctionAreaId(); + String areaName = getAreaName(houseId, areaId); + ioTDeviceService.upsetToDynamoDB(device, areaName); + log.info("syncNodeCapabilities: thing={} caps={}", thing, capabilities); + } + @Override public PagedResponse getAlerts(UUID houseId, int limit, String cursor, String date, Severity level) { try { @@ -498,9 +532,9 @@ public OtaStatusResponse getOtaStatus(UUID houseId, String jobId) { Map item = result.item(); return new OtaStatusResponse( jobId, - item.getOrDefault("status", av("PENDING")).s(), - item.containsKey("progress") ? Integer.parseInt(item.get("progress").n()) : null, - item.containsKey("error") ? item.get("error").s() : null, + item.getOrDefault("status", av("PENDING")).s(), + item.containsKey("progress") ? Integer.parseInt(item.get("progress").n()) : null, + item.containsKey("error") ? item.get("error").s() : null, item.containsKey("updatedAt") ? Long.parseLong(item.get("updatedAt").n()) : 0L ); } catch (ResponseStatusException e) { @@ -511,14 +545,52 @@ public OtaStatusResponse getOtaStatus(UUID houseId, String jobId) { } } - private String extractCertId(String arn) { - return arn.substring(arn.lastIndexOf("/") + 1); - } + @Override + public void sendPowerCutCommand(UUID houseId) { + IotController ctrl = controllerRepository.findByHouseId(houseId) + .orElseThrow(() -> new NotFoundException("No controller for house " + houseId)); + + String topic = "esp32/" + ctrl.getThingName() + "/cmd"; + Map payload = Map.of("cmd", "POWER_CUT"); - private String getAreaName(UUID houseId, UUID areaId) { - if (areaId == null) return null; try { - var house = houseGrpc.getHouseById(houseId); + iotDataPlaneClient.publish(r -> r + .topic(topic) + .qos(1) + .payload(SdkBytes.fromUtf8String(objectMapper.writeValueAsString(payload))) + .build()); + log.info("[IoT] PowerCut command sent houseId={} topic={}", houseId, topic); + } catch (Exception e) { + throw new RuntimeException("Failed to send power cut command: " + e.getMessage(), e); + } + } + + private String extractCertId(String arn) { + return arn.substring(arn.lastIndexOf("/") + 1); + } + + private void validateAreaBelongsToHouse(UUID houseId, UUID areaId) { + if (areaId == null) { + return; + } + + boolean belongsToHouse = houseGrpc.getHouseById(houseId) + .getFunctionalAreasList() + .stream() + .anyMatch(area -> area.getId().equals(areaId.toString())); + + if (!belongsToHouse) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Area " + areaId + " does not belong to house " + houseId + ); + } + } + + private String getAreaName(UUID houseId, UUID areaId) { + if (areaId == null) return null; + try { + var house = houseGrpc.getHouseById(houseId); return house.getFunctionalAreasList().stream() .filter(a -> a.getId().equals(areaId.toString())) .map(FunctionalAreaResponse::getName) diff --git a/src/main/java/com/isums/assetservice/services/IotSafetyConfigService.java b/src/main/java/com/isums/assetservice/services/IotSafetyConfigService.java new file mode 100644 index 0000000..037db16 --- /dev/null +++ b/src/main/java/com/isums/assetservice/services/IotSafetyConfigService.java @@ -0,0 +1,168 @@ +package com.isums.assetservice.services; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.isums.assetservice.domains.dtos.CreateIotSafetyConfigRequest; +import com.isums.assetservice.domains.dtos.IotSafetyBandDto; +import com.isums.assetservice.domains.dtos.IotSafetyCapabilityGapDto; +import com.isums.assetservice.domains.dtos.IotSafetyConfigDto; +import com.isums.assetservice.domains.dtos.IotSafetyConfigVersionDto; +import com.isums.assetservice.domains.dtos.IotSafetyScoreComponentDto; +import com.isums.assetservice.domains.dtos.IotSafetySensorDto; +import com.isums.assetservice.domains.dtos.IotSafetyThresholdDto; +import com.isums.assetservice.domains.entities.IotSafetyConfigVersion; +import com.isums.assetservice.infrastructures.repositories.IotSafetyConfigVersionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class IotSafetyConfigService { + + private static final String SAFETY_CONFIG_CACHE = "iot-safety-config"; + private static final String ACTIVE_KEY = "active"; + + private final IotSafetyConfigVersionRepository repository; + private final ObjectMapper objectMapper; + + @Cacheable(value = SAFETY_CONFIG_CACHE, key = "'" + ACTIVE_KEY + "'") + @Transactional(readOnly = true) + public IotSafetyConfigDto getCurrent() { + IotSafetyConfigVersion active = repository.findActive() + .orElseThrow(() -> new IllegalStateException( + "No active iot_safety_config_version. Run migration V20260507_1500.")); + return parseConfig(active); + } + + @Transactional(readOnly = true) + public List getHistory() { + return repository.findHistory().stream().map(this::toDto).toList(); + } + + @Transactional + @CacheEvict(value = SAFETY_CONFIG_CACHE, allEntries = true) + public IotSafetyConfigVersionDto createVersion( + UUID actorId, + CreateIotSafetyConfigRequest request) { + validatePayload(request.configJson()); + Instant now = Instant.now(); + + repository.findActive().ifPresent(active -> { + active.setExpiredAt(now); + active.setExpiredBy(actorId); + repository.save(active); + }); + + String json; + try { + json = objectMapper.writeValueAsString(request.configJson()); + } catch (JsonProcessingException ex) { + throw new IllegalArgumentException("Invalid configJson: " + ex.getMessage(), ex); + } + + IotSafetyConfigVersion saved = repository.save(IotSafetyConfigVersion.builder() + .id(UUID.randomUUID()) + .version(request.version()) + .configJson(json) + .effectiveFrom(now) + .expiredAt(null) + .notes(request.notes()) + .createdBy(actorId) + .createdAt(now) + .expiredBy(null) + .build()); + + log.info("[IoT Safety Config] New version published id={} version={} by={}", + saved.getId(), saved.getVersion(), actorId); + return toDto(saved); + } + + @Transactional + @CacheEvict(value = SAFETY_CONFIG_CACHE, allEntries = true) + public IotSafetyConfigVersionDto expireVersion(UUID id, UUID actorId) { + IotSafetyConfigVersion v = repository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Version not found: " + id)); + if (v.getExpiredAt() != null) { + throw new IllegalStateException("Version already expired: " + id); + } + v.setExpiredAt(Instant.now()); + v.setExpiredBy(actorId); + IotSafetyConfigVersion saved = repository.save(v); + log.info("[IoT Safety Config] Expired version id={} by={}", id, actorId); + return toDto(saved); + } + + private IotSafetyConfigDto parseConfig(IotSafetyConfigVersion v) { + try { + JsonNode root = objectMapper.readTree(v.getConfigJson()); + return new IotSafetyConfigDto( + asList(root.path("activeSensors"), IotSafetySensorDto.class), + asList(root.path("capabilityGaps"), IotSafetyCapabilityGapDto.class), + asList(root.path("thresholds"), IotSafetyThresholdDto.class), + asList(root.path("scoreComponents"), IotSafetyScoreComponentDto.class), + asList(root.path("bands"), IotSafetyBandDto.class), + root.path("disclaimer").asText(""), + root.path("scoreFormulaDescription").asText(""), + root.path("standardsApplied").asText(""), + v.getVersion(), + v.getEffectiveFrom() == null ? null : v.getEffectiveFrom().toString(), + v.getNotes()); + } catch (Exception ex) { + throw new IllegalStateException( + "Failed to parse safety config version " + v.getId() + ": " + ex.getMessage(), ex); + } + } + + private List asList(JsonNode node, Class elementType) { + return objectMapper.convertValue( + node, + objectMapper.getTypeFactory().constructCollectionType(List.class, elementType)); + } + + private IotSafetyConfigVersionDto toDto(IotSafetyConfigVersion v) { + return new IotSafetyConfigVersionDto( + v.getId(), + v.getVersion(), + parseConfig(v), + v.getEffectiveFrom(), + v.getExpiredAt(), + v.getNotes(), + v.getCreatedBy(), + v.getCreatedAt(), + v.getExpiredBy(), + v.getExpiredAt() == null); + } + + private void validatePayload(JsonNode root) { + if (root == null || root.isNull() || root.isMissingNode()) { + throw new IllegalArgumentException("configJson is required"); + } + if (!root.path("scoreComponents").isArray()) { + throw new IllegalArgumentException("scoreComponents[] is required"); + } + if (!root.path("thresholds").isArray()) { + throw new IllegalArgumentException("thresholds[] is required"); + } + if (!root.path("bands").isArray()) { + throw new IllegalArgumentException("bands[] is required"); + } + double weightSum = 0; + for (JsonNode c : root.path("scoreComponents")) { + weightSum += c.path("weight").asDouble(0); + } + if (weightSum < 0.99 || weightSum > 1.01) { + throw new IllegalArgumentException( + "scoreComponents weights must sum to 1.0 (got " + weightSum + ")"); + } + } +} diff --git a/src/main/java/com/isums/assetservice/services/IotThresholdService.java b/src/main/java/com/isums/assetservice/services/IotThresholdService.java index 00ea3a2..370a011 100644 --- a/src/main/java/com/isums/assetservice/services/IotThresholdService.java +++ b/src/main/java/com/isums/assetservice/services/IotThresholdService.java @@ -41,8 +41,8 @@ public void seedDefaults(UUID houseId) { build(houseId, null, "w_lpm", null, 50.0, "CRITICAL"), build(houseId, null, "d_w_tot", null, 100.0, "WARNING"), build(houseId, null, "gas_ppm", null, 300.0, "CRITICAL"), - build(houseId, null, "temperature", null, 45.0, "WARNING"), - build(houseId, null, "humidity", null, 90.0, "INFO") + build(houseId, null, "temperature", null, 80.0, "CRITICAL"), + build(houseId, null, "humidity", null, 90.0, "WARNING") ); defaults.stream() diff --git a/src/main/java/com/isums/assetservice/services/S3ServiceImpl.java b/src/main/java/com/isums/assetservice/services/S3ServiceImpl.java index 8f17dac..dadb238 100644 --- a/src/main/java/com/isums/assetservice/services/S3ServiceImpl.java +++ b/src/main/java/com/isums/assetservice/services/S3ServiceImpl.java @@ -8,9 +8,16 @@ import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; import java.io.IOException; +import java.security.MessageDigest; +import java.time.Duration; +import java.util.HexFormat; +import java.util.Optional; import java.util.UUID; @Service @@ -18,18 +25,18 @@ @Slf4j public class S3ServiceImpl { + private static final int DEFAULT_PRESIGN_TTL_MINUTES = 60; + private final S3Client s3Client; + private final S3Presigner s3Presigner; @Value("${app.s3.bucket}") private String bucket; - @Value("${app.s3.cloudfront-domain}") - private String cloudFrontDomain; - public String upload(MultipartFile file, String folder) { try { String ext = getExtension(file.getOriginalFilename()); - String key = "media/" + "folder" + "/" + UUID.randomUUID() + "." + ext; + String key = "media/" + folder + "/" + UUID.randomUUID() + "." + ext; s3Client.putObject(PutObjectRequest.builder() .bucket(bucket) @@ -60,7 +67,31 @@ public void delete(String key) { } public String getImageUrl(String key) { - return "https://" + cloudFrontDomain + "/" + key; + return presignedUrl(key, DEFAULT_PRESIGN_TTL_MINUTES); + } + + public String presignedUrl(String key, int ttlMinutes) { + if (key == null || key.isBlank()) return null; + if (key.matches("(?i)^https?://.*")) return key; + GetObjectPresignRequest req = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(ttlMinutes)) + .getObjectRequest(r -> r.bucket(bucket).key(key)) + .build(); + return s3Presigner.presignGetObject(req).url().toString(); + } + + public Optional getObjectSha256(String key) { + try { + byte[] bytes = s3Client.getObjectAsBytes(GetObjectRequest.builder() + .bucket(bucket) + .key(key) + .build()).asByteArray(); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return Optional.of(HexFormat.of().formatHex(digest.digest(bytes))); + } catch (Exception e) { + log.warn("S3 hash failed key={}: {}", key, e.getMessage()); + return Optional.empty(); + } } private String getExtension(String fileName) { diff --git a/src/main/java/com/isums/assetservice/services/TranslationAutoFillService.java b/src/main/java/com/isums/assetservice/services/TranslationAutoFillService.java new file mode 100644 index 0000000..81bc2b0 --- /dev/null +++ b/src/main/java/com/isums/assetservice/services/TranslationAutoFillService.java @@ -0,0 +1,93 @@ +package com.isums.assetservice.services; + +import common.i18n.TranslationMap; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.services.translate.TranslateClient; +import software.amazon.awssdk.services.translate.model.TranslateTextRequest; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TranslationAutoFillService { + + private static final List SUPPORTED_LANGUAGES = List.of("vi", "en", "ja"); + + private final TranslateClient translateClient; + + public TranslationMap complete(Map input) { + Map normalized = normalize(input); + if (normalized.isEmpty()) { + throw new RuntimeException("At least one translation is required"); + } + + String sourceLanguage = resolveSourceLanguage(normalized); + String sourceText = normalized.get(sourceLanguage); + + for (String targetLanguage : SUPPORTED_LANGUAGES) { + if (!hasText(normalized.get(targetLanguage))) { + normalized.put(targetLanguage, translateOrFallback(sourceText, sourceLanguage, targetLanguage)); + } + } + + return TranslationMap.of(normalized); + } + + private Map normalize(Map input) { + Map normalized = new LinkedHashMap<>(); + if (input == null) { + return normalized; + } + + for (Map.Entry entry : input.entrySet()) { + if (entry.getKey() == null || entry.getKey().isBlank() || !hasText(entry.getValue())) { + continue; + } + normalized.put(entry.getKey().trim().toLowerCase(Locale.ROOT), entry.getValue().trim()); + } + return normalized; + } + + private String resolveSourceLanguage(Map normalized) { + if (hasText(normalized.get("vi"))) { + return "vi"; + } + + return normalized.entrySet().stream() + .filter(entry -> SUPPORTED_LANGUAGES.contains(entry.getKey()) && hasText(entry.getValue())) + .map(Map.Entry::getKey) + .findFirst() + .orElseGet(() -> normalized.entrySet().stream() + .filter(entry -> hasText(entry.getValue())) + .map(Map.Entry::getKey) + .findFirst() + .orElseThrow(() -> new RuntimeException("At least one translation is required"))); + } + + private String translateOrFallback(String sourceText, String sourceLanguage, String targetLanguage) { + if (targetLanguage.equalsIgnoreCase(sourceLanguage)) { + return sourceText; + } + + try { + return translateClient.translateText(TranslateTextRequest.builder() + .text(sourceText) + .sourceLanguageCode(sourceLanguage) + .targetLanguageCode(targetLanguage) + .build()).translatedText(); + } catch (Exception ex) { + log.warn("Translation fallback source={} target={}: {}", sourceLanguage, targetLanguage, ex.getMessage()); + return sourceText; + } + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/src/main/java/com/isums/assetservice/services/UtilityAlertsServiceImpl.java b/src/main/java/com/isums/assetservice/services/UtilityAlertsServiceImpl.java new file mode 100644 index 0000000..8fad6b1 --- /dev/null +++ b/src/main/java/com/isums/assetservice/services/UtilityAlertsServiceImpl.java @@ -0,0 +1,274 @@ +package com.isums.assetservice.services; + +import com.isums.assetservice.domains.dtos.ForecastScopeDto; +import com.isums.assetservice.domains.dtos.UtilityAlertItem; +import com.isums.assetservice.domains.dtos.UtilityAlertSummary; +import com.isums.assetservice.domains.dtos.UtilityAlertsResponse; +import com.isums.assetservice.domains.entities.IotThreshold; +import com.isums.assetservice.domains.enums.UtilityMetric; +import com.isums.assetservice.domains.enums.UtilityStatus; +import com.isums.assetservice.infrastructures.abstracts.IotForecastService; +import com.isums.assetservice.infrastructures.abstracts.UtilityAlertsService; +import com.isums.assetservice.infrastructures.grpcs.GrpcUserClient; +import com.isums.assetservice.infrastructures.grpcs.HouseGrpcImpl; +import com.isums.assetservice.infrastructures.repositories.IotThresholdRepository; +import com.isums.houseservice.grpc.HouseResponse; +import com.isums.userservice.grpc.UserResponse; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.QueryRequest; +import software.amazon.awssdk.services.dynamodb.model.QueryResponse; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class UtilityAlertsServiceImpl implements UtilityAlertsService { + + private static final ZoneId VN = ZoneId.of("Asia/Ho_Chi_Minh"); + private static final DateTimeFormatter MONTH_FMT = DateTimeFormatter.ofPattern("yyyy-MM"); + private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ISO_LOCAL_DATE; + + private static final double WARN_RATIO = 0.80; + private static final double CRIT_RATIO = 1.00; + + private final IotForecastService iotForecastService; + private final IotThresholdRepository thresholdRepository; + private final HouseGrpcImpl houseGrpc; + private final GrpcUserClient userGrpc; + private final DynamoDbClient dynamoDb; + private final MeterRegistry meterRegistry; + + @Value("${app.ddb.alertsTable:esp32_alerts}") + private String alertsTable; + + @Override + @Cacheable( + value = "utilityAlerts", + key = "#keycloakId + ':' + #metric.name() + ':' + T(java.time.LocalDate).now(T(java.time.ZoneId).of('Asia/Ho_Chi_Minh')).format(T(java.time.format.DateTimeFormatter).ofPattern('yyyy-MM'))" + ) + public UtilityAlertsResponse listAlerts(String keycloakId, UtilityMetric metric) { + Timer.Sample sample = Timer.start(meterRegistry); + try { + return doListAlerts(keycloakId, metric); + } finally { + sample.stop(meterRegistry.timer( + "utility_alerts_build_duration", + "metric", metric.name().toLowerCase(Locale.ROOT))); + } + } + + private UtilityAlertsResponse doListAlerts(String keycloakId, UtilityMetric metric) { + String month = LocalDate.now(VN).format(MONTH_FMT); + String today = LocalDate.now(VN).format(DATE_FMT); + + UserResponse caller; + try { + caller = userGrpc.getUserIdAndRoleByKeyCloakId(keycloakId); + } catch (Exception e) { + log.warn("[UtilityAlerts] user lookup failed keycloakId={}: {}", keycloakId, e.getMessage()); + return emptyResponse(metric, month); + } + + UUID userId; + try { + userId = UUID.fromString(caller.getId()); + } catch (Exception e) { + log.warn("[UtilityAlerts] caller has non-UUID id={}, returning empty", caller.getId()); + return emptyResponse(metric, month); + } + + Set roles = new java.util.HashSet<>(caller.getRolesList()); + + List houses = resolveScopedHouses(userId, roles); + if (houses.isEmpty()) { + log.debug("[UtilityAlerts] no houses in scope userId={} roles={}", userId, roles); + return emptyResponse(metric, month); + } + + Map limitByHouse = loadMonthlyLimits(houses, metric); + + List items = new ArrayList<>(houses.size()); + double sumUsage = 0; + int countWithData = 0; + AtomicInteger housesOverThreshold = new AtomicInteger(0); + + for (HouseResponse h : houses) { + UUID houseId; + try { houseId = UUID.fromString(h.getId()); } catch (Exception skip) { continue; } + + ForecastScopeDto fc = safeForecast(houseId, metric, month); + + Double currentUsage = fc != null ? fc.usedSoFar() : null; + Double forecastTotal = fc != null ? fc.totalEstimate() : null; + Integer daysLeft = fc != null ? fc.daysLeft() : null; + Long lastReadingAt = fc != null && fc.forecastedAt() > 0 ? fc.forecastedAt() : null; + + Double monthlyLimit = limitByHouse.get(houseId); + Double usagePercent = (monthlyLimit != null && monthlyLimit > 0 && currentUsage != null) + ? 100.0 * currentUsage / monthlyLimit + : null; + + UtilityStatus status = deriveStatus(currentUsage, monthlyLimit); + if (status == UtilityStatus.WARNING || status == UtilityStatus.CRITICAL) { + housesOverThreshold.incrementAndGet(); + } + + AlertCountToday ac = countActiveAlerts(houseId, today); + + items.add(new UtilityAlertItem( + houseId, + h.getName(), + null , + currentUsage, + forecastTotal, + monthlyLimit, + usagePercent == null ? null : round(usagePercent, 1), + metric.unit(), + status, + ac.count, + daysLeft, + lastReadingAt, + ac.lastTs + )); + + if (currentUsage != null) { + sumUsage += currentUsage; + countWithData++; + } + } + + UtilityAlertSummary summary = new UtilityAlertSummary( + countWithData > 0 ? round(sumUsage, 2) : null, + countWithData > 0 ? round(sumUsage / countWithData, 2) : null, + houses.size(), + housesOverThreshold.get() + ); + + meterRegistry.counter( + "utility_alerts_requests_total", + "metric", metric.name().toLowerCase(Locale.ROOT), + "houses", String.valueOf(houses.size()) + ).increment(); + + return new UtilityAlertsResponse(metric, metric.unit(), month, items, summary); + } + + private List resolveScopedHouses(UUID userId, Set roles) { + if (roles.contains("LANDLORD") || roles.contains("ADMIN")) { + return houseGrpc.listHousesByLandlord(userId); + } + if (roles.contains("MANAGER")) { + return houseGrpc.listHousesByManager(userId); + } + return Collections.emptyList(); + } + + private Map loadMonthlyLimits(List houses, UtilityMetric metric) { + List ids = houses.stream() + .map(h -> { try { return UUID.fromString(h.getId()); } catch (Exception e) { return null; } }) + .filter(java.util.Objects::nonNull) + .toList(); + if (ids.isEmpty()) return Collections.emptyMap(); + + Map out = new HashMap<>(); + for (UUID id : ids) { + thresholdRepository + .findByHouseIdAndAreaIdIsNullAndMetric(id, metric.thresholdKey()) + .filter(t -> Boolean.TRUE.equals(t.getEnabled())) + .map(IotThreshold::getMaxVal) + .filter(v -> v != null && v > 0) + .ifPresent(v -> out.put(id, v)); + } + return out; + } + + private ForecastScopeDto safeForecast(UUID houseId, UtilityMetric metric, String month) { + try { + return iotForecastService.getForecast(houseId, null, metric.forecastKey(), month); + } catch (Exception e) { + log.warn("[UtilityAlerts] forecast fetch failed houseId={} metric={}: {}", + houseId, metric.forecastKey(), e.getMessage()); + return null; + } + } + + private AlertCountToday countActiveAlerts(UUID houseId, String date) { + try { + String pk = houseId + "#" + date; + QueryResponse r = dynamoDb.query(QueryRequest.builder() + .tableName(alertsTable) + .indexName("house-date-ts-index") + .keyConditionExpression("houseDatePartition = :pk") + .filterExpression("attribute_not_exists(resolved) OR resolved = :f") + .expressionAttributeValues(Map.of( + ":pk", AttributeValue.builder().s(pk).build(), + ":f", AttributeValue.builder().bool(false).build() + )) + .limit(100) + .scanIndexForward(false) + .build()); + + int count = r.items().size(); + Long lastTs = null; + if (!r.items().isEmpty()) { + AttributeValue ts = r.items().get(0).get("ts"); + if (ts != null && ts.n() != null) { + try { lastTs = Long.parseLong(ts.n()); } catch (NumberFormatException ignore) { } + } + } + return new AlertCountToday(count, lastTs); + } catch (Exception e) { + log.warn("[UtilityAlerts] alert count failed houseId={}: {}", houseId, e.getMessage()); + return new AlertCountToday(0, null); + } + } + + private UtilityStatus deriveStatus(Double currentUsage, Double monthlyLimit) { + if (currentUsage == null || monthlyLimit == null || monthlyLimit <= 0) { + return UtilityStatus.NO_DATA; + } + double ratio = currentUsage / monthlyLimit; + if (ratio >= CRIT_RATIO) return UtilityStatus.CRITICAL; + if (ratio >= WARN_RATIO) return UtilityStatus.WARNING; + return UtilityStatus.GOOD; + } + + private UtilityAlertsResponse emptyResponse(UtilityMetric metric, String month) { + return new UtilityAlertsResponse( + metric, + metric.unit(), + month, + Collections.emptyList(), + new UtilityAlertSummary(null, null, 0, 0) + ); + } + + private static double round(double v, int scale) { + double factor = Math.pow(10, scale); + return Math.round(v * factor) / factor; + } + + private record AlertCountToday(int count, Long lastTs) { } +} + diff --git a/src/main/java/common/i18n/TranslationMap.java b/src/main/java/common/i18n/TranslationMap.java new file mode 100644 index 0000000..d9f38a7 --- /dev/null +++ b/src/main/java/common/i18n/TranslationMap.java @@ -0,0 +1,116 @@ +package common.i18n; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; + +public class TranslationMap { + private final Map translations; + + public TranslationMap() { + this.translations = new LinkedHashMap<>(); + } + + public TranslationMap(Map translations) { + this.translations = normalize(translations); + } + + public static TranslationMap of(Map translations) { + return new TranslationMap(translations); + } + + public static TranslationMap ofDefault(String value) { + Map map = new LinkedHashMap<>(); + if (value != null) { + map.put("vi", value); + } + return new TranslationMap(map); + } + + public Map getTranslations() { + return translations; + } + + public String resolve() { + if (translations.isEmpty()) return null; + String lang = resolveLanguage(); + if (lang == null || lang.isBlank()) lang = Locale.getDefault().getLanguage(); + String direct = translations.get(lang); + if (direct != null && !direct.isBlank()) return direct; + String vi = translations.get("vi"); + if (vi != null && !vi.isBlank()) return vi; + String en = translations.get("en"); + if (en != null && !en.isBlank()) return en; + return translations.values().stream() + .filter(v -> v != null && !v.isBlank()) + .findFirst() + .orElse(null); + } + + /** Language resolved from header → JWT → LocaleContext → JVM default. "vi"/"en"/"ja". */ + public static String currentLanguage() { + return new TranslationMap().resolveLanguage(); + } + + private String resolveLanguage() { + String headerLanguage = resolveHeaderLanguage(); + if (headerLanguage != null) return headerLanguage; + + String jwtLanguage = resolveJwtLanguage(); + if (jwtLanguage != null) return jwtLanguage; + + String localeLanguage = normalizeLanguage(LocaleContextHolder.getLocale() != null + ? LocaleContextHolder.getLocale().getLanguage() + : null); + if (localeLanguage != null) return localeLanguage; + + return normalizeLanguage(Locale.getDefault().getLanguage()); + } + + private String resolveHeaderLanguage() { + if (!(RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes)) { + return null; + } + HttpServletRequest request = attributes.getRequest(); + if (request == null) return null; + String header = request.getHeader("Accept-Language"); + if (header == null || header.isBlank()) return null; + String candidate = header.split(",")[0].trim(); + if (candidate.isBlank() || candidate.startsWith("*")) return null; + candidate = candidate.split(";")[0].trim(); + return normalizeLanguage(Locale.forLanguageTag(candidate).getLanguage()); + } + + private String resolveJwtLanguage() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !(authentication.getPrincipal() instanceof Jwt jwt)) { + return null; + } + return normalizeLanguage(jwt.getClaimAsString("locale")); + } + + private String normalizeLanguage(String language) { + if (language == null || language.isBlank()) { + return null; + } + return language.trim().toLowerCase(Locale.ROOT); + } + + private static Map normalize(Map input) { + Map out = new LinkedHashMap<>(); + if (input == null) return out; + for (Map.Entry entry : input.entrySet()) { + if (entry.getKey() == null || entry.getKey().isBlank()) continue; + out.put(entry.getKey().trim().toLowerCase(Locale.ROOT), entry.getValue()); + } + return out; + } +} diff --git a/src/main/java/common/i18n/TranslationMapConverter.java b/src/main/java/common/i18n/TranslationMapConverter.java new file mode 100644 index 0000000..d68af03 --- /dev/null +++ b/src/main/java/common/i18n/TranslationMapConverter.java @@ -0,0 +1,34 @@ +package common.i18n; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +import java.util.Map; + +@Converter(autoApply = false) +public class TranslationMapConverter implements AttributeConverter { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + + @Override + public String convertToDatabaseColumn(TranslationMap attribute) { + if (attribute == null) return null; + try { + return MAPPER.writeValueAsString(attribute.getTranslations()); + } catch (Exception e) { + throw new IllegalArgumentException("Cannot serialize translations", e); + } + } + + @Override + public TranslationMap convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isBlank()) return new TranslationMap(); + try { + return TranslationMap.of(MAPPER.readValue(dbData, MAP_TYPE)); + } catch (Exception ignored) { + return TranslationMap.ofDefault(dbData); + } + } +} \ No newline at end of file diff --git a/src/main/resources/db/migration/V20260425_1550__asset_event_translations.sql b/src/main/resources/db/migration/V20260425_1550__asset_event_translations.sql new file mode 100644 index 0000000..b56111d --- /dev/null +++ b/src/main/resources/db/migration/V20260425_1550__asset_event_translations.sql @@ -0,0 +1,8 @@ +-- Phase 5e i18n: per-locale translation map for AssetEvent.note. AssetItem +-- already has note_translations from a prior migration; this fills the gap on +-- AssetEvent rows so action history is readable across locales. +ALTER TABLE "assetEvents" + ADD COLUMN IF NOT EXISTS note_translations TEXT; + +COMMENT ON COLUMN "assetEvents".note_translations IS + 'JSON map of locale -> translated event note. Reserved keys: _source, _auto.'; diff --git a/src/main/resources/db/migration/V20260425_1715__asset_event_image_type.sql b/src/main/resources/db/migration/V20260425_1715__asset_event_image_type.sql new file mode 100644 index 0000000..403ed58 --- /dev/null +++ b/src/main/resources/db/migration/V20260425_1715__asset_event_image_type.sql @@ -0,0 +1,5 @@ +ALTER TABLE asset_event_images + ADD COLUMN IF NOT EXISTS type VARCHAR(10); + +CREATE INDEX IF NOT EXISTS idx_asset_event_images_event_type_created_at + ON asset_event_images (event_id, type, created_at); diff --git a/src/main/resources/db/migration/V20260503_1530__iot_device_capabilities.sql b/src/main/resources/db/migration/V20260503_1530__iot_device_capabilities.sql new file mode 100644 index 0000000..333a7e6 --- /dev/null +++ b/src/main/resources/db/migration/V20260503_1530__iot_device_capabilities.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS iot_device_capabilities ( + device_id UUID NOT NULL, + capability VARCHAR(255) NOT NULL, + CONSTRAINT fk_iot_device_capabilities_device + FOREIGN KEY (device_id) REFERENCES iot_devices(id) ON DELETE CASCADE, + CONSTRAINT pk_iot_device_capabilities + PRIMARY KEY (device_id, capability) +); + +CREATE INDEX IF NOT EXISTS idx_iot_device_capabilities_device + ON iot_device_capabilities (device_id); diff --git a/src/main/resources/db/migration/V20260507_1500__iot_safety_config.sql b/src/main/resources/db/migration/V20260507_1500__iot_safety_config.sql new file mode 100644 index 0000000..152ca08 --- /dev/null +++ b/src/main/resources/db/migration/V20260507_1500__iot_safety_config.sql @@ -0,0 +1,96 @@ +-- IoT indoor-safety configuration (versioned, immutable history). +-- +-- One row = one published version of the safety config. +-- Active version = WHERE expired_at IS NULL ORDER BY effective_from DESC LIMIT 1. +-- +-- Mutation rules: +-- - INSERT new row when admin updates config +-- → previously active row gets expired_at = effective_at_of_new +-- - NEVER UPDATE config_json once a row is published +-- - Seed row uses all-zeros UUID as created_by (system marker) +-- +-- Why JSON column instead of normalized tables: +-- - Config has heterogeneous nested arrays (sensors, gaps, thresholds, +-- score components, bands) with different shapes — 5 sub-tables would +-- be over-normalized for a small registry that's read >> written. +-- - PostgreSQL native JSONB enables admin tooling to inspect/diff +-- versions easily. Schema evolution is forward-compatible. + +CREATE TABLE IF NOT EXISTS iot_safety_config_version ( + id uuid PRIMARY KEY, + version varchar(80) NOT NULL, + config_json jsonb NOT NULL, + effective_from timestamptz NOT NULL DEFAULT now(), + expired_at timestamptz, + notes varchar(1000), + created_by uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + expired_by uuid, + + CONSTRAINT chk_isc_dates CHECK (expired_at IS NULL OR expired_at > effective_from) +); + +CREATE INDEX IF NOT EXISTS idx_isc_active + ON iot_safety_config_version (effective_from DESC) + WHERE expired_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_isc_history + ON iot_safety_config_version (effective_from DESC); + +INSERT INTO iot_safety_config_version (id, version, config_json, created_by, notes) +VALUES ( + '5a1e7c00-0000-0000-0000-000000000001', + 'iot-safety-2026-05', + '{ + "activeSensors": [ + { + "code": "MQ2", + "displayName": "MQ-2 — Cảm biến gas dễ cháy + khói", + "model": "MQ-2 (Hanwei Electronics)", + "measures": ["gas_ppm", "smoke"], + "accuracyNotes": "Cross-sensitive: phát hiện LPG/methane/propane/khói nhưng không phân biệt được loại gas. Đơn vị ppm là xấp xỉ, chưa calibrated cho từng gas riêng.", + "calibrationStatus": "factory_default", + "datasheet": "https://www.pololu.com/file/0J309/MQ2.pdf" + }, + { + "code": "DHT22", + "displayName": "DHT22 — Cảm biến nhiệt độ và độ ẩm", + "model": "DHT22 / AM2302 (Aosong)", + "measures": ["temp_c", "humidity_pct"], + "accuracyNotes": "Sai số ±0.5°C, ±2% RH. Phù hợp cho indoor monitoring, không phù hợp cho ứng dụng laboratory.", + "calibrationStatus": "factory_default", + "datasheet": "https://www.sparkfun.com/datasheets/Sensors/Temperature/DHT22.pdf" + } + ], + "capabilityGaps": [ + {"metric":"pm25","displayName":"PM2.5","description":"Bụi mịn dưới 2.5μm — chỉ số chính của AQI","requiredSensor":"PMS5003 (Plantower) hoặc tương đương","sensorPriceVndApprox":700000,"standardRef":"QCVN 05:2023/BTNMT (giới hạn 24h: 50μg/m³)"}, + {"metric":"pm10","displayName":"PM10","description":"Bụi mịn dưới 10μm","requiredSensor":"PMS5003 hoặc SDS011","sensorPriceVndApprox":400000,"standardRef":"QCVN 05:2023/BTNMT (giới hạn 24h: 100μg/m³)"}, + {"metric":"co","displayName":"CO","description":"Carbon monoxide — khí CO độc","requiredSensor":"MQ-7 hoặc MH-Z19B (CO + CO2 NDIR)","sensorPriceVndApprox":400000,"standardRef":"QCVN 05:2023/BTNMT (giới hạn 1h: 30mg/m³)"}, + {"metric":"co2","displayName":"CO₂","description":"Carbon dioxide — chỉ báo thông gió","requiredSensor":"MH-Z19B / SCD30 / SCD41","sensorPriceVndApprox":500000,"standardRef":"ASHRAE 62.1 (mức an toàn <1000 ppm)"}, + {"metric":"no2","displayName":"NO₂","description":"Nitrogen dioxide","requiredSensor":"MiCS-2714 hoặc Spec NO2","sensorPriceVndApprox":1500000,"standardRef":"QCVN 05:2023/BTNMT (giới hạn 1h: 200μg/m³)"}, + {"metric":"voc","displayName":"VOC","description":"Hợp chất hữu cơ dễ bay hơi","requiredSensor":"BME680 hoặc CCS811","sensorPriceVndApprox":250000,"standardRef":"WHO Indoor Air Quality Guidelines"} + ], + "thresholds": [ + {"metric":"gas_ppm","displayName":"Khí gas dễ cháy","unit":"ppm","comfortMin":0,"comfortMax":50,"warningMin":50,"warningMax":150,"criticalThreshold":200,"criticalIsHigh":true,"standardRef":"OSHA Combustible Gas Lower Explosion Limit (LEL); ngưỡng cảnh báo cháy nổ thông dụng cho cảm biến MQ-2"}, + {"metric":"temp_c","displayName":"Nhiệt độ","unit":"°C","comfortMin":22,"comfortMax":28,"warningMin":18,"warningMax":32,"criticalThreshold":null,"criticalIsHigh":null,"standardRef":"ASHRAE Standard 55-2020 — Thermal Environmental Conditions for Human Occupancy (climate zone hot-humid 22-28°C)"}, + {"metric":"humidity_pct","displayName":"Độ ẩm","unit":"%","comfortMin":40,"comfortMax":70,"warningMin":30,"warningMax":80,"criticalThreshold":null,"criticalIsHigh":null,"standardRef":"EPA Indoor Air Quality (IAQ) Guidelines — dải comfort 40-70%, ngoài 30-80% gây khó chịu hoặc nấm mốc"} + ], + "scoreComponents": [ + {"metric":"gas_ppm","weight":0.50,"normalizationStrategy":"ratioToCritical","normalizationParam":300}, + {"metric":"temp_c","weight":0.25,"normalizationStrategy":"absDeviationFromCenter","normalizationParam":25}, + {"metric":"humidity_pct","weight":0.25,"normalizationStrategy":"absDeviationFromCenter","normalizationParam":55} + ], + "bands": [ + {"code":"SAFE","label":"An toàn","description":"Không phát hiện rò rỉ gas, nhiệt độ và độ ẩm dễ chịu","scoreMax":30,"severity":"GOOD"}, + {"code":"CAUTION","label":"Cần chú ý","description":"Một số chỉ số ngoài dải khuyến nghị, hãy kiểm tra","scoreMax":60,"severity":"WARNING"}, + {"code":"POOR","label":"Bất ổn","description":"Nên thông gió, kiểm tra bếp gas và điều hòa","scoreMax":85,"severity":"WARNING"}, + {"code":"DANGER","label":"Nguy hiểm","description":"Khả năng cao có rò rỉ gas — mở cửa thông gió, không bật bếp","scoreMax":100,"severity":"CRITICAL"} + ], + "disclaimer": "Chỉ số an toàn ở đây là composite weighted score từ MQ-2 + DHT22, ưu tiên phát hiện rò rỉ gas + duy trì comfort. KHÔNG phải AQI theo QCVN 05:2023/BTNMT (yêu cầu PM2.5/PM10/CO/O3/NO2/SO2 đo bằng cảm biến chuyên dụng).", + "scoreFormulaDescription": "Score = 0.5 × ratioToCritical(gas, 300ppm) + 0.25 × absDeviation(temp, 25°C) × 6 + 0.25 × absDeviation(humidity, 55%) × 2. Tất cả thành phần clamp về 0-100. Score càng thấp càng an toàn.", + "standardsApplied": "ASHRAE 55-2020 (thermal comfort) · EPA IAQ Guidelines (humidity) · OSHA LEL (combustible gas) · QCVN 05:2023/BTNMT (reference for non-implemented metrics)" + }'::jsonb, + '00000000-0000-0000-0000-000000000000', + 'Initial seed: hardware MQ-2 + DHT22, citations cho QCVN 05/ASHRAE 55/EPA IAQ/OSHA LEL.' +) +ON CONFLICT (id) DO NOTHING; diff --git a/src/main/resources/db/migration/V20260507_1700__iot_safety_config_realign.sql b/src/main/resources/db/migration/V20260507_1700__iot_safety_config_realign.sql new file mode 100644 index 0000000..8255dcb --- /dev/null +++ b/src/main/resources/db/migration/V20260507_1700__iot_safety_config_realign.sql @@ -0,0 +1,59 @@ +-- Realign iot_safety_config thresholds to match values actually deployed +-- in DynamoDB esp32_thresholds (the source of truth that the Lambda +-- threshold-checker reads when emitting alerts). +-- +-- Discrepancy found via direct DDB scan on 2026-05-07: +-- gas_ppm DDB CRITICAL=300 | safety_config CRITICAL=200 → align to 300 +-- temperature DDB CRITICAL=80 | safety_config no critical → align critical=80, warning band 18-38 +-- (humidity is not in DDB / not an alarm; kept as comfort hint only) +-- +-- Pattern: expire previous version (effective_at_of_new = now), publish +-- new immutable row. Mobile fetches latest within 1h cache. + +UPDATE iot_safety_config_version +SET expired_at = now(), + expired_by = '00000000-0000-0000-0000-000000000000' +WHERE id = '5a1e7c00-0000-0000-0000-000000000001' + AND expired_at IS NULL; + +INSERT INTO iot_safety_config_version (id, version, config_json, created_by, notes) +VALUES ( + '5a1e7c00-0000-0000-0000-000000000002', + 'iot-safety-2026-05-realigned', + '{ + "activeSensors": [ + {"code":"MQ2","displayName":"MQ-2 — Cảm biến gas dễ cháy + khói","model":"MQ-2 (Hanwei Electronics)","measures":["gas_ppm","smoke"],"accuracyNotes":"Cross-sensitive: phát hiện LPG/methane/propane/khói nhưng không phân biệt được loại gas. Đơn vị ppm là xấp xỉ.","calibrationStatus":"factory_default","datasheet":"https://www.pololu.com/file/0J309/MQ2.pdf"}, + {"code":"DHT22","displayName":"DHT22 — Cảm biến nhiệt độ và độ ẩm","model":"DHT22 / AM2302 (Aosong)","measures":["temp_c","humidity_pct"],"accuracyNotes":"Sai số ±0.5°C, ±2% RH. Phù hợp indoor monitoring.","calibrationStatus":"factory_default","datasheet":"https://www.sparkfun.com/datasheets/Sensors/Temperature/DHT22.pdf"} + ], + "capabilityGaps": [ + {"metric":"pm25","displayName":"PM2.5","description":"Bụi mịn dưới 2.5μm — chỉ số chính của AQI","requiredSensor":"PMS5003 (Plantower) hoặc tương đương","sensorPriceVndApprox":700000,"standardRef":"QCVN 05:2023/BTNMT (giới hạn 24h: 50μg/m³)"}, + {"metric":"pm10","displayName":"PM10","description":"Bụi mịn dưới 10μm","requiredSensor":"PMS5003 hoặc SDS011","sensorPriceVndApprox":400000,"standardRef":"QCVN 05:2023/BTNMT (giới hạn 24h: 100μg/m³)"}, + {"metric":"co","displayName":"CO","description":"Carbon monoxide — khí CO độc","requiredSensor":"MQ-7 hoặc MH-Z19B","sensorPriceVndApprox":400000,"standardRef":"QCVN 05:2023/BTNMT (giới hạn 1h: 30mg/m³)"}, + {"metric":"co2","displayName":"CO₂","description":"Carbon dioxide — chỉ báo thông gió","requiredSensor":"MH-Z19B / SCD30 / SCD41","sensorPriceVndApprox":500000,"standardRef":"ASHRAE 62.1 (mức an toàn <1000 ppm)"}, + {"metric":"no2","displayName":"NO₂","description":"Nitrogen dioxide","requiredSensor":"MiCS-2714 hoặc Spec NO2","sensorPriceVndApprox":1500000,"standardRef":"QCVN 05:2023/BTNMT (giới hạn 1h: 200μg/m³)"}, + {"metric":"voc","displayName":"VOC","description":"Hợp chất hữu cơ dễ bay hơi","requiredSensor":"BME680 hoặc CCS811","sensorPriceVndApprox":250000,"standardRef":"WHO Indoor Air Quality Guidelines"} + ], + "thresholds": [ + {"metric":"gas_ppm","displayName":"Khí gas dễ cháy","unit":"ppm","comfortMin":0,"comfortMax":50,"warningMin":50,"warningMax":300,"criticalThreshold":300,"criticalIsHigh":true,"standardRef":"OSHA Combustible Gas LEL · Khớp với esp32_thresholds DynamoDB (Lambda alarm) max_val=300 CRITICAL"}, + {"metric":"temp_c","displayName":"Nhiệt độ","unit":"°C","comfortMin":22,"comfortMax":28,"warningMin":18,"warningMax":38,"criticalThreshold":80,"criticalIsHigh":true,"standardRef":"ASHRAE 55-2020 (comfort 22-28°C) · Lambda fire-risk alarm tại 80°C — khớp esp32_thresholds DynamoDB"}, + {"metric":"humidity_pct","displayName":"Độ ẩm","unit":"%","comfortMin":40,"comfortMax":70,"warningMin":30,"warningMax":90,"criticalThreshold":null,"criticalIsHigh":null,"standardRef":"EPA IAQ Guidelines (comfort 40-70%) · Hiển thị FE; chưa có alarm Lambda cho humidity"} + ], + "scoreComponents": [ + {"metric":"gas_ppm","weight":0.50,"normalizationStrategy":"ratioToCritical","normalizationParam":300}, + {"metric":"temp_c","weight":0.25,"normalizationStrategy":"absDeviationFromCenter","normalizationParam":25}, + {"metric":"humidity_pct","weight":0.25,"normalizationStrategy":"absDeviationFromCenter","normalizationParam":55} + ], + "bands": [ + {"code":"SAFE","label":"An toàn","description":"Không phát hiện rò rỉ gas, nhiệt độ và độ ẩm dễ chịu","scoreMax":30,"severity":"GOOD"}, + {"code":"CAUTION","label":"Cần chú ý","description":"Một số chỉ số ngoài dải khuyến nghị","scoreMax":60,"severity":"WARNING"}, + {"code":"POOR","label":"Bất ổn","description":"Nên thông gió, kiểm tra bếp gas và điều hòa","scoreMax":85,"severity":"WARNING"}, + {"code":"DANGER","label":"Nguy hiểm","description":"Khả năng cao có rò rỉ gas — mở cửa thông gió, không bật bếp","scoreMax":100,"severity":"CRITICAL"} + ], + "disclaimer": "Chỉ số an toàn ở đây là composite weighted score từ MQ-2 + DHT22, ưu tiên phát hiện rò rỉ gas + duy trì comfort. Ngưỡng critical (gas 300ppm, temperature 80°C) khớp đúng với esp32_thresholds DynamoDB — Lambda threshold-checker dùng cùng giá trị để fire push notification + voice call. KHÔNG phải AQI theo QCVN 05:2023/BTNMT.", + "scoreFormulaDescription": "Score = 0.5 × ratioToCritical(gas, 300ppm) + 0.25 × absDeviation(temp, 25°C) × 6 + 0.25 × absDeviation(humidity, 55%) × 2. Tất cả thành phần clamp 0-100. Score càng thấp càng an toàn.", + "standardsApplied": "ASHRAE 55-2020 · EPA IAQ Guidelines · OSHA LEL · QCVN 05:2023/BTNMT (reference) · DynamoDB esp32_thresholds (single source of truth cho alarm)" + }'::jsonb, + '00000000-0000-0000-0000-000000000000', + 'Realign thresholds với DynamoDB esp32_thresholds (gas 300 critical, temp 80 critical) — verified via aws dynamodb scan 2026-05-07.' +) +ON CONFLICT (id) DO NOTHING; diff --git a/src/main/resources/db/migration/V20260507_1800__iot_threshold_align_temp_humidity.sql b/src/main/resources/db/migration/V20260507_1800__iot_threshold_align_temp_humidity.sql new file mode 100644 index 0000000..c690875 --- /dev/null +++ b/src/main/resources/db/migration/V20260507_1800__iot_threshold_align_temp_humidity.sql @@ -0,0 +1,73 @@ +-- Backfill temperature + humidity threshold rows for any existing house +-- that doesn't have them. Match values just deployed to DynamoDB +-- esp32_thresholds (verified via aws dynamodb scan 2026-05-07): +-- temperature: max_val=80, severity=CRITICAL +-- humidity: max_val=90, severity=WARNING +-- +-- Why backfill: new IotThresholdService.seedDefaults values changed from +-- (temp 45 WARNING, humidity 90 INFO) to (temp 80 CRITICAL, humidity 90 WARNING) +-- to match what the Lambda threshold-checker actually fires on. +-- Existing houses created before this realignment would otherwise drift +-- between PG (BE source) and DDB (Lambda source). +-- +-- Idempotent: only inserts where no row exists yet for (house_id, area_id IS NULL, metric). + +INSERT INTO iot_thresholds (id, house_id, area_id, metric, min_val, max_val, enabled, severity, created_at, updated_at) +SELECT + gen_random_uuid(), + h.house_id, + NULL, + 'temperature', + NULL, + 80.0, + true, + 'CRITICAL', + now(), + now() +FROM ( + SELECT DISTINCT house_id + FROM iot_thresholds + WHERE area_id IS NULL +) h +WHERE NOT EXISTS ( + SELECT 1 FROM iot_thresholds t + WHERE t.house_id = h.house_id AND t.area_id IS NULL AND t.metric = 'temperature' +); + +INSERT INTO iot_thresholds (id, house_id, area_id, metric, min_val, max_val, enabled, severity, created_at, updated_at) +SELECT + gen_random_uuid(), + h.house_id, + NULL, + 'humidity', + NULL, + 90.0, + true, + 'WARNING', + now(), + now() +FROM ( + SELECT DISTINCT house_id + FROM iot_thresholds + WHERE area_id IS NULL +) h +WHERE NOT EXISTS ( + SELECT 1 FROM iot_thresholds t + WHERE t.house_id = h.house_id AND t.area_id IS NULL AND t.metric = 'humidity' +); + +UPDATE iot_thresholds +SET max_val = 80.0, + severity = 'CRITICAL', + updated_at = now() +WHERE area_id IS NULL + AND metric = 'temperature' + AND (max_val < 80 OR severity != 'CRITICAL'); + +UPDATE iot_thresholds +SET max_val = 90.0, + severity = 'WARNING', + updated_at = now() +WHERE area_id IS NULL + AND metric = 'humidity' + AND severity = 'INFO'; diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..ad97959 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,23 @@ + + + + + + + + true + true + true + true + true + true + true + true + true + + + + + + + \ No newline at end of file diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties new file mode 100644 index 0000000..7d295d0 --- /dev/null +++ b/src/main/resources/messages.properties @@ -0,0 +1,79 @@ + +error.not_found=Không tìm thấy +error.conflict=Xung đột dữ liệu +error.database=Lỗi cơ sở dữ liệu +error.unexpected=Lỗi không mong đợi +error.bad_request=Yêu cầu không hợp lệ +error.unauthorized=Chưa xác thực +error.access_denied=Không có quyền truy cập +error.forbidden=Bị từ chối truy cập +error.upstream=Lỗi dịch vụ phụ thuộc +error.data_integrity=Vi phạm tính toàn vẹn dữ liệu +error.serial_exists=Số serial đã tồn tại +error.validation=Dữ liệu không hợp lệ +error.internal=Lỗi máy chủ nội bộ + +# === Thành công chung === +success=Thành công +success.get=Lấy dữ liệu thành công +success.create=Tạo mới thành công +success.update=Cập nhật thành công +success.delete=Xóa thành công +success.upload=Tải lên thành công + +# === Tài sản === +asset.get_all=Lấy danh sách tài sản thành công +asset.create=Tạo tài sản thành công +asset.update=Cập nhật tài sản thành công +asset.delete=Xóa tài sản thành công +asset.get_by_id=Lấy thông tin tài sản thành công +asset.get_by_house=Lấy tài sản theo nhà thành công +asset.update_house=Cập nhật nhà cho tài sản thành công +asset.upload_images=Tải ảnh tài sản lên thành công +asset.delete_image=Xóa ảnh tài sản thành công +asset.batch_update=Cập nhật hàng loạt tài sản thành công +asset.confirm=Xác nhận tài sản thành công + +# === Danh mục tài sản === +category.get_all=Lấy danh sách danh mục thành công +category.get_by_id=Lấy thông tin danh mục thành công +category.create=Tạo danh mục thành công + +# === Tag tài sản === +tag.attach=Gắn tag thành công +tag.detach=Gỡ tag thành công +tag.transfer=Chuyển tag thành công +tag.get=Lấy thông tin tài sản qua tag thành công + +# === Log tag === +tag_log.get_by_tag=Lấy lịch sử theo tag thành công +tag_log.get_by_asset=Lấy lịch sử theo tài sản thành công + +# === Sự kiện tài sản === +event.get_all=Lấy danh sách sự kiện thành công +event.update_status=Cập nhật trạng thái sự kiện thành công +event.get_by_job=Lấy sự kiện theo công việc thành công +event.get_latest=Lấy sự kiện mới nhất thành công +event.upload_images=Tải ảnh sự kiện lên thành công + +# === IoT === +iot.power_updated=Cập nhật trạng thái nguồn điện thành công +iot.power_retrieved=Lấy trạng thái nguồn điện thành công +iot.token_generated=Tạo token thành công, hiệu lực 10 phút +iot.device_get=Lấy thông tin thiết bị IoT thành công +iot.devices_get=Lấy danh sách thiết bị IoT thành công +iot.controller_provisioned=Bộ điều khiển IoT đã được kích hoạt thành công +iot.controller_deprovisioned=Bộ điều khiển IoT đã được hủy kích hoạt thành công +iot.controller_retrieved=Lấy thông tin bộ điều khiển IoT thành công +iot.node_provisioned=Node đã được kích hoạt thành công +iot.alerts_retrieved=Lấy danh sách cảnh báo thành công +iot.command_sent=Lệnh đã được gửi thành công +iot.upload_url_generated=Tạo URL tải lên thành công +iot.ota_triggered=Kích hoạt OTA thành công +iot.ota_status=Lấy trạng thái OTA thành công +iot.threshold_get=Lấy ngưỡng giám sát thành công +iot.threshold_upsert=Cập nhật ngưỡng giám sát thành công +iot.forecast=Lấy dự báo thành công +iot.forecast_triggered=Kích hoạt dự báo thành công +iot.forecast_triggered_all=Kích hoạt dự báo cho tất cả nhà thành công +utility_alerts.retrieved=Lấy danh sách cảnh báo tiện ích thành công diff --git a/src/main/resources/messages_en.properties b/src/main/resources/messages_en.properties new file mode 100644 index 0000000..f249d9f --- /dev/null +++ b/src/main/resources/messages_en.properties @@ -0,0 +1,80 @@ + +# === Common errors === +error.not_found=Not found +error.conflict=Data conflict +error.database=Database error +error.unexpected=Unexpected error +error.bad_request=Bad request +error.unauthorized=Unauthorized +error.access_denied=Access denied +error.forbidden=Forbidden +error.upstream=Upstream service error +error.data_integrity=Data integrity violation +error.serial_exists=Serial number already exists +error.validation=Validation failed +error.internal=Internal server error + +# === Common success === +success=Success +success.get=Retrieved successfully +success.create=Created successfully +success.update=Updated successfully +success.delete=Deleted successfully +success.upload=Uploaded successfully + +# === Asset === +asset.get_all=Asset list retrieved successfully +asset.create=Asset created successfully +asset.update=Asset updated successfully +asset.delete=Assets deleted successfully +asset.get_by_id=Asset retrieved successfully +asset.get_by_house=Assets retrieved by house successfully +asset.update_house=Asset house updated successfully +asset.upload_images=Asset images uploaded successfully +asset.delete_image=Asset image deleted successfully +asset.batch_update=Assets batch updated successfully +asset.confirm=Asset confirmed successfully + +# === Category === +category.get_all=Categories retrieved successfully +category.get_by_id=Category retrieved successfully +category.create=Category created successfully + +# === Tag === +tag.attach=Tag attached successfully +tag.detach=Tag detached successfully +tag.transfer=Tag transferred successfully +tag.get=Asset retrieved by tag successfully + +# === Tag log === +tag_log.get_by_tag=Logs retrieved by tag successfully +tag_log.get_by_asset=Logs retrieved by asset successfully + +# === Asset event === +event.get_all=Events retrieved successfully +event.update_status=Event status updated successfully +event.get_by_job=Events retrieved by job successfully +event.get_latest=Latest event retrieved successfully +event.upload_images=Event images uploaded successfully + +# === IoT === +iot.power_updated=Power state updated successfully +iot.power_retrieved=Power state retrieved successfully +iot.token_generated=Token generated, valid for 10 minutes +iot.device_get=IoT device retrieved successfully +iot.devices_get=IoT devices retrieved successfully +iot.controller_provisioned=IoT controller provisioned successfully +iot.controller_deprovisioned=IoT controller deprovisioned successfully +iot.controller_retrieved=IoT controller retrieved successfully +iot.node_provisioned=Node provisioned successfully +iot.alerts_retrieved=Alerts retrieved successfully +iot.command_sent=Command sent successfully +iot.upload_url_generated=Upload URL generated successfully +iot.ota_triggered=OTA triggered successfully +iot.ota_status=OTA status retrieved successfully +iot.threshold_get=Thresholds retrieved successfully +iot.threshold_upsert=Threshold updated successfully +iot.forecast=Forecast retrieved successfully +iot.forecast_triggered=Forecast job triggered successfully +iot.forecast_triggered_all=Forecast jobs triggered for all houses successfully +utility_alerts.retrieved=Utility alerts retrieved successfully diff --git a/src/main/resources/messages_ja.properties b/src/main/resources/messages_ja.properties new file mode 100644 index 0000000..75783a8 --- /dev/null +++ b/src/main/resources/messages_ja.properties @@ -0,0 +1,79 @@ + +error.not_found=見つかりません +error.conflict=データの競合 +error.database=データベースエラー +error.unexpected=予期しないエラー +error.bad_request=不正なリクエスト +error.unauthorized=認証されていません +error.access_denied=アクセス権限がありません +error.forbidden=アクセスが拒否されました +error.upstream=上流サービスエラー +error.data_integrity=データ整合性の違反 +error.serial_exists=シリアル番号は既に存在します +error.validation=バリデーションエラー +error.internal=内部サーバーエラー + +# === 共通成功 === +success=成功 +success.get=取得成功 +success.create=作成成功 +success.update=更新成功 +success.delete=削除成功 +success.upload=アップロード成功 + +# === 資産 === +asset.get_all=資産一覧の取得成功 +asset.create=資産の作成成功 +asset.update=資産の更新成功 +asset.delete=資産の削除成功 +asset.get_by_id=資産の取得成功 +asset.get_by_house=物件別資産の取得成功 +asset.update_house=資産の物件更新成功 +asset.upload_images=資産画像のアップロード成功 +asset.delete_image=資産画像の削除成功 +asset.batch_update=資産の一括更新成功 +asset.confirm=資産の確認成功 + +# === カテゴリ === +category.get_all=カテゴリ一覧の取得成功 +category.get_by_id=カテゴリの取得成功 +category.create=カテゴリの作成成功 + +# === タグ === +tag.attach=タグの取り付け成功 +tag.detach=タグの取り外し成功 +tag.transfer=タグの移転成功 +tag.get=タグによる資産の取得成功 + +# === タグログ === +tag_log.get_by_tag=タグ別ログの取得成功 +tag_log.get_by_asset=資産別ログの取得成功 + +# === 資産イベント === +event.get_all=イベント一覧の取得成功 +event.update_status=イベントステータスの更新成功 +event.get_by_job=ジョブ別イベントの取得成功 +event.get_latest=最新イベントの取得成功 +event.upload_images=イベント画像のアップロード成功 + +# === IoT === +iot.power_updated=電源状態の更新成功 +iot.power_retrieved=電源状態の取得成功 +iot.token_generated=トークン生成成功、有効期間10分 +iot.device_get=IoTデバイスの取得成功 +iot.devices_get=IoTデバイス一覧の取得成功 +iot.controller_provisioned=IoTコントローラーのプロビジョニング成功 +iot.controller_deprovisioned=IoTコントローラーのデプロビジョニング成功 +iot.controller_retrieved=IoTコントローラーの取得成功 +iot.node_provisioned=ノードのプロビジョニング成功 +iot.alerts_retrieved=アラート一覧の取得成功 +iot.command_sent=コマンドの送信成功 +iot.upload_url_generated=アップロードURLの生成成功 +iot.ota_triggered=OTAのトリガー成功 +iot.ota_status=OTAステータスの取得成功 +iot.threshold_get=閾値の取得成功 +iot.threshold_upsert=閾値の更新成功 +iot.forecast=予測データの取得成功 +iot.forecast_triggered=予測ジョブのトリガー成功 +iot.forecast_triggered_all=全物件の予測ジョブのトリガー成功 +utility_alerts.retrieved=ユーティリティ警報の取得に成功しました diff --git a/src/test/java/com/isums/assetservice/AssetServiceApplicationTests.java b/src/test/java/com/isums/assetservice/AssetServiceApplicationTests.java index 9d99a6c..9fea6a1 100644 --- a/src/test/java/com/isums/assetservice/AssetServiceApplicationTests.java +++ b/src/test/java/com/isums/assetservice/AssetServiceApplicationTests.java @@ -1,13 +1,14 @@ package com.isums.assetservice; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest +@Disabled("Requires Postgres/Kafka/Redis/DynamoDB/AWS IoT/Lambda/S3/gRPC infrastructure; run as integration test with Testcontainers + LocalStack") class AssetServiceApplicationTests { @Test void contextLoads() { } - } diff --git a/src/test/java/com/isums/assetservice/exceptions/GlobalExceptionHandlerTest.java b/src/test/java/com/isums/assetservice/exceptions/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..414d647 --- /dev/null +++ b/src/test/java/com/isums/assetservice/exceptions/GlobalExceptionHandlerTest.java @@ -0,0 +1,137 @@ +package com.isums.assetservice.exceptions; + +import com.isums.assetservice.domains.dtos.ApiResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.context.support.StaticMessageSource; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.server.ResponseStatusException; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("GlobalExceptionHandler (asset-service)") +class GlobalExceptionHandlerTest { + + private final StaticMessageSource messageSource = new StaticMessageSource(); + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(messageSource); + + { + messageSource.addMessage("error.serial_exists", java.util.Locale.ENGLISH, "Serial number already exists"); + messageSource.addMessage("error.data_integrity", java.util.Locale.ENGLISH, "Data integrity violation"); + } + + @Test + @DisplayName("handleDb returns 500 with DB_ERROR code and root cause message") + void db() { + DataAccessException ex = new DataAccessException("outer", new RuntimeException("root")) {}; + + ResponseEntity> res = handler.handleDb(ex); + + assertThat(res.getStatusCode().value()).isEqualTo(500); + assertThat(res.getBody().getErrors().get(0).getCode()).isEqualTo("DB_ERROR"); + assertThat(res.getBody().getErrors().get(0).getMessage()).isEqualTo("root"); + } + + @Test + @DisplayName("handleBadRequest returns 400 with BAD_REQUEST code") + void badRequest() { + ResponseEntity> res = + handler.handleBadRequest(new IllegalArgumentException("nope")); + assertThat(res.getStatusCode().value()).isEqualTo(400); + assertThat(res.getBody().getErrors().get(0).getCode()).isEqualTo("BAD_REQUEST"); + } + + @Test + @DisplayName("handleGeneric returns 500 with INTERNAL_ERROR code") + void generic() { + ResponseEntity> res = handler.handleGeneric(new Exception("boom")); + assertThat(res.getStatusCode().value()).isEqualTo(500); + assertThat(res.getBody().getErrors().get(0).getCode()).isEqualTo("INTERNAL_ERROR"); + } + + @Test + @DisplayName("handleDataIntegrityViolation returns 409 with serial_number-specific message") + void dataIntegritySerial() { + DataIntegrityViolationException ex = + new DataIntegrityViolationException("duplicate key violates uk_serial_number"); + + ResponseEntity> res = handler.handleDataIntegrityViolation(ex); + + assertThat(res.getStatusCode().value()).isEqualTo(409); + assertThat(res.getBody().getMessage()).isIn("Serial number already exists", "error.serial_exists"); + } + + @Test + @DisplayName("handleDataIntegrityViolation returns 409 with generic message otherwise") + void dataIntegrityGeneric() { + DataIntegrityViolationException ex = new DataIntegrityViolationException("fk violation"); + ResponseEntity> res = handler.handleDataIntegrityViolation(ex); + + assertThat(res.getStatusCode().value()).isEqualTo(409); + assertThat(res.getBody().getMessage()).isIn("Data integrity violation", "error.data_integrity"); + } + + @Test + @DisplayName("handleConflictException returns 409") + void conflict() { + ResponseEntity> res = handler.handleConflictException(new ConflictException("dup")); + assertThat(res.getStatusCode().value()).isEqualTo(409); + assertThat(res.getBody().getMessage()).isEqualTo("dup"); + } + + @Test + @DisplayName("handleNotFound returns 404 with NOT_FOUND code") + void notFound() { + ResponseEntity> res = + handler.handleNotFound(new NotFoundException("missing")); + assertThat(res.getStatusCode().value()).isEqualTo(404); + assertThat(res.getBody().getErrors().get(0).getCode()).isEqualTo("NOT_FOUND"); + } + + @Test + @DisplayName("handleResponseStatus 402 → PAYMENT_REQUIRED code") + void rs402() { + ResponseStatusException ex = new ResponseStatusException(HttpStatus.PAYMENT_REQUIRED, "pay"); + + ResponseEntity> res = handler.handleResponseStatus(ex); + + assertThat(res.getStatusCode().value()).isEqualTo(402); + assertThat(res.getBody().getErrors().get(0).getCode()).isEqualTo("PAYMENT_REQUIRED"); + } + + @Test + @DisplayName("handleResponseStatus 503 → SERVICE_UNAVAILABLE code") + void rs503() { + ResponseStatusException ex = new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "down"); + + ResponseEntity> res = handler.handleResponseStatus(ex); + + assertThat(res.getStatusCode().value()).isEqualTo(503); + assertThat(res.getBody().getErrors().get(0).getCode()).isEqualTo("SERVICE_UNAVAILABLE"); + } + + @Test + @DisplayName("handleResponseStatus 502 → BAD_GATEWAY code") + void rs502() { + ResponseStatusException ex = new ResponseStatusException(HttpStatus.BAD_GATEWAY, "gw"); + + ResponseEntity> res = handler.handleResponseStatus(ex); + + assertThat(res.getStatusCode().value()).isEqualTo(502); + assertThat(res.getBody().getErrors().get(0).getCode()).isEqualTo("BAD_GATEWAY"); + } + + @Test + @DisplayName("handleResponseStatus other status → HTTP_") + void rsOther() { + ResponseStatusException ex = new ResponseStatusException(HttpStatus.I_AM_A_TEAPOT, "teapot"); + + ResponseEntity> res = handler.handleResponseStatus(ex); + + assertThat(res.getStatusCode().value()).isEqualTo(418); + assertThat(res.getBody().getErrors().get(0).getCode()).isEqualTo("HTTP_418"); + } +} diff --git a/src/test/java/com/isums/assetservice/infrastructures/listeners/AssetConditionEventListenerTest.java b/src/test/java/com/isums/assetservice/infrastructures/listeners/AssetConditionEventListenerTest.java new file mode 100644 index 0000000..4e1d3b9 --- /dev/null +++ b/src/test/java/com/isums/assetservice/infrastructures/listeners/AssetConditionEventListenerTest.java @@ -0,0 +1,78 @@ +package com.isums.assetservice.infrastructures.listeners; + +import com.isums.assetservice.domains.events.AssetConditionEvent; +import com.isums.assetservice.infrastructures.abstracts.AssetItemService; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.kafka.support.Acknowledgment; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AssetConditionEventListener") +class AssetConditionEventListenerTest { + + @Mock private AssetItemService assetItemService; + @Mock private ObjectMapper objectMapper; + @Mock private Acknowledgment ack; + + @InjectMocks private AssetConditionEventListener listener; + + private final ConsumerRecord rec = + new ConsumerRecord<>("asset-condition-update-topic", 0, 0L, "k", "v"); + + @Test + @DisplayName("updates condition and acks on happy path") + void happy() throws Exception { + UUID assetId = UUID.randomUUID(); + AssetConditionEvent event = AssetConditionEvent.builder() + .assetId(assetId).conditionScore(75).build(); + when(objectMapper.readValue("v", AssetConditionEvent.class)).thenReturn(event); + + listener.handleAssetConditionUpdate(rec, ack); + + verify(assetItemService).updateCondition(assetId, 75); + verify(ack).acknowledge(); + } + + @Test + @DisplayName("acks and swallows JacksonException (poison pill)") + void jackson() throws Exception { + when(objectMapper.readValue(any(String.class), eq(AssetConditionEvent.class))) + .thenThrow(new JacksonException("bad") {}); + + listener.handleAssetConditionUpdate(rec, ack); + + verify(ack).acknowledge(); + verifyNoInteractions(assetItemService); + } + + @Test + @DisplayName("rethrows RuntimeException when downstream fails (Kafka retry)") + void retry() throws Exception { + UUID assetId = UUID.randomUUID(); + AssetConditionEvent event = AssetConditionEvent.builder().assetId(assetId).conditionScore(50).build(); + when(objectMapper.readValue("v", AssetConditionEvent.class)).thenReturn(event); + doThrow(new RuntimeException("db")).when(assetItemService).updateCondition(any(), any()); + + assertThatThrownBy(() -> listener.handleAssetConditionUpdate(rec, ack)) + .isInstanceOf(RuntimeException.class); + verify(ack, never()).acknowledge(); + } +} diff --git a/src/test/java/com/isums/assetservice/infrastructures/listeners/ContractEventListenersTest.java b/src/test/java/com/isums/assetservice/infrastructures/listeners/ContractEventListenersTest.java new file mode 100644 index 0000000..33a028c --- /dev/null +++ b/src/test/java/com/isums/assetservice/infrastructures/listeners/ContractEventListenersTest.java @@ -0,0 +1,158 @@ +package com.isums.assetservice.infrastructures.listeners; + +import com.isums.assetservice.domains.dtos.AppAccessChangedEvent; +import com.isums.assetservice.domains.dtos.PowerCutConfirmedEvent; +import com.isums.assetservice.domains.entities.IotController; +import com.isums.assetservice.domains.entities.PowerCutJob; +import com.isums.assetservice.domains.enums.IotControllerStatus; +import com.isums.assetservice.infrastructures.repositories.IotControllerRepository; +import com.isums.assetservice.infrastructures.repositories.PowerCutJobRepository; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.kafka.support.Acknowledgment; +import tools.jackson.databind.ObjectMapper; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ContractEventListeners (asset-service)") +class ContractEventListenersTest { + + @Mock private ObjectMapper objectMapper; + @Mock private PowerCutJobRepository powerCutJobRepo; + @Mock private IotControllerRepository controllerRepo; + @Mock private Acknowledgment ack; + + @InjectMocks private ContractEventListeners listener; + + @Nested + @DisplayName("handlePowerCutConfirmed") + class PowerCut { + + private ConsumerRecord rec = + new ConsumerRecord<>("contract.power-cut-confirmed", 0, 0L, "k", "v"); + + @Test + @DisplayName("saves PENDING PowerCutJob on happy path") + void happy() throws Exception { + PowerCutConfirmedEvent event = new PowerCutConfirmedEvent( + UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), + UUID.randomUUID(), Instant.now().plusSeconds(86400), "m1"); + when(objectMapper.readValue("v", PowerCutConfirmedEvent.class)).thenReturn(event); + + listener.handlePowerCutConfirmed(rec, ack); + + ArgumentCaptor cap = ArgumentCaptor.forClass(PowerCutJob.class); + verify(powerCutJobRepo).save(cap.capture()); + assertThat(cap.getValue().getStatus().name()).isEqualTo("PENDING"); + verify(ack).acknowledge(); + } + + @Test + @DisplayName("rethrows RuntimeException on any failure (Kafka retry)") + void retry() throws Exception { + when(objectMapper.readValue(any(String.class), eq(PowerCutConfirmedEvent.class))) + .thenThrow(new RuntimeException("bad")); + + assertThatThrownBy(() -> listener.handlePowerCutConfirmed(rec, ack)) + .isInstanceOf(RuntimeException.class); + verify(ack, never()).acknowledge(); + } + } + + @Nested + @DisplayName("handleAppAccessChanged") + class AppAccess { + + private ConsumerRecord rec = + new ConsumerRecord<>("payment.app-access-changed", 0, 0L, "k", "v"); + + @Test + @DisplayName("acks and skips when restricted=true (event is for setting restriction, not lifting)") + void restricted() throws Exception { + AppAccessChangedEvent event = new AppAccessChangedEvent( + UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), true, "LATE_PAYMENT", "m1"); + when(objectMapper.readValue("v", AppAccessChangedEvent.class)).thenReturn(event); + + listener.handleAppAccessChanged(rec, ack); + + verify(ack).acknowledge(); + verify(controllerRepo, never()).save(any()); + } + + @Test + @DisplayName("sets paymentCutActive=false when restricted=false and controller has it active") + void unrestrict() throws Exception { + UUID houseId = UUID.randomUUID(); + AppAccessChangedEvent event = new AppAccessChangedEvent( + UUID.randomUUID(), houseId, UUID.randomUUID(), false, "PAYMENT_RECEIVED", "m1"); + IotController ctrl = IotController.builder() + .id(UUID.randomUUID()).deviceId("d1").houseId(houseId) + .status(IotControllerStatus.ACTIVE) + .paymentCutActive(true) + .activePaymentCutJobId(UUID.randomUUID()).build(); + + when(objectMapper.readValue("v", AppAccessChangedEvent.class)).thenReturn(event); + when(controllerRepo.findByHouseId(houseId)).thenReturn(Optional.of(ctrl)); + + listener.handleAppAccessChanged(rec, ack); + + assertThat(ctrl.isPaymentCutActive()).isFalse(); + assertThat(ctrl.getActivePaymentCutJobId()).isNull(); + verify(controllerRepo).save(ctrl); + verify(ack).acknowledge(); + } + + @Test + @DisplayName("acks but skips save when paymentCutActive already false (idempotent)") + void alreadyInactive() throws Exception { + UUID houseId = UUID.randomUUID(); + AppAccessChangedEvent event = new AppAccessChangedEvent( + UUID.randomUUID(), houseId, UUID.randomUUID(), false, "r", "m1"); + IotController ctrl = IotController.builder() + .id(UUID.randomUUID()).deviceId("d1").houseId(houseId) + .status(IotControllerStatus.ACTIVE).paymentCutActive(false).build(); + + when(objectMapper.readValue("v", AppAccessChangedEvent.class)).thenReturn(event); + when(controllerRepo.findByHouseId(houseId)).thenReturn(Optional.of(ctrl)); + + listener.handleAppAccessChanged(rec, ack); + + verify(controllerRepo, never()).save(any()); + verify(ack).acknowledge(); + } + + @Test + @DisplayName("acks when no controller exists for the house") + void noController() throws Exception { + UUID houseId = UUID.randomUUID(); + AppAccessChangedEvent event = new AppAccessChangedEvent( + UUID.randomUUID(), houseId, UUID.randomUUID(), false, "r", "m1"); + + when(objectMapper.readValue("v", AppAccessChangedEvent.class)).thenReturn(event); + when(controllerRepo.findByHouseId(houseId)).thenReturn(Optional.empty()); + + listener.handleAppAccessChanged(rec, ack); + + verify(controllerRepo, never()).save(any()); + verify(ack).acknowledge(); + } + } +} diff --git a/src/test/java/com/isums/assetservice/infrastructures/listeners/HouseEventListenerTest.java b/src/test/java/com/isums/assetservice/infrastructures/listeners/HouseEventListenerTest.java new file mode 100644 index 0000000..26fdcd4 --- /dev/null +++ b/src/test/java/com/isums/assetservice/infrastructures/listeners/HouseEventListenerTest.java @@ -0,0 +1,57 @@ +package com.isums.assetservice.infrastructures.listeners; + +import com.isums.assetservice.services.IotThresholdService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import tools.jackson.databind.ObjectMapper; + +import java.util.UUID; + +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("HouseEventListener") +class HouseEventListenerTest { + + @Mock private IotThresholdService thresholdService; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private HouseEventListener listener; + + @BeforeEach + void setUp() { + listener = new HouseEventListener(thresholdService, objectMapper); + } + + @Test + @DisplayName("seeds default thresholds when house.created message is valid JSON") + void happy() { + UUID houseId = UUID.randomUUID(); + listener.onHouseCreated("{\"houseId\":\"" + houseId + "\"}"); + + verify(thresholdService).seedDefaults(houseId); + } + + @Test + @DisplayName("swallows parse error silently (does not break Kafka stream)") + void badJson() { + listener.onHouseCreated("{invalid-json"); + + verify(thresholdService, never()).seedDefaults(org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("swallows when houseId missing") + void missingHouseId() { + listener.onHouseCreated("{}"); + + verify(thresholdService, never()).seedDefaults(org.mockito.ArgumentMatchers.any()); + } +} diff --git a/src/test/java/com/isums/assetservice/schedulers/PowerCutSchedulerTest.java b/src/test/java/com/isums/assetservice/schedulers/PowerCutSchedulerTest.java new file mode 100644 index 0000000..7c182f8 --- /dev/null +++ b/src/test/java/com/isums/assetservice/schedulers/PowerCutSchedulerTest.java @@ -0,0 +1,126 @@ +package com.isums.assetservice.schedulers; + +import com.isums.assetservice.domains.entities.IotController; +import com.isums.assetservice.domains.entities.PowerCutJob; +import com.isums.assetservice.domains.enums.IotControllerStatus; +import com.isums.assetservice.domains.enums.PowerCutJobStatus; +import com.isums.assetservice.infrastructures.abstracts.IotProvisioningService; +import com.isums.assetservice.infrastructures.repositories.AreaPowerStateRepository; +import com.isums.assetservice.infrastructures.repositories.IotControllerRepository; +import com.isums.assetservice.infrastructures.repositories.PowerCutJobRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("PowerCutScheduler") +class PowerCutSchedulerTest { + + @Mock private PowerCutJobRepository jobRepo; + @Mock private IotProvisioningService provisioning; + @Mock private IotControllerRepository controllerRepo; + @Mock private AreaPowerStateRepository stateRepo; + + @InjectMocks private PowerCutScheduler scheduler; + + private PowerCutJob pendingJob(UUID houseId) { + return PowerCutJob.builder() + .id(UUID.randomUUID()).houseId(houseId) + .contractId(UUID.randomUUID()).executeAt(Instant.now().minusSeconds(60)) + .status(PowerCutJobStatus.PENDING).build(); + } + + @Test + @DisplayName("executes pending job: calls IoT, marks controller cut-active, marks areas PAYMENT_DUE, persists EXECUTED") + void executesJob() { + UUID houseId = UUID.randomUUID(); + PowerCutJob job = pendingJob(houseId); + IotController ctrl = IotController.builder() + .id(UUID.randomUUID()).deviceId("d1").houseId(houseId) + .status(IotControllerStatus.ACTIVE).paymentCutActive(false).build(); + + when(jobRepo.findByStatusAndExecuteAtBefore(eq(PowerCutJobStatus.PENDING), any(Instant.class))) + .thenReturn(List.of(job)); + when(controllerRepo.findByHouseId(houseId)).thenReturn(Optional.of(ctrl)); + when(stateRepo.markAllAreasPaymentDue(houseId, job.getId())).thenReturn(3); + + scheduler.executePendingPowerCuts(); + + verify(provisioning).sendPowerCutCommand(houseId); + assertThat(ctrl.isPaymentCutActive()).isTrue(); + assertThat(ctrl.getActivePaymentCutJobId()).isEqualTo(job.getId()); + verify(controllerRepo).save(ctrl); + verify(stateRepo).markAllAreasPaymentDue(houseId, job.getId()); + assertThat(job.getStatus()).isEqualTo(PowerCutJobStatus.EXECUTED); + verify(jobRepo).save(job); + } + + @Test + @DisplayName("continues processing next job when one fails (error isolation)") + void isolatesFailure() { + UUID houseA = UUID.randomUUID(); + UUID houseB = UUID.randomUUID(); + PowerCutJob bad = pendingJob(houseA); + PowerCutJob good = pendingJob(houseB); + + when(jobRepo.findByStatusAndExecuteAtBefore(any(), any())).thenReturn(List.of(bad, good)); + doThrow(new RuntimeException("iot down")) + .when(provisioning).sendPowerCutCommand(houseA); + + // controller for B so the good path proceeds + IotController ctrlB = IotController.builder() + .id(UUID.randomUUID()).deviceId("d2").houseId(houseB) + .status(IotControllerStatus.ACTIVE).paymentCutActive(false).build(); + when(controllerRepo.findByHouseId(houseB)).thenReturn(Optional.of(ctrlB)); + + scheduler.executePendingPowerCuts(); + + // bad job retained PENDING (not EXECUTED) + assertThat(bad.getStatus()).isEqualTo(PowerCutJobStatus.PENDING); + // good job executed + verify(provisioning).sendPowerCutCommand(houseB); + assertThat(good.getStatus()).isEqualTo(PowerCutJobStatus.EXECUTED); + } + + @Test + @DisplayName("no work when no pending jobs") + void noWork() { + when(jobRepo.findByStatusAndExecuteAtBefore(any(), any())).thenReturn(List.of()); + + scheduler.executePendingPowerCuts(); + + verifyNoInteractions(provisioning, controllerRepo, stateRepo); + } + + @Test + @DisplayName("marks EXECUTED even when controller missing (already-deprovisioned case)") + void missingController() { + UUID houseId = UUID.randomUUID(); + PowerCutJob job = pendingJob(houseId); + when(jobRepo.findByStatusAndExecuteAtBefore(any(), any())).thenReturn(List.of(job)); + when(controllerRepo.findByHouseId(houseId)).thenReturn(Optional.empty()); + + scheduler.executePendingPowerCuts(); + + verify(controllerRepo, never()).save(any()); + assertThat(job.getStatus()).isEqualTo(PowerCutJobStatus.EXECUTED); + } +} diff --git a/src/test/java/com/isums/assetservice/services/AreaPowerServiceImplTest.java b/src/test/java/com/isums/assetservice/services/AreaPowerServiceImplTest.java new file mode 100644 index 0000000..b7f6dbd --- /dev/null +++ b/src/test/java/com/isums/assetservice/services/AreaPowerServiceImplTest.java @@ -0,0 +1,212 @@ +package com.isums.assetservice.services; + +import com.isums.assetservice.domains.dtos.AreaPowerStateResponse; +import com.isums.assetservice.domains.entities.AreaPowerState; +import com.isums.assetservice.domains.entities.AssetItem; +import com.isums.assetservice.domains.entities.IoTDevice; +import com.isums.assetservice.domains.entities.IotController; +import com.isums.assetservice.domains.enums.IotControllerStatus; +import com.isums.assetservice.domains.enums.NodeCapability; +import com.isums.assetservice.domains.enums.PowerAction; +import com.isums.assetservice.domains.enums.PowerCutReason; +import com.isums.assetservice.exceptions.NotFoundException; +import com.isums.assetservice.infrastructures.repositories.AreaPowerStateRepository; +import com.isums.assetservice.infrastructures.repositories.IoTDeviceRepository; +import com.isums.assetservice.infrastructures.repositories.IotControllerRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.server.ResponseStatusException; +import software.amazon.awssdk.services.iotdataplane.IotDataPlaneClient; +import tools.jackson.databind.ObjectMapper; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AreaPowerServiceImpl") +class AreaPowerServiceImplTest { + + @Mock private IotControllerRepository controllerRepo; + @Mock private IoTDeviceRepository deviceRepo; + @Mock private AreaPowerStateRepository stateRepo; + @Mock private IotDataPlaneClient iotClient; + + private AreaPowerServiceImpl service; + + private UUID houseId; + private UUID areaId; + private UUID requesterId; + + @BeforeEach + void setUp() { + // Construct manually so we can inject a REAL ObjectMapper (mock would return null + // for writeValueAsString and cause NPE in sendRelayCommands). + service = new AreaPowerServiceImpl( + controllerRepo, deviceRepo, stateRepo, iotClient, new ObjectMapper()); + houseId = UUID.randomUUID(); + areaId = UUID.randomUUID(); + requesterId = UUID.randomUUID(); + } + + private IotController activeController() { + return IotController.builder() + .id(UUID.randomUUID()).deviceId("ctrl-1").houseId(houseId) + .thingName("thing-1").status(IotControllerStatus.ACTIVE) + .paymentCutActive(false).build(); + } + + private IoTDevice relayNode() { + return IoTDevice.builder() + .id(UUID.randomUUID()).thing("node-1").serialNumber("SER-1") + .assetItem(AssetItem.builder().id(UUID.randomUUID()).build()) + .build(); + } + + @Nested + @DisplayName("toggleAreaPower") + class Toggle { + + @Test + @DisplayName("sends MQTT relay_off + persists state when OFF") + void off() { + when(controllerRepo.findByHouseId(houseId)).thenReturn(Optional.of(activeController())); + when(deviceRepo.findByAreaIdAndCapability(areaId, NodeCapability.RELAY.name())) + .thenReturn(List.of(relayNode())); + when(stateRepo.findByHouseIdAndAreaId(houseId, areaId)).thenReturn(Optional.empty()); + when(stateRepo.save(any(AreaPowerState.class))).thenAnswer(a -> a.getArgument(0)); + + AreaPowerStateResponse res = service.toggleAreaPower(houseId, areaId, PowerAction.OFF, requesterId); + + assertThat(res.powered()).isFalse(); + assertThat(res.cutReason()).isEqualTo(PowerCutReason.MANUAL); + verify(iotClient).publish(any(Consumer.class)); + } + + @Test + @DisplayName("sends relay_on + clears cutReason when ON") + void on() { + when(controllerRepo.findByHouseId(houseId)).thenReturn(Optional.of(activeController())); + when(deviceRepo.findByAreaIdAndCapability(areaId, NodeCapability.RELAY.name())) + .thenReturn(List.of(relayNode())); + when(stateRepo.findByHouseIdAndAreaId(houseId, areaId)).thenReturn(Optional.empty()); + when(stateRepo.save(any(AreaPowerState.class))).thenAnswer(a -> a.getArgument(0)); + + AreaPowerStateResponse res = service.toggleAreaPower(houseId, areaId, PowerAction.ON, requesterId); + + assertThat(res.powered()).isTrue(); + assertThat(res.cutReason()).isNull(); + } + + @Test + @DisplayName("throws PAYMENT_REQUIRED when paymentCutActive is true") + void paymentCutActive() { + IotController ctrl = activeController(); + ctrl.setPaymentCutActive(true); + when(controllerRepo.findByHouseId(houseId)).thenReturn(Optional.of(ctrl)); + + assertThatThrownBy(() -> service.toggleAreaPower(houseId, areaId, PowerAction.ON, requesterId)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("402"); + verifyNoInteractions(iotClient); + } + + @Test + @DisplayName("throws NotFoundException when no controller for house") + void noController() { + when(controllerRepo.findByHouseId(houseId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.toggleAreaPower(houseId, areaId, PowerAction.OFF, requesterId)) + .isInstanceOf(NotFoundException.class); + } + + @Test + @DisplayName("throws SERVICE_UNAVAILABLE when controller status not ACTIVE") + void inactiveController() { + IotController ctrl = activeController(); + ctrl.setStatus(IotControllerStatus.OFFLINE); + when(controllerRepo.findByHouseId(houseId)).thenReturn(Optional.of(ctrl)); + + assertThatThrownBy(() -> service.toggleAreaPower(houseId, areaId, PowerAction.OFF, requesterId)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("503"); + } + + @Test + @DisplayName("throws NotFoundException when no RELAY devices in area") + void noRelayDevices() { + when(controllerRepo.findByHouseId(houseId)).thenReturn(Optional.of(activeController())); + when(deviceRepo.findByAreaIdAndCapability(areaId, NodeCapability.RELAY.name())) + .thenReturn(List.of()); + + assertThatThrownBy(() -> service.toggleAreaPower(houseId, areaId, PowerAction.OFF, requesterId)) + .isInstanceOf(NotFoundException.class) + .hasMessageContaining("RELAY"); + } + } + + @Nested + @DisplayName("getAreaPowerState") + class GetState { + + @Test + @DisplayName("returns PAYMENT_DUE when controller has paymentCutActive") + void paymentCutActive() { + IotController ctrl = activeController(); + ctrl.setPaymentCutActive(true); + when(controllerRepo.findByHouseId(houseId)).thenReturn(Optional.of(ctrl)); + + AreaPowerStateResponse res = service.getAreaPowerState(houseId, areaId); + assertThat(res.powered()).isFalse(); + assertThat(res.cutReason()).isEqualTo(PowerCutReason.PAYMENT_DUE); + } + + @Test + @DisplayName("returns default 'power on' when no stored state") + void defaultOn() { + when(controllerRepo.findByHouseId(houseId)).thenReturn(Optional.of(activeController())); + when(stateRepo.findByHouseIdAndAreaId(houseId, areaId)).thenReturn(Optional.empty()); + + AreaPowerStateResponse res = service.getAreaPowerState(houseId, areaId); + assertThat(res.powered()).isTrue(); + assertThat(res.cutReason()).isNull(); + } + + @Test + @DisplayName("returns stored state when present") + void stored() { + when(controllerRepo.findByHouseId(houseId)).thenReturn(Optional.of(activeController())); + AreaPowerState state = AreaPowerState.builder() + .houseId(houseId).areaId(areaId).powered(false) + .cutReason(PowerCutReason.MANUAL) + .changedAt(java.time.Instant.now()).build(); + when(stateRepo.findByHouseIdAndAreaId(houseId, areaId)).thenReturn(Optional.of(state)); + + AreaPowerStateResponse res = service.getAreaPowerState(houseId, areaId); + assertThat(res.powered()).isFalse(); + assertThat(res.cutReason()).isEqualTo(PowerCutReason.MANUAL); + } + + @Test + @DisplayName("throws NotFoundException when no controller") + void noController() { + when(controllerRepo.findByHouseId(houseId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getAreaPowerState(houseId, areaId)) + .isInstanceOf(NotFoundException.class); + } + } +} diff --git a/src/test/java/com/isums/assetservice/services/AssetCategoryServiceImplTest.java b/src/test/java/com/isums/assetservice/services/AssetCategoryServiceImplTest.java new file mode 100644 index 0000000..4e0e043 --- /dev/null +++ b/src/test/java/com/isums/assetservice/services/AssetCategoryServiceImplTest.java @@ -0,0 +1,210 @@ +package com.isums.assetservice.services; + +import com.isums.assetservice.domains.dtos.AssetCategoryDTO.AssetCategoryDto; +import com.isums.assetservice.domains.dtos.AssetCategoryDTO.CreateAssetCategoryRequest; +import com.isums.assetservice.domains.dtos.AssetCategoryDTO.UpdateAssetCategoryRequest; +import com.isums.assetservice.domains.entities.AssetCategory; +import com.isums.assetservice.infrastructures.mapper.AssetMapper; +import com.isums.assetservice.infrastructures.repositories.AssetCategoryRepository; +import common.i18n.TranslationMap; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AssetCategoryServiceImpl") +class AssetCategoryServiceImplTest { + + @Mock private AssetCategoryRepository categoryRepository; + @Mock private AssetMapper assetMapper; + @Mock private TranslationAutoFillService translationAutoFillService; + + @InjectMocks private AssetCategoryServiceImpl service; + + private UUID id; + + @BeforeEach + void setUp() { + id = UUID.randomUUID(); + } + + private AssetCategory category() { + return AssetCategory.builder() + .id(id) + .name(TranslationMap.of(Map.of("vi", "Đồ gỗ", "en", "Wood items", "ja", "木製品"))) + .compensationPercent(50) + .description(TranslationMap.of(Map.of("vi", "mô tả", "en", "desc", "ja", "説明"))) + .build(); + } + + private AssetCategoryDto dto() { + return new AssetCategoryDto( + id, + "Đồ gỗ", + Map.of("en", "Wood items", "ja", "木製品"), + 50, + "mô tả", + Map.of("en", "desc", "ja", "説明"), + null + ); + } + + @Nested + @DisplayName("createAssetCategory") + class Create { + + @Test + @DisplayName("auto-fills translations from Vietnamese-only input") + void happy() { + CreateAssetCategoryRequest req = new CreateAssetCategoryRequest( + Map.of("vi", "Đồ gỗ"), + 50, + Map.of("vi", "mô tả") + ); + TranslationMap filledName = TranslationMap.of(Map.of("vi", "Đồ gỗ", "en", "Wood items", "ja", "木製品")); + TranslationMap filledDesc = TranslationMap.of(Map.of("vi", "mô tả", "en", "desc", "ja", "説明")); + AssetCategory saved = category(); + + when(translationAutoFillService.complete(req.name())).thenReturn(filledName); + when(translationAutoFillService.complete(req.description())).thenReturn(filledDesc); + when(categoryRepository.save(any(AssetCategory.class))).thenReturn(saved); + when(assetMapper.mapAssetCategory(saved)).thenReturn(dto()); + + assertThat(service.createAssetCategory(req)).isNotNull(); + + ArgumentCaptor cap = ArgumentCaptor.forClass(AssetCategory.class); + verify(categoryRepository).save(cap.capture()); + assertThat(cap.getValue().getName().getTranslations()).containsEntry("en", "Wood items"); + assertThat(cap.getValue().getDescription().getTranslations()).containsEntry("ja", "説明"); + } + + @Test + @DisplayName("wraps repo exception as RuntimeException") + void repoFails() { + CreateAssetCategoryRequest req = new CreateAssetCategoryRequest( + Map.of("vi", "n"), + 10, + Map.of("vi", "d") + ); + when(translationAutoFillService.complete(req.name())).thenReturn(TranslationMap.of(Map.of("vi", "n", "en", "n", "ja", "n"))); + when(translationAutoFillService.complete(req.description())).thenReturn(TranslationMap.of(Map.of("vi", "d", "en", "d", "ja", "d"))); + when(categoryRepository.save(any())).thenThrow(new RuntimeException("db")); + + assertThatThrownBy(() -> service.createAssetCategory(req)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Error to create asset categories"); + } + } + + @Nested + @DisplayName("getAllAssetCategories") + class GetAll { + + @Test + @DisplayName("returns mapped list") + void list() { + when(categoryRepository.findAll()).thenReturn(List.of(category())); + when(assetMapper.mapAssetCategories(any())).thenReturn(List.of(dto())); + + assertThat(service.getAllAssetCategories()).hasSize(1); + } + } + + @Nested + @DisplayName("getById") + class GetById { + + @Test + @DisplayName("returns DTO when found") + void found() { + when(categoryRepository.findById(id)).thenReturn(Optional.of(category())); + when(assetMapper.mapAssetCategory(any())).thenReturn(dto()); + + assertThat(service.getById(id)).isNotNull(); + } + } + + @Nested + @DisplayName("updateAssetCategory") + class Update { + + @Test + @DisplayName("auto-fills missing translations on update") + void updatesAll() { + AssetCategory existing = category(); + UpdateAssetCategoryRequest request = new UpdateAssetCategoryRequest( + Map.of("vi", "Thiết bị bếp"), + 80, + Map.of("vi", "dùng trong bếp") + ); + TranslationMap filledName = TranslationMap.of(Map.of("vi", "Thiết bị bếp", "en", "Kitchen appliance", "ja", "キッチン設備")); + TranslationMap filledDesc = TranslationMap.of(Map.of("vi", "dùng trong bếp", "en", "used in kitchen", "ja", "キッチンで使用")); + + when(categoryRepository.findById(id)).thenReturn(Optional.of(existing)); + when(translationAutoFillService.complete(request.name())).thenReturn(filledName); + when(translationAutoFillService.complete(request.description())).thenReturn(filledDesc); + when(categoryRepository.save(existing)).thenReturn(existing); + when(assetMapper.mapAssetCategory(existing)).thenReturn(dto()); + + service.updateAssetCategory(id, request); + + assertThat(existing.getName().getTranslations()).containsEntry("en", "Kitchen appliance"); + assertThat(existing.getDescription().getTranslations()).containsEntry("ja", "キッチンで使用"); + assertThat(existing.getCompensationPercent()).isEqualTo(80); + } + + @Test + @DisplayName("only updates non-null fields") + void partial() { + AssetCategory existing = category(); + when(categoryRepository.findById(id)).thenReturn(Optional.of(existing)); + when(categoryRepository.save(existing)).thenReturn(existing); + when(assetMapper.mapAssetCategory(existing)).thenReturn(dto()); + + service.updateAssetCategory(id, new UpdateAssetCategoryRequest(null, null, null)); + + verify(translationAutoFillService, never()).complete(any()); + } + + @Test + @DisplayName("rejects empty name map") + void blankName() { + when(categoryRepository.findById(id)).thenReturn(Optional.of(category())); + + assertThatThrownBy(() -> service.updateAssetCategory(id, + new UpdateAssetCategoryRequest(Map.of(), null, null))) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Name isn't correct form"); + verify(categoryRepository, never()).save(any()); + } + + @Test + @DisplayName("rejects compensationPercent out of range") + void tooHighPercent() { + when(categoryRepository.findById(id)).thenReturn(Optional.of(category())); + + assertThatThrownBy(() -> service.updateAssetCategory(id, + new UpdateAssetCategoryRequest(null, 101, null))) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("compensation must be in 0 - 100"); + } + } +} diff --git a/src/test/java/com/isums/assetservice/services/AssetEventServiceImplTest.java b/src/test/java/com/isums/assetservice/services/AssetEventServiceImplTest.java new file mode 100644 index 0000000..6f5b2e5 --- /dev/null +++ b/src/test/java/com/isums/assetservice/services/AssetEventServiceImplTest.java @@ -0,0 +1,458 @@ +package com.isums.assetservice.services; + +import com.isums.assetservice.domains.dtos.AssetEventDTO.AssetEventDto; +import com.isums.assetservice.domains.dtos.AssetEventDTO.UpdateAssetEventRequest; +import com.isums.assetservice.domains.entities.AssetEvent; +import com.isums.assetservice.domains.entities.AssetEventImage; +import com.isums.assetservice.domains.entities.AssetImage; +import com.isums.assetservice.domains.entities.AssetItem; +import com.isums.assetservice.domains.enums.AssetEventImageType; +import com.isums.assetservice.domains.enums.AssetEventType; +import com.isums.assetservice.infrastructures.mapper.AssetMapper; +import com.isums.assetservice.infrastructures.repositories.AssetEventImageRepository; +import com.isums.assetservice.infrastructures.repositories.AssetEventRepository; +import com.isums.assetservice.infrastructures.repositories.AssetImageRepository; +import common.i18n.TranslationMap; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AssetEventServiceImpl") +class AssetEventServiceImplTest { + + @Mock private AssetEventRepository eventRepo; + @Mock private AssetEventImageRepository eventImageRepo; + @Mock private AssetMapper mapper; + @Mock private S3ServiceImpl s3; + @Mock private AssetImageRepository imageRepo; + + @InjectMocks private AssetEventServiceImpl service; + + private UUID id; + private AssetItem assetItem; + + @BeforeEach + void setUp() { + id = UUID.randomUUID(); + assetItem = AssetItem.builder() + .id(UUID.randomUUID()) + .displayName(TranslationMap.ofDefault("Cai quat")) + .build(); + } + + private AssetEvent event() { + return AssetEvent.builder() + .id(id) + .eventType(AssetEventType.CREATED) + .assetItem(assetItem) + .createdAt(Instant.now()) + .build(); + } + + private AssetEventDto dto() { + AssetEventDto d = new AssetEventDto(); + d.setId(id); + d.setEventType(AssetEventType.CREATED); + return d; + } + + @Nested + @DisplayName("getAllAssetEvents") + class GetAll { + + @Test + @DisplayName("returns mapped events on happy path") + void happy() { + when(eventRepo.findAll()).thenReturn(List.of(event())); + when(mapper.maAssetEvents(any())).thenReturn(List.of(dto())); + + assertThat(service.getAllAssetEvents()).hasSize(1); + } + + @Test + @DisplayName("wraps repo failure") + void wraps() { + when(eventRepo.findAll()).thenThrow(new RuntimeException("db")); + + assertThatThrownBy(() -> service.getAllAssetEvents()) + .isInstanceOf(RuntimeException.class); + } + } + + @Nested + @DisplayName("updateEventStatus") + class UpdateStatus { + + @Test + @DisplayName("updates eventType and saves when status provided") + void happy() { + AssetEvent e = event(); + when(eventRepo.findById(id)).thenReturn(Optional.of(e)); + when(eventRepo.save(e)).thenReturn(e); + when(mapper.mapAssetEvent(e)).thenReturn(dto()); + + service.updateEventStatus(id, new UpdateAssetEventRequest(AssetEventType.REPAIRED)); + + assertThat(e.getEventType()).isEqualTo(AssetEventType.REPAIRED); + verify(eventRepo).save(e); + } + + @Test + @DisplayName("wraps when event missing") + void missing() { + when(eventRepo.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.updateEventStatus(id, + new UpdateAssetEventRequest(AssetEventType.REPAIRED))) + .isInstanceOf(RuntimeException.class); + verify(eventRepo, never()).save(any()); + } + + @Test + @DisplayName("wraps when status null") + void nullStatus() { + when(eventRepo.findById(id)).thenReturn(Optional.of(event())); + + assertThatThrownBy(() -> service.updateEventStatus(id, + new UpdateAssetEventRequest(null))) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Status is required"); + } + } + + @Nested + @DisplayName("getEventsByJob") + class GetEventsByJob { + + @Test + @DisplayName("maps before and after images from the same event") + void mapsBeforeAndAfterImagesFromCurrentEvent() { + UUID jobId = UUID.randomUUID(); + AssetEvent e = event(); + AssetEventImage beforeImage = AssetEventImage.builder() + .id(UUID.randomUUID()) + .event(e) + .key("asset/before.jpg") + .type(AssetEventImageType.BEFORE) + .createdAt(Instant.now().minusSeconds(60)) + .build(); + AssetEventImage afterImage = AssetEventImage.builder() + .id(UUID.randomUUID()) + .event(e) + .key("asset/after.jpg") + .type(AssetEventImageType.AFTER) + .createdAt(Instant.now()) + .build(); + AssetEventDto mapped = dto(); + + when(eventRepo.findByJobIdWithAsset(jobId)).thenReturn(List.of(e)); + when(mapper.mapAssetEvent(e)).thenReturn(mapped); + when(eventImageRepo.findByEventIdAndTypeOrderByCreatedAtAsc(e.getId(), AssetEventImageType.BEFORE)) + .thenReturn(List.of(beforeImage)); + when(eventImageRepo.findByEventIdAndTypeOrderByCreatedAtAsc(e.getId(), AssetEventImageType.AFTER)) + .thenReturn(List.of(afterImage)); + when(s3.getImageUrl("asset/before.jpg")).thenReturn("https://isums.pro/asset/before.jpg"); + when(s3.getImageUrl("asset/after.jpg")).thenReturn("https://isums.pro/asset/after.jpg"); + + List result = service.getEventsByJob(jobId); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().getOldImages()) + .extracting("url") + .containsExactly("https://isums.pro/asset/before.jpg"); + assertThat(result.getFirst().getImages()) + .extracting("url") + .containsExactly("https://isums.pro/asset/after.jpg"); + verify(eventRepo, never()).findPreviousEvent(any()); + } + + @Test + @DisplayName("keeps after image even when content hash matches before image") + void keepsAfterImageWhenContentDuplicatesBeforeImage() { + UUID jobId = UUID.randomUUID(); + AssetEvent e = event(); + AssetEventImage beforeImage = AssetEventImage.builder() + .id(UUID.randomUUID()) + .event(e) + .key("asset/before.jpg") + .type(AssetEventImageType.BEFORE) + .createdAt(Instant.now().minusSeconds(60)) + .build(); + AssetEventImage duplicateAfterImage = AssetEventImage.builder() + .id(UUID.randomUUID()) + .event(e) + .key("asset/after-same.jpg") + .type(AssetEventImageType.AFTER) + .createdAt(Instant.now()) + .build(); + AssetEventDto mapped = dto(); + String sameHash = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + + when(eventRepo.findByJobIdWithAsset(jobId)).thenReturn(List.of(e)); + when(mapper.mapAssetEvent(e)).thenReturn(mapped); + when(eventImageRepo.findByEventIdAndTypeOrderByCreatedAtAsc(e.getId(), AssetEventImageType.BEFORE)) + .thenReturn(List.of(beforeImage)); + when(eventImageRepo.findByEventIdAndTypeOrderByCreatedAtAsc(e.getId(), AssetEventImageType.AFTER)) + .thenReturn(List.of(duplicateAfterImage)); + when(s3.getImageUrl("asset/before.jpg")).thenReturn("https://isums.pro/asset/before.jpg"); + when(s3.getImageUrl("asset/after-same.jpg")).thenReturn("https://isums.pro/asset/after-same.jpg"); + + List result = service.getEventsByJob(jobId); + + assertThat(result.getFirst().getOldImages()) + .extracting("url") + .containsExactly("https://isums.pro/asset/before.jpg"); + assertThat(result.getFirst().getImages()) + .extracting("url") + .containsExactly("https://isums.pro/asset/after-same.jpg"); + } + + @Test + @DisplayName("does not borrow images from recent unscoped asset uploads") + void doesNotBorrowRecentUnscopedUploadImages() { + UUID jobId = UUID.randomUUID(); + AssetEvent maintenance = event(); + maintenance.setJobId(jobId); + maintenance.setEventType(AssetEventType.MAINTENANCE); + AssetEventImage beforeImage = AssetEventImage.builder() + .id(UUID.randomUUID()) + .event(maintenance) + .key("asset/current-at-batch.jpg") + .type(AssetEventImageType.BEFORE) + .createdAt(Instant.now().minusSeconds(60)) + .build(); + AssetEventImage afterImage = AssetEventImage.builder() + .id(UUID.randomUUID()) + .event(maintenance) + .key("asset/uploaded-after-batch.jpg") + .type(AssetEventImageType.AFTER) + .createdAt(Instant.now()) + .build(); + AssetEventDto mapped = dto(); + + when(eventRepo.findByJobIdWithAsset(jobId)).thenReturn(List.of(maintenance)); + when(mapper.mapAssetEvent(maintenance)).thenReturn(mapped); + when(eventImageRepo.findByEventIdAndTypeOrderByCreatedAtAsc(id, AssetEventImageType.BEFORE)) + .thenReturn(List.of(beforeImage)); + when(eventImageRepo.findByEventIdAndTypeOrderByCreatedAtAsc(id, AssetEventImageType.AFTER)) + .thenReturn(List.of(afterImage)); + when(s3.getImageUrl("asset/current-at-batch.jpg")).thenReturn("https://isums.pro/asset/current-at-batch.jpg"); + when(s3.getImageUrl("asset/uploaded-after-batch.jpg")).thenReturn("https://isums.pro/asset/uploaded-after-batch.jpg"); + + List result = service.getEventsByJob(jobId); + + assertThat(result.getFirst().getOldImages()) + .extracting("url") + .containsExactly("https://isums.pro/asset/current-at-batch.jpg"); + assertThat(result.getFirst().getImages()) + .extracting("url") + .containsExactly("https://isums.pro/asset/uploaded-after-batch.jpg"); + } + + @Test + @DisplayName("does not rewrite maintenance image groups from recent asset upload events") + void doesNotRewriteMaintenanceImageGroupsFromRecentAssetUploadEvents() { + UUID jobId = UUID.randomUUID(); + Instant maintenanceAt = Instant.parse("2026-04-25T11:47:01Z"); + AssetEvent maintenance = event(); + maintenance.setJobId(jobId); + maintenance.setEventType(AssetEventType.MAINTENANCE); + maintenance.setCreatedAt(maintenanceAt); + AssetEventImage wronglyPersistedOldImage = AssetEventImage.builder() + .id(UUID.randomUUID()) + .event(maintenance) + .key("asset/first-upload.jpg") + .type(AssetEventImageType.BEFORE) + .createdAt(maintenanceAt.minusSeconds(264)) + .build(); + AssetEventDto mapped = dto(); + + when(eventRepo.findByJobIdWithAsset(jobId)).thenReturn(List.of(maintenance)); + when(mapper.mapAssetEvent(maintenance)).thenReturn(mapped); + when(eventImageRepo.findByEventIdAndTypeOrderByCreatedAtAsc(id, AssetEventImageType.BEFORE)) + .thenReturn(List.of(wronglyPersistedOldImage)); + when(eventImageRepo.findByEventIdAndTypeOrderByCreatedAtAsc(id, AssetEventImageType.AFTER)) + .thenReturn(List.of()); + when(s3.getImageUrl("asset/first-upload.jpg")).thenReturn("https://isums.pro/asset/first-upload.jpg"); + + List result = service.getEventsByJob(jobId); + + assertThat(result.getFirst().getOldImages()) + .extracting("url") + .containsExactly("https://isums.pro/asset/first-upload.jpg"); + assertThat(result.getFirst().getImages()).isEmpty(); + verify(eventRepo, never()).findRecentUnscopedImageReplacementEvents(any(), any(), any(), any()); + } + } + + @Nested + @DisplayName("getLatestEvent") + class Latest { + + @Test + @DisplayName("falls back to legacy untyped images for current image list") + void fallsBackToLegacyUntypedAfterImages() { + AssetEvent e = event(); + AssetEventImage legacyImage = AssetEventImage.builder() + .id(UUID.randomUUID()) + .event(e) + .key("asset/current.jpg") + .createdAt(Instant.now()) + .build(); + + when(eventRepo.findLatestEvent(assetItem.getId())).thenReturn(List.of(e)); + when(eventImageRepo.findByEventIdAndTypeOrderByCreatedAtAsc(id, AssetEventImageType.BEFORE)) + .thenReturn(List.of()); + when(eventImageRepo.findByEventIdAndTypeOrderByCreatedAtAsc(id, AssetEventImageType.AFTER)) + .thenReturn(List.of()); + when(eventImageRepo.findByEventIdOrderByCreatedAtAsc(id)).thenReturn(List.of(legacyImage)); + when(s3.getImageUrl("asset/current.jpg")).thenReturn("https://isums.pro/asset/current.jpg"); + + AssetEventDto result = service.getLatestEvent(assetItem.getId()); + + assertThat(result.getId()).isEqualTo(id); + assertThat(result.getImages()) + .extracting("url") + .containsExactly("https://isums.pro/asset/current.jpg"); + assertThat(result.getOldImages()).isEmpty(); + } + } + + @Nested + @DisplayName("uploadEventImages") + class Upload { + + @Test + @DisplayName("snapshots current asset images as BEFORE and uploads new ones as AFTER") + void snapshotsBeforeAndUploadsAfter() { + AssetEvent e = event(); + AssetImage currentOne = AssetImage.builder() + .id(UUID.randomUUID()) + .assetItem(assetItem) + .key("asset/current-1.jpg") + .createdAt(Instant.now().minusSeconds(120)) + .build(); + AssetImage currentTwo = AssetImage.builder() + .id(UUID.randomUUID()) + .assetItem(assetItem) + .key("asset/current-2.jpg") + .createdAt(Instant.now().minusSeconds(60)) + .build(); + MockMultipartFile newFile = new MockMultipartFile("files", "new.jpg", "image/jpeg", "abc".getBytes()); + + when(eventRepo.findById(id)).thenReturn(Optional.of(e)); + when(imageRepo.findByAssetItemId(assetItem.getId())).thenReturn(List.of(currentOne, currentTwo)); + when(eventImageRepo.findByEventIdAndTypeOrderByCreatedAtAsc(id, AssetEventImageType.BEFORE)) + .thenReturn(List.of()); + when(eventImageRepo.findByEventIdAndTypeOrderByCreatedAtAsc(id, AssetEventImageType.AFTER)) + .thenReturn(List.of()); + when(s3.upload(newFile, "asset/" + assetItem.getId())).thenReturn("asset/new.jpg"); + + service.uploadEventImages(id, List.of(newFile)); + + ArgumentCaptor> beforeCaptor = ArgumentCaptor.forClass(List.class); + ArgumentCaptor afterCaptor = ArgumentCaptor.forClass(AssetEventImage.class); + + verify(eventImageRepo).saveAll(beforeCaptor.capture()); + verify(eventImageRepo).save(afterCaptor.capture()); + verify(imageRepo).deleteAll(List.of(currentOne, currentTwo)); + + assertThat(beforeCaptor.getValue()) + .extracting(AssetEventImage::getType, AssetEventImage::getKey) + .containsExactly( + org.assertj.core.groups.Tuple.tuple(AssetEventImageType.BEFORE, "asset/current-1.jpg"), + org.assertj.core.groups.Tuple.tuple(AssetEventImageType.BEFORE, "asset/current-2.jpg") + ); + assertThat(afterCaptor.getValue().getType()).isEqualTo(AssetEventImageType.AFTER); + assertThat(afterCaptor.getValue().getKey()).isEqualTo("asset/new.jpg"); + } + + @Test + @DisplayName("throws when event missing") + void eventMissing() { + UUID eventId = UUID.randomUUID(); + when(eventRepo.findById(eventId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.uploadEventImages(eventId, List.of())) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Event not found"); + } + + @Test + @DisplayName("does not resnapshot BEFORE images when event already has snapshot") + void doesNotResnapshotExistingBeforeImages() { + AssetEvent e = event(); + AssetEventImage existingBefore = AssetEventImage.builder() + .id(UUID.randomUUID()) + .event(e) + .key("asset/current-1.jpg") + .type(AssetEventImageType.BEFORE) + .createdAt(Instant.now()) + .build(); + MockMultipartFile newFile = new MockMultipartFile("files", "new.jpg", "image/jpeg", "abc".getBytes()); + + when(eventRepo.findById(id)).thenReturn(Optional.of(e)); + when(imageRepo.findByAssetItemId(assetItem.getId())).thenReturn(List.of()); + when(eventImageRepo.findByEventIdAndTypeOrderByCreatedAtAsc(id, AssetEventImageType.BEFORE)) + .thenReturn(List.of(existingBefore)); + when(eventImageRepo.findByEventIdAndTypeOrderByCreatedAtAsc(id, AssetEventImageType.AFTER)) + .thenReturn(List.of()); + when(s3.upload(newFile, "asset/" + assetItem.getId())).thenReturn("asset/new.jpg"); + + service.uploadEventImages(id, List.of(newFile)); + + verify(eventImageRepo, never()).saveAll(any()); + verify(eventImageRepo, never()).deleteAll(any()); + verify(eventImageRepo).save(any(AssetEventImage.class)); + } + + @Test + @DisplayName("records uploaded file as AFTER even when it matches current image") + void recordsAfterImageWhenUploadDuplicatesCurrentImage() { + AssetEvent e = event(); + AssetImage current = AssetImage.builder() + .id(UUID.randomUUID()) + .assetItem(assetItem) + .key("asset/current.jpg") + .createdAt(Instant.now().minusSeconds(60)) + .build(); + MockMultipartFile duplicateFile = new MockMultipartFile("files", "same.jpg", "image/jpeg", "abc".getBytes()); + + when(eventRepo.findById(id)).thenReturn(Optional.of(e)); + when(imageRepo.findByAssetItemId(assetItem.getId())).thenReturn(List.of(current)); + when(eventImageRepo.findByEventIdAndTypeOrderByCreatedAtAsc(id, AssetEventImageType.BEFORE)) + .thenReturn(List.of()); + when(eventImageRepo.findByEventIdAndTypeOrderByCreatedAtAsc(id, AssetEventImageType.AFTER)) + .thenReturn(List.of()); + when(s3.upload(duplicateFile, "asset/" + assetItem.getId())).thenReturn("asset/same-uploaded.jpg"); + + service.uploadEventImages(id, List.of(duplicateFile)); + + verify(eventImageRepo).saveAll(any()); + verify(imageRepo).deleteAll(List.of(current)); + verify(s3).upload(duplicateFile, "asset/" + assetItem.getId()); + verify(eventImageRepo).save(any(AssetEventImage.class)); + } + } +} diff --git a/src/test/java/com/isums/assetservice/services/AssetItemServiceImplTest.java b/src/test/java/com/isums/assetservice/services/AssetItemServiceImplTest.java new file mode 100644 index 0000000..e3f07e5 --- /dev/null +++ b/src/test/java/com/isums/assetservice/services/AssetItemServiceImplTest.java @@ -0,0 +1,490 @@ +package com.isums.assetservice.services; + +import com.isums.assetservice.domains.dtos.AssetCategoryDTO.AssetCategoryDto; +import com.isums.assetservice.domains.dtos.AssetCountByFunctionAreaDto; +import com.isums.assetservice.domains.dtos.AssetItemDTO.AssetItemDto; +import com.isums.assetservice.domains.dtos.AssetItemDTO.CreateAssetItemRequest; +import com.isums.assetservice.domains.dtos.AssetItemDTO.UpdateAssetItemRequest; +import com.isums.assetservice.domains.dtos.AssetItemDTO.UpdateHouseRequest; +import com.isums.assetservice.domains.dtos.BatchUpdateAssetRequest; +import com.isums.assetservice.domains.entities.AssetCategory; +import com.isums.assetservice.domains.entities.AssetEvent; +import com.isums.assetservice.domains.entities.AssetEventImage; +import com.isums.assetservice.domains.entities.AssetImage; +import com.isums.assetservice.domains.entities.AssetItem; +import com.isums.assetservice.domains.entities.AssetTag; +import com.isums.assetservice.domains.enums.AssetEventImageType; +import com.isums.assetservice.domains.enums.AssetStatus; +import com.isums.assetservice.infrastructures.grpcs.GrpcUserClient; +import com.isums.assetservice.infrastructures.grpcs.HouseGrpcImpl; +import com.isums.assetservice.infrastructures.mapper.AssetMapper; +import com.isums.assetservice.infrastructures.repositories.AssetCategoryRepository; +import com.isums.assetservice.infrastructures.repositories.AssetEventImageRepository; +import com.isums.assetservice.infrastructures.repositories.AssetEventRepository; +import com.isums.assetservice.infrastructures.repositories.AssetImageRepository; +import com.isums.assetservice.infrastructures.repositories.AssetItemRepository; +import com.isums.assetservice.infrastructures.repositories.AssetTagRepository; +import com.isums.houseservice.grpc.FunctionalAreaResponse; +import com.isums.houseservice.grpc.HouseResponse; +import common.i18n.TranslationMap; +import common.paginations.cache.CachedPageService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AssetItemServiceImpl") +class AssetItemServiceImplTest { + + @Mock private AssetCategoryRepository assetCategoryRepository; + @Mock private AssetEventRepository assetEventRepository; + @Mock private AssetMapper assetMapper; + @Mock private AssetItemRepository assetItemRepository; + @Mock private AssetTagRepository assetTagRepository; + @Mock private S3ServiceImpl s3; + @Mock private AssetImageRepository assetImageRepository; + @Mock private GrpcUserClient grpcUserClient; + @Mock private CachedPageService cachedPageService; + @Mock private AssetEventImageRepository assetEventImageRepository; + @Mock private TranslationAutoFillService translationAutoFillService; + @Mock private HouseGrpcImpl houseGrpc; + + @InjectMocks private AssetItemServiceImpl service; + + @Test + @DisplayName("create auto-fills translations from Vietnamese-only displayName") + void createAutoFillsTranslations() { + UUID houseId = UUID.randomUUID(); + UUID functionAreaId = UUID.randomUUID(); + UUID categoryId = UUID.randomUUID(); + AssetCategory category = AssetCategory.builder().id(categoryId).build(); + TranslationMap filled = TranslationMap.of(Map.of("vi", "May lanh", "en", "Air conditioner", "ja", "Air conditioner jp")); + CreateAssetItemRequest request = new CreateAssetItemRequest( + houseId, + functionAreaId, + categoryId, + Map.of("vi", "May lanh"), + "SN-1", + 90, + AssetStatus.IN_USE, + List.of() + ); + AssetItemDto dto = new AssetItemDto(); + + when(assetCategoryRepository.findById(categoryId)).thenReturn(Optional.of(category)); + when(houseGrpc.getHouseById(houseId)).thenReturn(houseResponse(houseId, functionAreaId)); + when(translationAutoFillService.complete(request.displayName())).thenReturn(filled); + when(assetItemRepository.save(any(AssetItem.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(assetMapper.mapAssetItem(any(AssetItem.class))).thenReturn(dto); + + service.CreateAssetItem(request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AssetItem.class); + verify(assetItemRepository).save(captor.capture()); + assertThat(captor.getValue().getDisplayName().getTranslations()).containsEntry("ja", "Air conditioner jp"); + assertThat(captor.getValue().getFunctionAreaId()).isEqualTo(functionAreaId); + } + + @Test + @DisplayName("create rejects function area that does not belong to house") + void createRejectsForeignFunctionArea() { + UUID houseId = UUID.randomUUID(); + UUID validAreaId = UUID.randomUUID(); + UUID foreignAreaId = UUID.randomUUID(); + UUID categoryId = UUID.randomUUID(); + AssetCategory category = AssetCategory.builder().id(categoryId).build(); + CreateAssetItemRequest request = new CreateAssetItemRequest( + houseId, + foreignAreaId, + categoryId, + Map.of("vi", "Laptop"), + "SN-2", + 80, + AssetStatus.IN_USE, + List.of() + ); + + when(assetCategoryRepository.findById(categoryId)).thenReturn(Optional.of(category)); + when(houseGrpc.getHouseById(houseId)).thenReturn(houseResponse(houseId, validAreaId)); + + assertThatThrownBy(() -> service.CreateAssetItem(request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(foreignAreaId.toString()) + .hasMessageContaining(houseId.toString()); + + verify(assetItemRepository, never()).save(any(AssetItem.class)); + } + + @Test + @DisplayName("update auto-fills translations when only Vietnamese displayName is supplied") + void updateAutoFillsTranslations() { + UUID id = UUID.randomUUID(); + UUID houseId = UUID.randomUUID(); + UUID functionAreaId = UUID.randomUUID(); + AssetItem assetItem = AssetItem.builder() + .id(id) + .houseId(houseId) + .displayName(TranslationMap.of(Map.of("vi", "Cu", "en", "Old", "ja", "Old jp"))) + .build(); + TranslationMap filled = TranslationMap.of(Map.of("vi", "May giat", "en", "Washing machine", "ja", "Washing machine jp")); + UpdateAssetItemRequest request = new UpdateAssetItemRequest( + functionAreaId, + Map.of("vi", "May giat"), + null, + null, + null, + null, + null + ); + AssetItemDto dto = new AssetItemDto(); + + when(assetItemRepository.findById(id)).thenReturn(Optional.of(assetItem)); + when(houseGrpc.getHouseById(houseId)).thenReturn(houseResponse(houseId, functionAreaId)); + when(translationAutoFillService.complete(request.displayName())).thenReturn(filled); + when(assetItemRepository.save(assetItem)).thenReturn(assetItem); + when(assetMapper.mapAssetItem(assetItem)).thenReturn(dto); + + service.UpdateAssetItem(id, request); + + assertThat(assetItem.getDisplayName().getTranslations()).containsEntry("en", "Washing machine"); + assertThat(assetItem.getFunctionAreaId()).isEqualTo(functionAreaId); + } + + @Test + @DisplayName("update rejects function area that does not belong to asset house") + void updateRejectsForeignFunctionArea() { + UUID id = UUID.randomUUID(); + UUID houseId = UUID.randomUUID(); + UUID validAreaId = UUID.randomUUID(); + UUID foreignAreaId = UUID.randomUUID(); + AssetItem assetItem = AssetItem.builder() + .id(id) + .houseId(houseId) + .build(); + UpdateAssetItemRequest request = new UpdateAssetItemRequest( + foreignAreaId, + null, + null, + null, + null, + null, + null + ); + + when(assetItemRepository.findById(id)).thenReturn(Optional.of(assetItem)); + when(houseGrpc.getHouseById(houseId)).thenReturn(houseResponse(houseId, validAreaId)); + + assertThatThrownBy(() -> service.UpdateAssetItem(id, request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(foreignAreaId.toString()) + .hasMessageContaining(houseId.toString()); + + verify(assetItemRepository, never()).save(assetItem); + } + + @Test + @DisplayName("updateHouseForAsset clears function area when asset moves to another house") + void updateHouseClearsFunctionArea() { + UUID assetId = UUID.randomUUID(); + UUID oldHouseId = UUID.randomUUID(); + UUID newHouseId = UUID.randomUUID(); + UUID functionAreaId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + AssetItem assetItem = AssetItem.builder() + .id(assetId) + .houseId(oldHouseId) + .functionAreaId(functionAreaId) + .build(); + AssetItemDto dto = new AssetItemDto(); + + when(assetItemRepository.findById(assetId)).thenReturn(Optional.of(assetItem)); + when(assetItemRepository.save(assetItem)).thenReturn(assetItem); + when(assetEventRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(assetMapper.mapAssetItem(assetItem)).thenReturn(dto); + + service.updateHouseForAsset(assetId, new UpdateHouseRequest(newHouseId), userId); + + assertThat(assetItem.getHouseId()).isEqualTo(newHouseId); + assertThat(assetItem.getFunctionAreaId()).isNull(); + verify(assetItemRepository).save(assetItem); + } + + @Test + @DisplayName("getAssetItemById returns category detail in response") + void getAssetItemByIdIncludesCategoryDetail() { + UUID id = UUID.randomUUID(); + UUID categoryId = UUID.randomUUID(); + + AssetCategory category = AssetCategory.builder() + .id(categoryId) + .build(); + + AssetItem assetItem = AssetItem.builder() + .id(id) + .category(category) + .build(); + + AssetCategoryDto categoryDto = new AssetCategoryDto(); + categoryDto.setId(categoryId); + categoryDto.setName("Dieu hoa"); + + AssetItemDto dto = new AssetItemDto(); + dto.setId(id); + dto.setCategoryId(categoryId); + dto.setCategory(categoryDto); + + when(assetItemRepository.findById(id)).thenReturn(Optional.of(assetItem)); + when(assetMapper.mapAssetItem(assetItem)).thenReturn(dto); + when(assetTagRepository.findByAssetItemIdAndIsActiveTrue(id)).thenReturn(List.of()); + when(assetImageRepository.findByAssetItemId(id)).thenReturn(List.of()); + + AssetItemDto result = service.getAssetItemById(id); + + assertThat(result.getCategoryId()).isEqualTo(categoryId); + assertThat(result.getCategory()).isNotNull(); + assertThat(result.getCategory().getId()).isEqualTo(categoryId); + assertThat(result.getCategory().getName()).isEqualTo("Dieu hoa"); + } + + @Test + @DisplayName("getAssetItemsByHouseIdAndFunctionAreaId filters by both house and function area, then hydrates tags/images") + void getByHouseAndFunctionArea() { + UUID houseId = UUID.randomUUID(); + UUID functionAreaId = UUID.randomUUID(); + UUID assetId = UUID.randomUUID(); + UUID tagId = UUID.randomUUID(); + UUID imageId = UUID.randomUUID(); + + AssetCategory category = AssetCategory.builder().id(UUID.randomUUID()).build(); + AssetItem asset = AssetItem.builder() + .id(assetId) + .houseId(houseId) + .functionAreaId(functionAreaId) + .category(category) + .displayName(TranslationMap.of(Map.of("vi", "May lanh"))) + .build(); + + AssetTag tag = AssetTag.builder() + .id(tagId) + .assetItem(asset) + .build(); + + AssetImage image = AssetImage.builder() + .id(imageId) + .assetItem(asset) + .key("asset/test.jpg") + .build(); + + AssetItemDto dto = new AssetItemDto(); + dto.setId(assetId); + dto.setHouseId(houseId); + dto.setFunctionAreaId(functionAreaId); + + when(assetItemRepository.findByHouseIdAndFunctionAreaId(houseId, functionAreaId)).thenReturn(List.of(asset)); + when(assetTagRepository.findByAssetItemIdInAndIsActiveTrue(List.of(assetId))).thenReturn(List.of(tag)); + when(assetMapper.mapAssetItem(asset)).thenReturn(dto); + when(assetMapper.tagDtos(List.of(tag))).thenReturn(List.of()); + when(assetImageRepository.findByAssetItemId(assetId)).thenReturn(List.of(image)); + when(s3.getImageUrl("asset/test.jpg")).thenReturn("https://isums.pro/asset/test.jpg"); + + List result = service.getAssetItemsByHouseIdAndFunctionAreaId(houseId, functionAreaId); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().getId()).isEqualTo(assetId); + assertThat(result.getFirst().getFunctionAreaId()).isEqualTo(functionAreaId); + assertThat(result.getFirst().getImages()) + .extracting(com.isums.assetservice.domains.dtos.AssetImageDto::url) + .containsExactly("https://isums.pro/asset/test.jpg"); + verify(assetItemRepository).findByHouseIdAndFunctionAreaId(houseId, functionAreaId); + verify(assetTagRepository).findByAssetItemIdInAndIsActiveTrue(List.of(assetId)); + verify(assetMapper).mapAssetItem(asset); + verify(assetMapper).tagDtos(eq(List.of(tag))); + } + + @Test + @DisplayName("getAssetCountByFunctionArea returns grouped counts from repository") + void getAssetCountByFunctionArea() { + UUID houseId = UUID.randomUUID(); + UUID areaA = UUID.randomUUID(); + UUID areaB = UUID.randomUUID(); + List counts = List.of( + new AssetCountByFunctionAreaDto(areaA, 2), + new AssetCountByFunctionAreaDto(areaB, 1) + ); + + when(assetItemRepository.countByHouseIdGroupByFunctionAreaId(houseId)).thenReturn(counts); + + assertThat(service.getAssetCountByFunctionArea(houseId)).isEqualTo(counts); + verify(assetItemRepository).countByHouseIdGroupByFunctionAreaId(houseId); + } + + @Test + @DisplayName("batch maintenance snapshots current asset images as old images") + void batchMaintenanceSnapshotsCurrentImagesAsOldImages() { + UUID assetId = UUID.randomUUID(); + UUID jobId = UUID.randomUUID(); + UUID maintenanceEventId = UUID.randomUUID(); + Instant eventAt = Instant.parse("2026-04-25T07:59:20Z"); + + AssetItem asset = AssetItem.builder() + .id(assetId) + .conditionPercent(90) + .build(); + AssetImage currentImageOne = AssetImage.builder() + .id(UUID.randomUUID()) + .assetItem(asset) + .key("asset/current-1.jpg") + .createdAt(eventAt.minusSeconds(120)) + .build(); + AssetImage currentImageTwo = AssetImage.builder() + .id(UUID.randomUUID()) + .assetItem(asset) + .key("asset/current-2.jpg") + .createdAt(eventAt.minusSeconds(60)) + .build(); + BatchUpdateAssetRequest request = new BatchUpdateAssetRequest( + jobId, + List.of(new BatchUpdateAssetRequest.AssetUpdateItem(assetId, 95, "Tot", null)) + ); + + when(assetItemRepository.findAllById(List.of(assetId))).thenReturn(List.of(asset)); + when(translationAutoFillService.complete(Map.of("vi", "Tot"))) + .thenReturn(TranslationMap.of(Map.of("vi", "Tot"))); + when(assetEventRepository.save(any(AssetEvent.class))).thenAnswer(invocation -> { + AssetEvent event = invocation.getArgument(0); + event.setId(maintenanceEventId); + event.setCreatedAt(eventAt); + return event; + }); + when(assetImageRepository.findByAssetItemId(assetId)).thenReturn(List.of(currentImageOne, currentImageTwo)); + + service.batchUpdateAssetCondition(UUID.randomUUID(), request); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(assetEventImageRepository).saveAll(captor.capture()); + assertThat(captor.getValue()) + .extracting(AssetEventImage::getType, AssetEventImage::getKey) + .containsExactly( + org.assertj.core.groups.Tuple.tuple(AssetEventImageType.BEFORE, "asset/current-1.jpg"), + org.assertj.core.groups.Tuple.tuple(AssetEventImageType.BEFORE, "asset/current-2.jpg") + ); + } + + @Test + @DisplayName("batch maintenance does not clone recent asset image replacement") + void batchMaintenanceDoesNotCloneRecentImageReplacement() { + UUID assetId = UUID.randomUUID(); + UUID jobId = UUID.randomUUID(); + UUID maintenanceEventId = UUID.randomUUID(); + Instant eventAt = Instant.parse("2026-04-25T08:10:00Z"); + + AssetItem asset = AssetItem.builder() + .id(assetId) + .conditionPercent(80) + .build(); + AssetImage currentNewImage = AssetImage.builder() + .id(UUID.randomUUID()) + .assetItem(asset) + .key("asset/new-after.jpg") + .createdAt(eventAt.minusSeconds(20)) + .build(); + BatchUpdateAssetRequest request = new BatchUpdateAssetRequest( + jobId, + List.of(new BatchUpdateAssetRequest.AssetUpdateItem(assetId, 85, "Checked", null)) + ); + + when(assetItemRepository.findAllById(List.of(assetId))).thenReturn(List.of(asset)); + when(translationAutoFillService.complete(Map.of("vi", "Checked"))) + .thenReturn(TranslationMap.of(Map.of("vi", "Checked"))); + when(assetEventRepository.save(any(AssetEvent.class))).thenAnswer(invocation -> { + AssetEvent event = invocation.getArgument(0); + event.setId(maintenanceEventId); + event.setCreatedAt(eventAt); + return event; + }); + when(assetImageRepository.findByAssetItemId(assetId)).thenReturn(List.of(currentNewImage)); + + service.batchUpdateAssetCondition(UUID.randomUUID(), request); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(assetEventImageRepository).saveAll(captor.capture()); + assertThat(captor.getValue()) + .extracting(AssetEventImage::getType, AssetEventImage::getKey) + .containsExactly( + org.assertj.core.groups.Tuple.tuple(AssetEventImageType.BEFORE, "asset/new-after.jpg") + ); + verify(assetEventRepository, never()).findRecentUnscopedImageReplacementEvents(any(), any(), any(), any()); + } + + @Test + @DisplayName("confirmAsset moves waiting asset to IN_USE without role lookup") + void confirmAssetAllowsWaitingAssetWithoutRoleLookup() { + UUID assetId = UUID.randomUUID(); + AssetItem asset = AssetItem.builder() + .id(assetId) + .status(AssetStatus.WAITING_MANAGER_CONFIRM) + .build(); + AssetItemDto dto = new AssetItemDto(); + + when(assetItemRepository.findById(assetId)).thenReturn(Optional.of(asset)); + when(assetItemRepository.save(asset)).thenReturn(asset); + when(assetMapper.mapAssetItem(asset)).thenReturn(dto); + + AssetItemDto result = service.confirmAsset(assetId, AssetStatus.IN_USE); + + assertThat(result).isSameAs(dto); + assertThat(asset.getStatus()).isEqualTo(AssetStatus.IN_USE); + verify(assetItemRepository).save(asset); + verify(grpcUserClient, never()).getUserIdAndRoleByKeyCloakId(any()); + } + + @Test + @DisplayName("confirmAsset rejects assets outside waiting manager confirm state") + void confirmAssetRejectsWrongState() { + UUID assetId = UUID.randomUUID(); + AssetItem asset = AssetItem.builder() + .id(assetId) + .status(AssetStatus.IN_USE) + .build(); + + when(assetItemRepository.findById(assetId)).thenReturn(Optional.of(asset)); + + assertThatThrownBy(() -> service.confirmAsset(assetId, AssetStatus.DELETED)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Asset is not in waiting state"); + + verify(assetItemRepository, never()).save(any()); + verify(grpcUserClient, never()).getUserIdAndRoleByKeyCloakId(any()); + } + + private HouseResponse houseResponse(UUID houseId, UUID... functionAreaIds) { + HouseResponse.Builder builder = HouseResponse.newBuilder().setId(houseId.toString()); + for (UUID functionAreaId : functionAreaIds) { + builder.addFunctionalAreas( + FunctionalAreaResponse.newBuilder() + .setId(functionAreaId.toString()) + .build() + ); + } + return builder.build(); + } +} diff --git a/src/test/java/com/isums/assetservice/services/AssetTagLogServiceImplTest.java b/src/test/java/com/isums/assetservice/services/AssetTagLogServiceImplTest.java new file mode 100644 index 0000000..6e17b35 --- /dev/null +++ b/src/test/java/com/isums/assetservice/services/AssetTagLogServiceImplTest.java @@ -0,0 +1,63 @@ +package com.isums.assetservice.services; + +import com.isums.assetservice.domains.entities.AssetTagLog; +import com.isums.assetservice.infrastructures.repositories.AssetTagLogRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AssetTagLogServiceImpl") +class AssetTagLogServiceImplTest { + + @Mock private AssetTagLogRepository repo; + + @InjectMocks private AssetTagLogServiceImpl service; + + @Test + @DisplayName("getLogsByTag returns logs when tagValue valid") + void byTagHappy() { + AssetTagLog log = AssetTagLog.builder().tagValue("TAG-1").build(); + when(repo.findByTagValueOrderByCreatedAtDesc("TAG-1")).thenReturn(List.of(log)); + + assertThat(service.getLogsByTag("TAG-1")).containsExactly(log); + } + + @Test + @DisplayName("getLogsByTag wraps when tagValue null or blank") + void byTagBlank() { + assertThatThrownBy(() -> service.getLogsByTag(null)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Error to get logs by tagValue"); + assertThatThrownBy(() -> service.getLogsByTag(" ")) + .isInstanceOf(RuntimeException.class); + } + + @Test + @DisplayName("getLogsByAsset returns logs when assetId provided") + void byAssetHappy() { + UUID id = UUID.randomUUID(); + AssetTagLog log = AssetTagLog.builder().newAssetId(id).build(); + when(repo.findAllLogsByAssetId(id)).thenReturn(List.of(log)); + + assertThat(service.getLogsByAsset(id)).containsExactly(log); + } + + @Test + @DisplayName("getLogsByAsset wraps when assetId null") + void byAssetNull() { + assertThatThrownBy(() -> service.getLogsByAsset(null)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Error to get logs by asset"); + } +} diff --git a/src/test/java/com/isums/assetservice/services/AssetTagServiceImplTest.java b/src/test/java/com/isums/assetservice/services/AssetTagServiceImplTest.java new file mode 100644 index 0000000..57d8688 --- /dev/null +++ b/src/test/java/com/isums/assetservice/services/AssetTagServiceImplTest.java @@ -0,0 +1,235 @@ +package com.isums.assetservice.services; + +import com.isums.assetservice.domains.dtos.AssetTagDto.AssetTagDto; +import com.isums.assetservice.domains.dtos.AssetTagDto.AttachTagRequest; +import com.isums.assetservice.domains.dtos.AssetTagDto.TransferTagRequest; +import com.isums.assetservice.domains.entities.AssetItem; +import com.isums.assetservice.domains.entities.AssetTag; +import com.isums.assetservice.domains.entities.AssetTagLog; +import com.isums.assetservice.domains.enums.TagType; +import com.isums.assetservice.infrastructures.mapper.AssetMapper; +import com.isums.assetservice.infrastructures.repositories.AssetImageRepository; +import com.isums.assetservice.infrastructures.repositories.AssetItemRepository; +import com.isums.assetservice.infrastructures.repositories.AssetTagLogRepository; +import com.isums.assetservice.infrastructures.repositories.AssetTagRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AssetTagServiceImpl") +class AssetTagServiceImplTest { + + @Mock private AssetTagRepository tagRepo; + @Mock private AssetItemRepository itemRepo; + @Mock private AssetTagLogRepository tagLogRepo; + @Mock private AssetImageRepository imageRepo; + @Mock private AssetMapper mapper; + @Mock private S3ServiceImpl s3; + + @InjectMocks private AssetTagServiceImpl service; + + private UUID assetId; + private UUID houseId; + + @BeforeEach + void setUp() { + assetId = UUID.randomUUID(); + houseId = UUID.randomUUID(); + } + + private AssetItem asset() { + return AssetItem.builder().id(assetId).houseId(houseId).build(); + } + + @Nested + @DisplayName("attachTag") + class Attach { + + @Test + @DisplayName("saves new tag and creates ATTACHED log when happy path") + void happy() { + AttachTagRequest req = new AttachTagRequest(assetId, "TAG-1", TagType.NFC); + when(itemRepo.findById(assetId)).thenReturn(Optional.of(asset())); + when(tagRepo.existsByTagValueAndIsActiveTrue("TAG-1")).thenReturn(false); + when(tagRepo.existsByAssetItemIdAndTagTypeAndIsActiveTrue(assetId, TagType.NFC)).thenReturn(false); + when(tagRepo.save(any(AssetTag.class))).thenAnswer(a -> a.getArgument(0)); + AssetTagDto dto = new AssetTagDto(UUID.randomUUID(), "TAG-1", TagType.NFC, assetId, houseId, null, true); + when(mapper.tagDto(any(AssetTag.class))).thenReturn(dto); + + assertThat(service.attachTag(req)).isNotNull(); + + verify(tagLogRepo).save(any(AssetTagLog.class)); + } + + @Test + @DisplayName("wraps asset-not-found as RuntimeException") + void assetMissing() { + when(itemRepo.findById(assetId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.attachTag( + new AttachTagRequest(assetId, "TAG-1", TagType.NFC))) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Error to attach tag"); + } + + @Test + @DisplayName("rejects tag value already in use") + void tagInUse() { + when(itemRepo.findById(assetId)).thenReturn(Optional.of(asset())); + when(tagRepo.existsByTagValueAndIsActiveTrue("TAG-1")).thenReturn(true); + + assertThatThrownBy(() -> service.attachTag( + new AttachTagRequest(assetId, "TAG-1", TagType.NFC))) + .isInstanceOf(RuntimeException.class); + } + + @Test + @DisplayName("rejects asset already has same tagType active") + void sameTypeExists() { + when(itemRepo.findById(assetId)).thenReturn(Optional.of(asset())); + when(tagRepo.existsByTagValueAndIsActiveTrue("TAG-1")).thenReturn(false); + when(tagRepo.existsByAssetItemIdAndTagTypeAndIsActiveTrue(assetId, TagType.NFC)).thenReturn(true); + + assertThatThrownBy(() -> service.attachTag( + new AttachTagRequest(assetId, "TAG-1", TagType.NFC))) + .isInstanceOf(RuntimeException.class); + } + } + + @Nested + @DisplayName("detachTag") + class Detach { + + @Test + @DisplayName("marks inactive, creates DETACHED log") + void happy() { + AssetTag tag = AssetTag.builder() + .id(UUID.randomUUID()).tagValue("TAG-1").tagType(TagType.NFC) + .isActive(true).assetItem(asset()).build(); + when(tagRepo.findByTagValueAndIsActiveTrue("TAG-1")).thenReturn(Optional.of(tag)); + when(tagRepo.save(tag)).thenReturn(tag); + when(mapper.tagDto(tag)).thenReturn(new AssetTagDto( + tag.getId(), "TAG-1", TagType.NFC, assetId, houseId, null, false)); + + service.detachTag("TAG-1"); + + assertThat(tag.getIsActive()).isFalse(); + assertThat(tag.getDeactivatedAt()).isNotNull(); + verify(tagLogRepo).save(any(AssetTagLog.class)); + } + + @Test + @DisplayName("wraps active-tag-missing") + void missing() { + when(tagRepo.findByTagValueAndIsActiveTrue("TAG-X")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.detachTag("TAG-X")) + .isInstanceOf(RuntimeException.class); + } + } + + @Nested + @DisplayName("transferTag") + class Transfer { + + @Test + @DisplayName("deactivates old, creates new tag + TRANSFERRED log on happy path") + void happy() { + UUID newAssetId = UUID.randomUUID(); + UUID newHouseId = UUID.randomUUID(); + AssetItem newAsset = AssetItem.builder().id(newAssetId).houseId(newHouseId).build(); + AssetTag current = AssetTag.builder() + .id(UUID.randomUUID()).tagValue("TAG-1").tagType(TagType.NFC) + .isActive(true).assetItem(asset()).build(); + + when(tagRepo.findByTagValueAndIsActiveTrue("TAG-1")).thenReturn(Optional.of(current)); + when(itemRepo.findById(newAssetId)).thenReturn(Optional.of(newAsset)); + when(tagRepo.existsByAssetItemIdAndTagTypeAndIsActiveTrue(newAssetId, TagType.NFC)).thenReturn(false); + when(tagRepo.save(any(AssetTag.class))).thenAnswer(a -> a.getArgument(0)); + when(mapper.tagDto(any(AssetTag.class))).thenReturn(new AssetTagDto( + UUID.randomUUID(), "TAG-1", TagType.NFC, newAssetId, newHouseId, null, true)); + + service.transferTag(new TransferTagRequest("TAG-1", newAssetId)); + + assertThat(current.getIsActive()).isFalse(); + verify(tagLogRepo).save(any(AssetTagLog.class)); + } + + @Test + @DisplayName("rejects blank tag value") + void blank() { + assertThatThrownBy(() -> service.transferTag( + new TransferTagRequest(" ", UUID.randomUUID()))) + .isInstanceOf(RuntimeException.class); + } + + @Test + @DisplayName("rejects transfer to same asset") + void sameAsset() { + AssetTag current = AssetTag.builder() + .id(UUID.randomUUID()).tagValue("TAG-1").tagType(TagType.NFC) + .isActive(true).assetItem(asset()).build(); + + when(tagRepo.findByTagValueAndIsActiveTrue("TAG-1")).thenReturn(Optional.of(current)); + when(itemRepo.findById(assetId)).thenReturn(Optional.of(asset())); + + assertThatThrownBy(() -> service.transferTag(new TransferTagRequest("TAG-1", assetId))) + .isInstanceOf(RuntimeException.class); + } + + @Test + @DisplayName("rejects if new asset already has active tag of same type") + void newAssetHasTag() { + UUID newAssetId = UUID.randomUUID(); + AssetItem newAsset = AssetItem.builder().id(newAssetId).houseId(UUID.randomUUID()).build(); + AssetTag current = AssetTag.builder() + .id(UUID.randomUUID()).tagValue("TAG-1").tagType(TagType.NFC) + .isActive(true).assetItem(asset()).build(); + + when(tagRepo.findByTagValueAndIsActiveTrue("TAG-1")).thenReturn(Optional.of(current)); + when(itemRepo.findById(newAssetId)).thenReturn(Optional.of(newAsset)); + when(tagRepo.existsByAssetItemIdAndTagTypeAndIsActiveTrue(newAssetId, TagType.NFC)).thenReturn(true); + + assertThatThrownBy(() -> service.transferTag(new TransferTagRequest("TAG-1", newAssetId))) + .isInstanceOf(RuntimeException.class); + } + } + + @Nested + @DisplayName("getAssetItemByTagValue") + class GetByTag { + + @Test + @DisplayName("rejects blank tag value") + void blank() { + assertThatThrownBy(() -> service.getAssetItemByTagValue("")) + .isInstanceOf(RuntimeException.class); + } + + @Test + @DisplayName("wraps when tag not found") + void notFound() { + when(tagRepo.findByTagValueAndIsActiveTrue("TAG-X")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getAssetItemByTagValue("TAG-X")) + .isInstanceOf(RuntimeException.class); + } + } +} diff --git a/src/test/java/com/isums/assetservice/services/IotNodeTokenServiceImplTest.java b/src/test/java/com/isums/assetservice/services/IotNodeTokenServiceImplTest.java new file mode 100644 index 0000000..474e5f7 --- /dev/null +++ b/src/test/java/com/isums/assetservice/services/IotNodeTokenServiceImplTest.java @@ -0,0 +1,96 @@ +package com.isums.assetservice.services; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("IotNodeTokenServiceImpl (security-critical)") +class IotNodeTokenServiceImplTest { + + @Mock private StringRedisTemplate redis; + @Mock private ValueOperations valueOps; + + @InjectMocks private IotNodeTokenServiceImpl service; + + @BeforeEach + void setUp() { + // Inject test secret (production reads from config, no longer hardcoded) + ReflectionTestUtils.setField(service, "secretKey", "test-secret-not-for-prod"); + } + + @Test + @DisplayName("generateToken writes HMAC-SHA256 hex to Redis with 10-min TTL") + void generates() { + when(redis.opsForValue()).thenReturn(valueOps); + + String token = service.generateToken("SERIAL-123"); + + assertThat(token).isNotNull().matches("^[0-9a-f]{64}$"); // 64 hex chars (SHA-256) + verify(valueOps).set(eq("iot-token:SERIAL-123"), eq(token), eq(Duration.ofMinutes(10))); + } + + @Test + @DisplayName("generateToken is deterministic for same (secret, serial)") + void deterministic() { + when(redis.opsForValue()).thenReturn(valueOps); + + String t1 = service.generateToken("SERIAL-X"); + String t2 = service.generateToken("SERIAL-X"); + assertThat(t1).isEqualTo(t2); + } + + @Test + @DisplayName("isTokenValid returns true when Redis has matching token") + void validMatch() { + when(redis.opsForValue()).thenReturn(valueOps); + when(valueOps.get("iot-token:SERIAL-A")).thenReturn("abc123"); + + assertThat(service.isTokenValid("SERIAL-A", "abc123")).isTrue(); + } + + @Test + @DisplayName("isTokenValid returns false when token differs") + void invalidMismatch() { + when(redis.opsForValue()).thenReturn(valueOps); + when(valueOps.get("iot-token:SERIAL-A")).thenReturn("abc123"); + + assertThat(service.isTokenValid("SERIAL-A", "wrong")).isFalse(); + } + + @Test + @DisplayName("isTokenValid returns false when Redis returns null (expired/never-generated)") + void missingToken() { + when(redis.opsForValue()).thenReturn(valueOps); + when(valueOps.get("iot-token:SERIAL-A")).thenReturn(null); + + assertThat(service.isTokenValid("SERIAL-A", "abc")).isFalse(); + } + + @Test + @DisplayName("isTokenValid returns false when provided token null (null-safe)") + void providedTokenNull() { + assertThat(service.isTokenValid("SERIAL-A", null)).isFalse(); + } + + @Test + @DisplayName("revokeToken deletes the Redis key") + void revokes() { + service.revokeToken("SERIAL-Z"); + verify(redis).delete("iot-token:SERIAL-Z"); + } +} diff --git a/src/test/java/com/isums/assetservice/services/IotProvisioningServiceImplTest.java b/src/test/java/com/isums/assetservice/services/IotProvisioningServiceImplTest.java new file mode 100644 index 0000000..4188925 --- /dev/null +++ b/src/test/java/com/isums/assetservice/services/IotProvisioningServiceImplTest.java @@ -0,0 +1,139 @@ +package com.isums.assetservice.services; + +import com.isums.assetservice.domains.entities.AssetItem; +import com.isums.assetservice.domains.entities.IoTDevice; +import com.isums.assetservice.domains.enums.NodeCapability; +import com.isums.assetservice.infrastructures.abstracts.IotNodeTokenService; +import com.isums.assetservice.infrastructures.grpcs.HouseGrpcImpl; +import com.isums.assetservice.infrastructures.repositories.AssetCategoryRepository; +import com.isums.assetservice.infrastructures.repositories.AssetItemRepository; +import com.isums.assetservice.infrastructures.repositories.IoTDeviceRepository; +import com.isums.assetservice.infrastructures.repositories.IotControllerRepository; +import com.isums.houseservice.grpc.FunctionalAreaResponse; +import com.isums.houseservice.grpc.HouseResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.server.ResponseStatusException; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.iot.IotClient; +import software.amazon.awssdk.services.iotdataplane.IotDataPlaneClient; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import tools.jackson.databind.ObjectMapper; + +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("IotProvisioningServiceImpl") +class IotProvisioningServiceImplTest { + + @Mock private IotClient iotClient; + @Mock private IotControllerRepository controllerRepository; + @Mock private DynamoDbClient dynamoDbClient; + @Mock private IoTDeviceRepository ioTDeviceRepository; + @Mock private AssetItemRepository assetItemRepository; + @Mock private IoTDeviceServiceImpl ioTDeviceService; + @Mock private IotNodeTokenService iotNodeTokenService; + @Mock private AssetCategoryRepository assetCategoryRepository; + @Mock private HouseGrpcImpl houseGrpc; + @Mock private ObjectMapper objectMapper; + @Mock private S3Presigner s3Presigner; + @Mock private IotDataPlaneClient iotDataPlaneClient; + + @InjectMocks private IotProvisioningServiceImpl service; + + @Test + @DisplayName("assignNodeToArea rejects foreign area") + void assignNodeToAreaRejectsForeignArea() { + UUID houseId = UUID.randomUUID(); + UUID validAreaId = UUID.randomUUID(); + UUID foreignAreaId = UUID.randomUUID(); + AssetItem asset = AssetItem.builder() + .houseId(houseId) + .build(); + IoTDevice device = IoTDevice.builder() + .thing("node-1") + .assetItem(asset) + .build(); + + when(ioTDeviceRepository.findByThing("node-1")).thenReturn(Optional.of(device)); + when(houseGrpc.getHouseById(houseId)).thenReturn(houseResponse(houseId, validAreaId)); + + assertThatThrownBy(() -> service.assignNodeToArea("node-1", foreignAreaId)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining(foreignAreaId.toString()) + .hasMessageContaining(houseId.toString()); + + verify(assetItemRepository, never()).save(any(AssetItem.class)); + } + + @Test + @DisplayName("provisionNode rejects foreign area") + void provisionNodeRejectsForeignArea() { + UUID houseId = UUID.randomUUID(); + UUID validAreaId = UUID.randomUUID(); + UUID foreignAreaId = UUID.randomUUID(); + + when(iotNodeTokenService.isTokenValid("SERIAL-1", "token-1")).thenReturn(true); + when(houseGrpc.getHouseById(houseId)).thenReturn(houseResponse(houseId, validAreaId)); + + assertThatThrownBy(() -> service.provisionNode( + houseId, + "SERIAL-1", + "token-1", + foreignAreaId, + Set.of(NodeCapability.PZEM) + )) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining(foreignAreaId.toString()) + .hasMessageContaining(houseId.toString()); + + verify(ioTDeviceRepository, never()).findBySerialNumber(any()); + verify(assetItemRepository, never()).save(any(AssetItem.class)); + } + + @Test + @DisplayName("provisionController rejects foreign area") + void provisionControllerRejectsForeignArea() { + UUID houseId = UUID.randomUUID(); + UUID validAreaId = UUID.randomUUID(); + UUID foreignAreaId = UUID.randomUUID(); + + when(controllerRepository.findByDeviceId("AA:BB:CC")).thenReturn(Optional.empty()); + when(houseGrpc.getHouseById(houseId)).thenReturn(houseResponse(houseId, validAreaId)); + + assertThatThrownBy(() -> service.provisionController(houseId, foreignAreaId, "AA:BB:CC")) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining(foreignAreaId.toString()) + .hasMessageContaining(houseId.toString()); + + verifyNoInteractions(iotClient); + verify(controllerRepository, never()).save(any()); + } + + private HouseResponse houseResponse(UUID houseId, UUID... areaIds) { + HouseResponse.Builder builder = HouseResponse.newBuilder() + .setId(houseId.toString()); + + for (UUID areaId : areaIds) { + builder.addFunctionalAreas(FunctionalAreaResponse.newBuilder() + .setId(areaId.toString()) + .setName("Area " + areaId) + .build()); + } + + return builder.build(); + } +} diff --git a/src/test/java/com/isums/assetservice/services/S3ServiceImplTest.java b/src/test/java/com/isums/assetservice/services/S3ServiceImplTest.java new file mode 100644 index 0000000..c691141 --- /dev/null +++ b/src/test/java/com/isums/assetservice/services/S3ServiceImplTest.java @@ -0,0 +1,128 @@ +package com.isums.assetservice.services; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("S3ServiceImpl (asset-service)") +class S3ServiceImplTest { + + @Mock private S3Client s3Client; + + @InjectMocks private S3ServiceImpl service; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "bucket", "test-bucket"); + ReflectionTestUtils.setField(service, "cloudFrontDomain", "cdn.example.com"); + } + + @Nested + @DisplayName("upload") + class Upload { + + @Test + @DisplayName("uses folder parameter in the key (fixed: was literal 'folder')") + void folderUsed() { + MockMultipartFile file = new MockMultipartFile( + "file", "a.JPG", "image/jpeg", + "data".getBytes(StandardCharsets.UTF_8)); + + String key = service.upload(file, "asset/42"); + + assertThat(key).startsWith("media/asset/42/"); + assertThat(key).endsWith(".jpg"); + ArgumentCaptor cap = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(s3Client).putObject(cap.capture(), any(RequestBody.class)); + assertThat(cap.getValue().bucket()).isEqualTo("test-bucket"); + } + + @Test + @DisplayName("defaults extension to jpg when filename has none") + void noExt() { + MockMultipartFile file = new MockMultipartFile( + "file", "noext", "image/jpeg", new byte[]{1}); + + assertThat(service.upload(file, "f")).endsWith(".jpg"); + } + + @Test + @DisplayName("wraps IOException into RuntimeException") + void ioException() { + MultipartFile broken = new BrokenMultipartFile(); + + assertThatThrownBy(() -> service.upload(broken, "f")) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Failed to upload"); + } + } + + @Nested + @DisplayName("delete") + class Delete { + + @Test + @DisplayName("deletes object with bucket and key") + void deletes() { + service.delete("media/asset/42/x.jpg"); + + ArgumentCaptor cap = ArgumentCaptor.forClass(DeleteObjectRequest.class); + verify(s3Client).deleteObject(cap.capture()); + assertThat(cap.getValue().key()).isEqualTo("media/asset/42/x.jpg"); + } + + @Test + @DisplayName("swallows S3 errors so callers are not broken") + void swallowsErrors() { + doThrow(AwsServiceException.builder().message("boom").build()) + .when(s3Client).deleteObject(any(DeleteObjectRequest.class)); + + service.delete("k"); + } + } + + @Test + @DisplayName("getImageUrl builds CDN URL") + void cdnUrl() { + assertThat(service.getImageUrl("media/x/y.jpg")) + .isEqualTo("https://cdn.example.com/media/x/y.jpg"); + } + + private static class BrokenMultipartFile implements MultipartFile { + @Override public String getName() { return "broken"; } + @Override public String getOriginalFilename() { return "x.jpg"; } + @Override public String getContentType() { return "image/jpeg"; } + @Override public boolean isEmpty() { return false; } + @Override public long getSize() { return 1L; } + @Override public byte[] getBytes() { return new byte[]{1}; } + @Override public InputStream getInputStream() throws IOException { + throw new IOException("simulated"); + } + @Override public void transferTo(java.io.File dest) {} + } +} diff --git a/src/test/java/com/isums/assetservice/services/TranslationAutoFillServiceTest.java b/src/test/java/com/isums/assetservice/services/TranslationAutoFillServiceTest.java new file mode 100644 index 0000000..9862d7c --- /dev/null +++ b/src/test/java/com/isums/assetservice/services/TranslationAutoFillServiceTest.java @@ -0,0 +1,58 @@ +package com.isums.assetservice.services; + +import common.i18n.TranslationMap; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.translate.TranslateClient; +import software.amazon.awssdk.services.translate.model.TranslateTextRequest; +import software.amazon.awssdk.services.translate.model.TranslateTextResponse; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DisplayName("TranslationAutoFillService") +class TranslationAutoFillServiceTest { + + @Test + @DisplayName("fills missing EN and JA from Vietnamese input") + void fillsMissingLanguages() { + TranslateClient client = mock(TranslateClient.class); + when(client.translateText(any(TranslateTextRequest.class))).thenAnswer(invocation -> { + TranslateTextRequest request = invocation.getArgument(0); + return switch (request.targetLanguageCode()) { + case "en" -> TranslateTextResponse.builder().translatedText("Air conditioner").build(); + case "ja" -> TranslateTextResponse.builder().translatedText("エアコン").build(); + default -> throw new IllegalStateException("Unexpected target"); + }; + }); + + TranslationAutoFillService service = new TranslationAutoFillService(client); + + TranslationMap result = service.complete(Map.of("vi", "Máy lạnh")); + + assertThat(result.getTranslations()) + .containsEntry("vi", "Máy lạnh") + .containsEntry("en", "Air conditioner") + .containsEntry("ja", "エアコン"); + } + + @Test + @DisplayName("falls back to source text when translation call fails") + void fallsBackToSourceText() { + TranslateClient client = mock(TranslateClient.class); + when(client.translateText(any(TranslateTextRequest.class))).thenThrow(new RuntimeException("aws down")); + + TranslationAutoFillService service = new TranslationAutoFillService(client); + + TranslationMap result = service.complete(Map.of("vi", "Bếp từ")); + + assertThat(result.getTranslations()) + .containsEntry("vi", "Bếp từ") + .containsEntry("en", "Bếp từ") + .containsEntry("ja", "Bếp từ"); + } +} diff --git a/src/test/java/com/isums/assetservice/services/UtilityAlertsServiceImplTest.java b/src/test/java/com/isums/assetservice/services/UtilityAlertsServiceImplTest.java new file mode 100644 index 0000000..dce5053 --- /dev/null +++ b/src/test/java/com/isums/assetservice/services/UtilityAlertsServiceImplTest.java @@ -0,0 +1,303 @@ +package com.isums.assetservice.services; + +import com.isums.assetservice.domains.dtos.ForecastScopeDto; +import com.isums.assetservice.domains.dtos.UtilityAlertsResponse; +import com.isums.assetservice.domains.entities.IotThreshold; +import com.isums.assetservice.domains.enums.UtilityMetric; +import com.isums.assetservice.domains.enums.UtilityStatus; +import com.isums.assetservice.infrastructures.abstracts.IotForecastService; +import com.isums.assetservice.infrastructures.grpcs.GrpcUserClient; +import com.isums.assetservice.infrastructures.grpcs.HouseGrpcImpl; +import com.isums.assetservice.infrastructures.repositories.IotThresholdRepository; +import com.isums.houseservice.grpc.HouseResponse; +import com.isums.userservice.grpc.UserResponse; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.QueryRequest; +import software.amazon.awssdk.services.dynamodb.model.QueryResponse; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +/** + * Focused unit tests for UtilityAlertsServiceImpl — the orchestrator + * that the /api/assets/utility-alerts endpoint depends on. We exercise + * the branches that matter to the landlord experience: + * + *

    + *
  • Role-based scope filtering (LANDLORD → listHousesByLandlord, + * MANAGER → listHousesByManager, unknown role → empty).
  • + *
  • Status derivation against the 80% / 100% band split.
  • + *
  • Graceful degradation when forecast or threshold is missing.
  • + *
+ * + *

Integration coverage for the DynamoDB / Postgres / gRPC joins + * belongs in a separate {@code @SpringBootTest} with LocalStack + + * Testcontainers — this test stays fast and schema-free. + */ +@ExtendWith(MockitoExtension.class) +// We share a set of default stubs from setUp() across the nested groups +// (userGrpc, houseGrpc, dynamoDb empty-result). Some tests — TECH_STAFF +// and the two alert-count tests — deliberately bypass those paths or +// override the DDB stub, which trips Mockito's strict-stubbing check. +// Lenient is safer here than picking apart which stub each test uses; +// the trade-off is that an accidentally-unused stub won't fail loud, +// but the assertions are already strong enough to catch wiring bugs. +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("UtilityAlertsServiceImpl") +class UtilityAlertsServiceImplTest { + + @Mock private IotForecastService iotForecastService; + @Mock private IotThresholdRepository thresholdRepository; + @Mock private HouseGrpcImpl houseGrpc; + @Mock private GrpcUserClient userGrpc; + @Mock private DynamoDbClient dynamoDb; + + private UtilityAlertsServiceImpl service; + private MeterRegistry meterRegistry; + + private static final String KEYCLOAK_ID = "kc-sub-abc"; + private UUID userId; + private UUID houseId; + + @BeforeEach + void setUp() { + meterRegistry = new SimpleMeterRegistry(); + service = new UtilityAlertsServiceImpl( + iotForecastService, + thresholdRepository, + houseGrpc, + userGrpc, + dynamoDb, + meterRegistry + ); + userId = UUID.randomUUID(); + houseId = UUID.randomUUID(); + + // Zero-alerts DDB response used by most tests — specific tests + // can override via a lenient when() in their own setup. + when(dynamoDb.query(any(QueryRequest.class))) + .thenReturn(QueryResponse.builder().items(Collections.emptyList()).build()); + } + + @Nested + @DisplayName("scope resolution") + class Scope { + + @Test + @DisplayName("TECH_STAFF-only caller → empty items, summary with zero houses") + void techStaffGetsEmpty() { + stubCaller(List.of("TECH_STAFF")); + + UtilityAlertsResponse res = service.listAlerts(KEYCLOAK_ID, UtilityMetric.ELECTRICITY); + + assertThat(res.items()).isEmpty(); + assertThat(res.summary().houseCount()).isZero(); + assertThat(res.summary().housesOverThreshold()).isZero(); + } + + @Test + @DisplayName("LANDLORD → houses from listHousesByLandlord") + void landlordFetchedFromLandlordRoute() { + stubCaller(List.of("LANDLORD")); + when(houseGrpc.listHousesByLandlord(eq(userId))) + .thenReturn(List.of(house(houseId, "Nhà Q1"))); + // No threshold / no forecast → NO_DATA + when(thresholdRepository.findByHouseIdAndAreaIdIsNullAndMetric(any(), any())) + .thenReturn(Optional.empty()); + + UtilityAlertsResponse res = service.listAlerts(KEYCLOAK_ID, UtilityMetric.ELECTRICITY); + + assertThat(res.items()).hasSize(1); + assertThat(res.items().get(0).status()).isEqualTo(UtilityStatus.NO_DATA); + } + + @Test + @DisplayName("MANAGER → houses from listHousesByManager") + void managerFetchedFromManagerRoute() { + stubCaller(List.of("MANAGER")); + when(houseGrpc.listHousesByManager(eq(userId))) + .thenReturn(List.of(house(houseId, "Nhà Q3"))); + when(thresholdRepository.findByHouseIdAndAreaIdIsNullAndMetric(any(), any())) + .thenReturn(Optional.empty()); + + UtilityAlertsResponse res = service.listAlerts(KEYCLOAK_ID, UtilityMetric.WATER); + + assertThat(res.items()).hasSize(1); + assertThat(res.metric()).isEqualTo(UtilityMetric.WATER); + assertThat(res.unit()).isEqualTo("m³"); + } + } + + @Nested + @DisplayName("status derivation") + class Status { + + @Test + @DisplayName("usage < 80% of limit → GOOD") + void good() { + prepareHouseWith(500.0 /*used*/, 1000.0 /*limit*/); + UtilityAlertsResponse res = service.listAlerts(KEYCLOAK_ID, UtilityMetric.ELECTRICITY); + assertThat(res.items().get(0).status()).isEqualTo(UtilityStatus.GOOD); + assertThat(res.summary().housesOverThreshold()).isZero(); + } + + @Test + @DisplayName("usage 80–99% → WARNING") + void warning() { + prepareHouseWith(850.0, 1000.0); + UtilityAlertsResponse res = service.listAlerts(KEYCLOAK_ID, UtilityMetric.ELECTRICITY); + assertThat(res.items().get(0).status()).isEqualTo(UtilityStatus.WARNING); + assertThat(res.summary().housesOverThreshold()).isEqualTo(1); + } + + @Test + @DisplayName("usage ≥ 100% → CRITICAL") + void critical() { + prepareHouseWith(1050.0, 1000.0); + UtilityAlertsResponse res = service.listAlerts(KEYCLOAK_ID, UtilityMetric.ELECTRICITY); + assertThat(res.items().get(0).status()).isEqualTo(UtilityStatus.CRITICAL); + assertThat(res.summary().housesOverThreshold()).isEqualTo(1); + } + + @Test + @DisplayName("threshold disabled → NO_DATA (don't alert)") + void disabledThresholdSkipped() { + stubCaller(List.of("LANDLORD")); + when(houseGrpc.listHousesByLandlord(eq(userId))) + .thenReturn(List.of(house(houseId, "Nhà Q5"))); + when(thresholdRepository.findByHouseIdAndAreaIdIsNullAndMetric(eq(houseId), any())) + .thenReturn(Optional.of(threshold(1000.0, false))); // disabled + when(iotForecastService.getForecast(eq(houseId), any(), any(), any())) + .thenReturn(forecast(1500.0)); + + UtilityAlertsResponse res = service.listAlerts(KEYCLOAK_ID, UtilityMetric.ELECTRICITY); + + assertThat(res.items().get(0).status()).isEqualTo(UtilityStatus.NO_DATA); + assertThat(res.items().get(0).monthlyLimit()).isNull(); + } + + @Test + @DisplayName("forecast missing → NO_DATA even with threshold") + void forecastMissing() { + stubCaller(List.of("LANDLORD")); + when(houseGrpc.listHousesByLandlord(eq(userId))) + .thenReturn(List.of(house(houseId, "Nhà Q7"))); + when(thresholdRepository.findByHouseIdAndAreaIdIsNullAndMetric(eq(houseId), any())) + .thenReturn(Optional.of(threshold(1000.0, true))); + when(iotForecastService.getForecast(eq(houseId), any(), any(), any())) + .thenReturn(null); + + UtilityAlertsResponse res = service.listAlerts(KEYCLOAK_ID, UtilityMetric.ELECTRICITY); + + assertThat(res.items().get(0).status()).isEqualTo(UtilityStatus.NO_DATA); + assertThat(res.items().get(0).currentUsage()).isNull(); + } + } + + @Nested + @DisplayName("alert count from DynamoDB") + class AlertCount { + + @Test + @DisplayName("DDB query returns rows → count echoed in tile") + void countEchoed() { + prepareHouseWith(500.0, 1000.0); + // Override the default zero-items stub from setUp() with 3 + // unresolved alerts for today's partition. + when(dynamoDb.query(any(QueryRequest.class))) + .thenReturn(QueryResponse.builder() + .items(List.of( + java.util.Map.of("ts", AttributeValue.builder().n("1234567890").build()), + java.util.Map.of("ts", AttributeValue.builder().n("1234567891").build()), + java.util.Map.of("ts", AttributeValue.builder().n("1234567892").build()) + )) + .build()); + + UtilityAlertsResponse res = service.listAlerts(KEYCLOAK_ID, UtilityMetric.ELECTRICITY); + + assertThat(res.items().get(0).activeAlertCount()).isEqualTo(3); + assertThat(res.items().get(0).lastAlertAt()).isEqualTo(1234567890L); + } + + @Test + @DisplayName("DDB failure → count falls back to 0, tile still rendered") + void ddbFailureDegrades() { + prepareHouseWith(500.0, 1000.0); + when(dynamoDb.query(any(QueryRequest.class))) + .thenThrow(new RuntimeException("boom")); + + UtilityAlertsResponse res = service.listAlerts(KEYCLOAK_ID, UtilityMetric.ELECTRICITY); + + assertThat(res.items()).hasSize(1); + assertThat(res.items().get(0).activeAlertCount()).isZero(); + } + } + + // ── Helpers ─────────────────────────────────────────────────────── + + private void stubCaller(List roles) { + UserResponse user = UserResponse.newBuilder() + .setId(userId.toString()) + .addAllRoles(roles) + .build(); + when(userGrpc.getUserIdAndRoleByKeyCloakId(KEYCLOAK_ID)).thenReturn(user); + } + + private void prepareHouseWith(double used, double limit) { + stubCaller(List.of("LANDLORD")); + when(houseGrpc.listHousesByLandlord(eq(userId))) + .thenReturn(List.of(house(houseId, "Test House"))); + when(thresholdRepository.findByHouseIdAndAreaIdIsNullAndMetric(eq(houseId), any())) + .thenReturn(Optional.of(threshold(limit, true))); + when(iotForecastService.getForecast(eq(houseId), any(), any(), any())) + .thenReturn(forecast(used)); + } + + private static HouseResponse house(UUID id, String name) { + return HouseResponse.newBuilder() + .setId(id.toString()) + .setName(name) + .build(); + } + + private static IotThreshold threshold(double maxVal, boolean enabled) { + return IotThreshold.builder() + .id(UUID.randomUUID()) + .maxVal(maxVal) + .enabled(enabled) + .metric("electricity_monthly_kwh") + .build(); + } + + private static ForecastScopeDto forecast(double usedSoFar) { + return new ForecastScopeDto( + "electricity", "kWh", "house", + null, null, + usedSoFar, + 0, usedSoFar + 100, + 0, 0, + 10, "stable", 30, + Collections.emptyList(), + 0L, "OK", null, "prophet_monthly" + ); + } +}