diff --git a/back001/README.md b/back001/README.md index 5fe4829..bb3d593 100644 --- a/back001/README.md +++ b/back001/README.md @@ -6,25 +6,39 @@ Production-ready Kotlin/Spring Boot 3 modular monolith skeleton for patient-care - Java 21 - Docker + Docker Compose -## Local Dev +## Local run 1) Start dependencies: ```bash docker compose up -d ``` -2) Run the API (JWT resource server): +2) Run the API (local profile, local auth enabled): ```bash -./gradlew :app:bootRun -Dspring.profiles.active=local +SPRING_PROFILES_ACTIVE=local \ +ALLOW_LOCAL_AUTH=true \ +./gradlew :app:bootRun ``` -3) Run the worker: +```bash +SPRING_PROFILES_ACTIVE=dev \ +ALLOW_LOCAL_AUTH=false \ +./gradlew :app:bootRun +``` + +3) (Optional) Run the worker: ```bash ./gradlew :workers:notification-worker:bootRun ``` +Required env flags (local/dev): +- `SPRING_PROFILES_ACTIVE=local` +- `ALLOW_LOCAL_AUTH=true` (enables local auth headers) +- `KEYCLOAK_ISSUER_URI=http://localhost:8081/realms/mosenioring` (if using Keycloak) +- Frontend should set `USE_LOCAL_AUTH=true` when using local auth headers. + ## Auth - The backend is a JWT resource server and does not handle user passwords. - Local auth shortcut is available only when `SPRING_PROFILES_ACTIVE=local` diff --git a/back001/app/src/main/kotlin/com/mosenioring/app/config/SecurityConfig.kt b/back001/app/src/main/kotlin/com/mosenioring/app/config/SecurityConfig.kt index 0dca6e4..5fa8534 100644 --- a/back001/app/src/main/kotlin/com/mosenioring/app/config/SecurityConfig.kt +++ b/back001/app/src/main/kotlin/com/mosenioring/app/config/SecurityConfig.kt @@ -24,6 +24,7 @@ class SecurityConfig { .csrf { it.disable() } .authorizeHttpRequests { it.requestMatchers("/actuator/**", "/health", "/v3/api-docs/**", "/swagger-ui/**").permitAll() + it.requestMatchers("/api/v1/demo").permitAll() it.requestMatchers(HttpMethod.POST, "/api/v1/tenants").hasRole("ADMIN") it.anyRequest().authenticated() } diff --git a/back001/app/src/main/kotlin/com/mosenioring/app/demo/DemoDataIds.kt b/back001/app/src/main/kotlin/com/mosenioring/app/demo/DemoDataIds.kt new file mode 100644 index 0000000..33e63a2 --- /dev/null +++ b/back001/app/src/main/kotlin/com/mosenioring/app/demo/DemoDataIds.kt @@ -0,0 +1,22 @@ +package com.mosenioring.app.demo + +object DemoDataIds { + const val tenantId = "11111111-1111-1111-1111-111111111111" + const val patientId = "22222222-2222-2222-2222-222222222222" + + const val patientUserId = "33333333-3333-3333-3333-333333333333" + const val caregiverUserId = "44444444-4444-4444-4444-444444444444" + const val doctorUserId = "55555555-5555-5555-5555-555555555555" + + const val patientCaregiverId = "66666666-6666-6666-6666-666666666666" + const val patientDoctorId = "77777777-7777-7777-7777-777777777777" + + const val medicationPlanId = "88888888-8888-8888-8888-888888888888" + const val testOrderId = "99999999-9999-9999-9999-999999999999" + const val messageOneId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + const val messageTwoId = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + const val fileId = "cccccccc-cccc-cccc-cccc-cccccccccccc" + const val auditEventOneId = "dddddddd-dddd-dddd-dddd-dddddddddddd" + const val auditEventTwoId = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee" + const val auditEventThreeId = "ffffffff-ffff-ffff-ffff-ffffffffffff" +} diff --git a/back001/app/src/main/kotlin/com/mosenioring/app/demo/LocalDemoController.kt b/back001/app/src/main/kotlin/com/mosenioring/app/demo/LocalDemoController.kt new file mode 100644 index 0000000..00b8438 --- /dev/null +++ b/back001/app/src/main/kotlin/com/mosenioring/app/demo/LocalDemoController.kt @@ -0,0 +1,31 @@ +package com.mosenioring.app.demo + +import org.springframework.context.annotation.Profile +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +data class DemoIdsResponse( + val tenantId: String, + val patientId: String, + val userIds: Map +) + +@RestController +@Profile("local") +@RequestMapping("/api/v1/demo") +class LocalDemoController { + + @GetMapping + fun demo(): DemoIdsResponse { + return DemoIdsResponse( + tenantId = DemoDataIds.tenantId, + patientId = DemoDataIds.patientId, + userIds = mapOf( + "patient" to DemoDataIds.patientUserId, + "caregiver" to DemoDataIds.caregiverUserId, + "doctor" to DemoDataIds.doctorUserId + ) + ) + } +} diff --git a/back001/app/src/main/kotlin/com/mosenioring/app/demo/LocalDemoSeeder.kt b/back001/app/src/main/kotlin/com/mosenioring/app/demo/LocalDemoSeeder.kt new file mode 100644 index 0000000..b0154d5 --- /dev/null +++ b/back001/app/src/main/kotlin/com/mosenioring/app/demo/LocalDemoSeeder.kt @@ -0,0 +1,284 @@ +package com.mosenioring.app.demo + +import com.mosenioring.audit.AuditEvent +import com.mosenioring.audit.AuditRepository +import com.mosenioring.clinical.MedicationPlan +import com.mosenioring.clinical.TestOrder +import com.mosenioring.clinical.repo.MedicationPlanRepository +import com.mosenioring.clinical.repo.TestOrderRepository +import com.mosenioring.common.BaseEntity +import com.mosenioring.identity.Patient +import com.mosenioring.identity.PatientCaregiver +import com.mosenioring.identity.PatientDoctor +import com.mosenioring.identity.Tenant +import com.mosenioring.identity.User +import com.mosenioring.identity.repo.PatientCaregiverRepository +import com.mosenioring.identity.repo.PatientDoctorRepository +import com.mosenioring.identity.repo.PatientRepository +import com.mosenioring.identity.repo.TenantRepository +import com.mosenioring.identity.repo.UserRepository +import com.mosenioring.integrations.FileMetadata +import com.mosenioring.integrations.repo.FileRepository +import com.mosenioring.messaging.Message +import com.mosenioring.messaging.repo.MessageRepository +import org.slf4j.LoggerFactory +import org.springframework.boot.CommandLineRunner +import org.springframework.context.annotation.Profile +import org.springframework.stereotype.Component + +@Component +@Profile("local") +class LocalDemoSeeder( + private val tenantRepository: TenantRepository, + private val userRepository: UserRepository, + private val patientRepository: PatientRepository, + private val patientCaregiverRepository: PatientCaregiverRepository, + private val patientDoctorRepository: PatientDoctorRepository, + private val medicationPlanRepository: MedicationPlanRepository, + private val testOrderRepository: TestOrderRepository, + private val messageRepository: MessageRepository, + private val fileRepository: FileRepository, + private val auditRepository: AuditRepository +) : CommandLineRunner { + + private val logger = LoggerFactory.getLogger(LocalDemoSeeder::class.java) + + override fun run(args: Array) { + var created = 0 + + if (!tenantRepository.existsById(DemoDataIds.tenantId)) { + tenantRepository.save( + seedDefaults(Tenant(DemoDataIds.tenantId, "Local Demo Tenant")) + ) + created += 1 + } + + created += saveIfMissing( + DemoDataIds.patientUserId, + userRepository + ) { + seedDefaults( + User( + DemoDataIds.patientUserId, + "patient@local.test", + "PATIENT", + "ACTIVE" + ) + ) + } + + created += saveIfMissing( + DemoDataIds.caregiverUserId, + userRepository + ) { + seedDefaults( + User( + DemoDataIds.caregiverUserId, + "caregiver@local.test", + "CAREGIVER", + "ACTIVE" + ) + ) + } + + created += saveIfMissing( + DemoDataIds.doctorUserId, + userRepository + ) { + seedDefaults( + User( + DemoDataIds.doctorUserId, + "doctor@local.test", + "DOCTOR", + "ACTIVE" + ) + ) + } + + created += saveIfMissing( + DemoDataIds.patientId, + patientRepository + ) { + seedDefaults( + Patient( + DemoDataIds.patientId, + "Alex Patient" + ) + ) + } + + created += saveIfMissing( + DemoDataIds.patientCaregiverId, + patientCaregiverRepository + ) { + seedDefaults( + PatientCaregiver( + DemoDataIds.patientCaregiverId, + DemoDataIds.patientId, + DemoDataIds.caregiverUserId + ) + ) + } + + created += saveIfMissing( + DemoDataIds.patientDoctorId, + patientDoctorRepository + ) { + seedDefaults( + PatientDoctor( + DemoDataIds.patientDoctorId, + DemoDataIds.patientId, + DemoDataIds.doctorUserId + ) + ) + } + + created += saveIfMissing( + DemoDataIds.medicationPlanId, + medicationPlanRepository + ) { + seedDefaults( + MedicationPlan( + DemoDataIds.medicationPlanId, + DemoDataIds.patientId, + "Morning: 5mg Samplex, Evening: 10mg Calmex" + ) + ) + } + + created += saveIfMissing( + DemoDataIds.testOrderId, + testOrderRepository + ) { + seedDefaults( + TestOrder( + DemoDataIds.testOrderId, + DemoDataIds.patientId, + "Blood pressure check", + "ORDERED" + ) + ) + } + + created += saveIfMissing( + DemoDataIds.messageOneId, + messageRepository + ) { + seedDefaults( + Message( + DemoDataIds.messageOneId, + DemoDataIds.patientId, + DemoDataIds.caregiverUserId, + "Morning meds taken." + ) + ) + } + + created += saveIfMissing( + DemoDataIds.messageTwoId, + messageRepository + ) { + seedDefaults( + Message( + DemoDataIds.messageTwoId, + DemoDataIds.patientId, + DemoDataIds.doctorUserId, + "Thanks! Please record evening dose too." + ) + ) + } + + created += saveIfMissing( + DemoDataIds.fileId, + fileRepository + ) { + seedDefaults( + FileMetadata( + DemoDataIds.fileId, + DemoDataIds.patientId, + "care-plan.pdf", + "application/pdf", + "demo/care-plan.pdf" + ) + ) + } + + created += saveIfMissing( + DemoDataIds.auditEventOneId, + auditRepository + ) { + seedDefaults( + AuditEvent( + DemoDataIds.auditEventOneId, + DemoDataIds.caregiverUserId, + "MESSAGE_SENT", + "message", + DemoDataIds.messageOneId, + DemoDataIds.patientId + ) + ) + } + + created += saveIfMissing( + DemoDataIds.auditEventTwoId, + auditRepository + ) { + seedDefaults( + AuditEvent( + DemoDataIds.auditEventTwoId, + DemoDataIds.doctorUserId, + "MESSAGE_SENT", + "message", + DemoDataIds.messageTwoId, + DemoDataIds.patientId + ) + ) + } + + created += saveIfMissing( + DemoDataIds.auditEventThreeId, + auditRepository + ) { + seedDefaults( + AuditEvent( + DemoDataIds.auditEventThreeId, + DemoDataIds.doctorUserId, + "MEDICATION_PLAN_UPDATED", + "medication_plan", + DemoDataIds.medicationPlanId, + DemoDataIds.patientId + ) + ) + } + + logger.info( + "Local demo data ready (created {} records): tenantId={}, patientId={}, users={{patient={}, caregiver={}, doctor={}}}, emails={{patient@local.test, caregiver@local.test, doctor@local.test}}", + created, + DemoDataIds.tenantId, + DemoDataIds.patientId, + DemoDataIds.patientUserId, + DemoDataIds.caregiverUserId, + DemoDataIds.doctorUserId + ) + } + + private fun seedDefaults(entity: T): T { + entity.tenantId = DemoDataIds.tenantId + entity.createdBy = "seed" + entity.updatedBy = "seed" + return entity + } + + private fun saveIfMissing( + id: String, + repository: org.springframework.data.jpa.repository.JpaRepository, + build: () -> T + ): Int { + return if (repository.existsById(id)) { + 0 + } else { + repository.save(build()) + 1 + } + } +} diff --git a/back001/docker/keycloak/realm.json b/back001/docker/keycloak/realm.json index b656185..86a6b54 100644 --- a/back001/docker/keycloak/realm.json +++ b/back001/docker/keycloak/realm.json @@ -17,6 +17,15 @@ "directAccessGrantsEnabled": true, "standardFlowEnabled": true, "redirectUris": ["*"] + }, + { + "clientId": "mosenioring-mobile", + "enabled": true, + "publicClient": true, + "directAccessGrantsEnabled": false, + "standardFlowEnabled": true, + "redirectUris": ["com.mosenioring.app://oauth2redirect"], + "webOrigins": ["*"] } ], "users": [ diff --git a/front001/mosenioring/README.md b/front001/mosenioring/README.md index 6e3146b..98b576d 100644 --- a/front001/mosenioring/README.md +++ b/front001/mosenioring/README.md @@ -16,18 +16,19 @@ management, and a login flow ready to integrate with a Swagger/OpenAPI backend. Runtime configuration is provided via `--dart-define` values. Auth + environment flags: -- `API_BASE_URL` (default: `http://localhost:8080`) +- `API_BASE_URL` (required) - `USE_LOCAL_AUTH` (`true`/`false`, default: `false`) -- `LOCAL_TENANT_ID` (default: `local-tenant`) +- `LOCAL_TENANT_ID` (default: `11111111-1111-1111-1111-111111111111`) - `LOCAL_ROLES` (default: `CAREGIVER`) -- `KEYCLOAK_ISSUER_URI` or `KEYCLOAK_ISSUER` (default: `http://localhost:8081/realms/mosenioring`) -- `KEYCLOAK_CLIENT_ID` (default: `mosenioring-mobile`) -- `KEYCLOAK_REDIRECT_URL` (default: `com.mosenioring.app://oauth2redirect`) +- `KEYCLOAK_ISSUER_URI` or `KEYCLOAK_ISSUER` (required when `USE_LOCAL_AUTH=false`) +- `KEYCLOAK_CLIENT_ID` (required when `USE_LOCAL_AUTH=false`) +- `KEYCLOAK_REDIRECT_URL` (required when `USE_LOCAL_AUTH=false`) Local dev behavior: - When `USE_LOCAL_AUTH=true`, the app keeps the email/password fields and sends `X-Local-Email`, `X-Local-Roles`, and `X-Tenant-Id` headers to the backend. - The backend must run with `SPRING_PROFILES_ACTIVE=local` and `ALLOW_LOCAL_AUTH=true`. +- Keycloak login via `flutter_appauth` is supported only on Android/iOS. Keycloak redirect URI to register: - `com.mosenioring.app://oauth2redirect` (Android + iOS) @@ -47,11 +48,24 @@ flutter pub get flutter run ``` -Quick start (Keycloak mode): +## Local run + ```sh flutter run \ - --dart-define=API_BASE_URL=http://localhost:8080 \ - --dart-define=KEYCLOAK_ISSUER_URI=http://localhost:8081/realms/mosenioring \ + --dart-define=API_BASE_URL=http://10.0.2.2:8080 \ + --dart-define=USE_LOCAL_AUTH=true +``` + +Android emulator note: +- Use `10.0.2.2` instead of `localhost` for `API_BASE_URL` and `KEYCLOAK_ISSUER_URI`. +- `http://` Keycloak issuers are allowed for local dev; the app enables insecure connections automatically for non-HTTPS issuers. +- iOS dev builds allow HTTP via App Transport Security; tighten this for production. + +Quick start (Keycloak mode): +```sh +flutter run -d emulator-5554 \ + --dart-define=API_BASE_URL=http://10.0.2.2:8080 \ + --dart-define=KEYCLOAK_ISSUER_URI=http://10.0.2.2:8081/realms/mosenioring \ --dart-define=KEYCLOAK_CLIENT_ID=mosenioring-mobile \ --dart-define=KEYCLOAK_REDIRECT_URL=com.mosenioring.app://oauth2redirect ``` @@ -61,9 +75,18 @@ Quick start (local auth, no Keycloak): export SPRING_PROFILES_ACTIVE=local export ALLOW_LOCAL_AUTH=true -flutter run \ - --dart-define=API_BASE_URL=http://localhost:8080 \ +flutter run -d \ + --dart-define=API_BASE_URL=http://10.0.2.2:8080 \ --dart-define=USE_LOCAL_AUTH=true \ - --dart-define=LOCAL_TENANT_ID=local-tenant \ + --dart-define=LOCAL_TENANT_ID=11111111-1111-1111-1111-111111111111 \ --dart-define=LOCAL_ROLES=CAREGIVER ``` + +Redirect configuration: +- Android: `android/app/src/main/AndroidManifest.xml` includes an intent-filter for `com.mosenioring.app://oauth2redirect`. +- iOS: `ios/Runner/Info.plist` registers `com.mosenioring.app` under `CFBundleURLTypes`. + +Launcher icons: +```sh +flutter pub run flutter_launcher_icons +``` diff --git a/front001/mosenioring/android/app/src/debug/AndroidManifest.xml b/front001/mosenioring/android/app/src/debug/AndroidManifest.xml index 399f698..c057a89 100644 --- a/front001/mosenioring/android/app/src/debug/AndroidManifest.xml +++ b/front001/mosenioring/android/app/src/debug/AndroidManifest.xml @@ -1,7 +1,13 @@ - + + + diff --git a/front001/mosenioring/android/app/src/main/AndroidManifest.xml b/front001/mosenioring/android/app/src/main/AndroidManifest.xml index df0c958..82e6a09 100644 --- a/front001/mosenioring/android/app/src/main/AndroidManifest.xml +++ b/front001/mosenioring/android/app/src/main/AndroidManifest.xml @@ -2,7 +2,9 @@ + android:icon="@mipmap/ic_launcher" + android:usesCleartextTraffic="true" + android:networkSecurityConfig="@xml/network_security_config"> + android:scheme="com.mosenioring.app" + android:host="oauth2redirect"/>