diff --git a/back001/.gitignore b/back001/.gitignore new file mode 100644 index 0000000..ca51a52 --- /dev/null +++ b/back001/.gitignore @@ -0,0 +1,28 @@ +# Gradle +.gradle/ +build/ +**/build/ + +# IntelliJ IDEA +.idea/ +*.iml +*.ipr +*.iws +out/ + +# Kotlin +*.class + +# Logs +*.log + +# OS +.DS_Store +Thumbs.db + +# Env/local +.env +.env.* + +# Wrapper cache +.gradle-wrapper/ diff --git a/back001/README.md b/back001/README.md new file mode 100644 index 0000000..b5d45e9 --- /dev/null +++ b/back001/README.md @@ -0,0 +1,46 @@ +# Mosenioring Backend + +Production-ready Kotlin/Spring Boot 3 modular monolith skeleton for patient-caregiver-doctor coordination. + +## Requirements +- Java 21 +- Docker + Docker Compose + +## Local Dev +1) Start dependencies: + +```bash +docker compose up -d +``` + +2) Run the API: + +```bash +./gradlew :app:bootRun -Dspring.profiles.active=local +``` + +3) Run the worker: + +```bash +./gradlew :workers:notification-worker:bootRun +``` + +## Auth (local profile) +For local development, add headers: +- `X-Local-User`: user id +- `X-Local-Tenant`: tenant id +- `X-Local-Roles`: comma-separated roles (ADMIN, DOCTOR, CAREGIVER) + +## OpenAPI +- http://localhost:8080/swagger-ui/index.html + +## Key services +- Postgres: localhost:5432 (mosenioring/mosenioring) +- Keycloak: http://localhost:8081 (admin/admin) +- RabbitMQ: http://localhost:15672 (guest/guest) +- MinIO: http://localhost:9001 (minio/minio123) + +## Notes +- Tenant ID is enforced via `TenantFilter` using JWT claim `tenant_id`, or `X-Tenant-Id` header (local). +- Medication plan creation publishes a `MedicationPlanCreated` outbox event. +- Worker consumes and emits `NotificationRequested` events with idempotency via Redis. diff --git a/back001/app/build.gradle.kts b/back001/app/build.gradle.kts new file mode 100644 index 0000000..87ed9c3 --- /dev/null +++ b/back001/app/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + kotlin("jvm") + kotlin("plugin.spring") + kotlin("plugin.jpa") + id("org.springframework.boot") + id("io.spring.dependency-management") +} + +dependencies { + implementation(project(":common")) + implementation(project(":modules:identity")) + implementation(project(":modules:clinical")) + implementation(project(":modules:messaging")) + implementation(project(":modules:notifications")) + implementation(project(":modules:audit")) + implementation(project(":modules:integrations")) + + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-amqp") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server") + implementation("org.springframework.boot:spring-boot-starter-data-redis") + implementation("org.flywaydb:flyway-core") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0") + implementation("io.micrometer:micrometer-registry-prometheus") + implementation("io.micrometer:micrometer-tracing-bridge-otel") + implementation("io.opentelemetry:opentelemetry-exporter-otlp:1.38.0") + implementation("software.amazon.awssdk:s3:2.25.63") + + runtimeOnly("org.postgresql:postgresql") + + testImplementation("org.springframework.boot:spring-boot-starter-test") +} diff --git a/back001/app/src/main/kotlin/com/mosenioring/app/Application.kt b/back001/app/src/main/kotlin/com/mosenioring/app/Application.kt new file mode 100644 index 0000000..11b62d2 --- /dev/null +++ b/back001/app/src/main/kotlin/com/mosenioring/app/Application.kt @@ -0,0 +1,17 @@ +package com.mosenioring.app + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication +import org.springframework.data.jpa.repository.config.EnableJpaRepositories +import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.scheduling.annotation.EnableScheduling + +@SpringBootApplication(scanBasePackages = ["com.mosenioring"]) +@EntityScan("com.mosenioring") +@EnableJpaRepositories("com.mosenioring") +@EnableScheduling +class Application + +fun main(args: Array) { + runApplication(*args) +} diff --git a/back001/app/src/main/kotlin/com/mosenioring/app/config/MessagingConfig.kt b/back001/app/src/main/kotlin/com/mosenioring/app/config/MessagingConfig.kt new file mode 100644 index 0000000..ff559c7 --- /dev/null +++ b/back001/app/src/main/kotlin/com/mosenioring/app/config/MessagingConfig.kt @@ -0,0 +1,33 @@ +package com.mosenioring.app.config + +import com.mosenioring.common.Events +import org.springframework.amqp.core.Binding +import org.springframework.amqp.core.BindingBuilder +import org.springframework.amqp.core.DirectExchange +import org.springframework.amqp.core.Queue +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class MessagingConfig { + + @Bean + fun medicationExchange(): DirectExchange = DirectExchange(Events.MEDICATION_EXCHANGE) + + @Bean + fun notificationExchange(): DirectExchange = DirectExchange(Events.NOTIFICATION_EXCHANGE) + + @Bean + fun medicationQueue(): Queue = Queue(Events.MEDICATION_QUEUE, true) + + @Bean + fun notificationQueue(): Queue = Queue(Events.NOTIFICATION_QUEUE, true) + + @Bean + fun medicationBinding(medicationExchange: DirectExchange, medicationQueue: Queue): Binding = + BindingBuilder.bind(medicationQueue).to(medicationExchange).with(Events.MEDICATION_PLAN_CREATED) + + @Bean + fun notificationBinding(notificationExchange: DirectExchange, notificationQueue: Queue): Binding = + BindingBuilder.bind(notificationQueue).to(notificationExchange).with(Events.NOTIFICATION_REQUESTED) +} 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 new file mode 100644 index 0000000..f1584be --- /dev/null +++ b/back001/app/src/main/kotlin/com/mosenioring/app/config/SecurityConfig.kt @@ -0,0 +1,34 @@ +package com.mosenioring.app.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpMethod +import com.mosenioring.common.security.LocalAuthFilter +import org.springframework.security.config.Customizer.withDefaults +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter +import org.springframework.beans.factory.ObjectProvider + +@Configuration +@EnableMethodSecurity +class SecurityConfig { + + @Bean + fun filterChain(http: HttpSecurity, localAuthFilterProvider: ObjectProvider): SecurityFilterChain { + http + .csrf { it.disable() } + .authorizeHttpRequests { + it.requestMatchers("/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll() + it.requestMatchers(HttpMethod.POST, "/api/v1/tenants").hasRole("ADMIN") + it.anyRequest().authenticated() + } + .oauth2ResourceServer { it.jwt(withDefaults()) } + val localAuthFilter = localAuthFilterProvider.ifAvailable + if (localAuthFilter != null) { + http.addFilterBefore(localAuthFilter, BearerTokenAuthenticationFilter::class.java) + } + return http.build() + } +} diff --git a/back001/app/src/main/kotlin/com/mosenioring/app/outbox/OutboxPublisher.kt b/back001/app/src/main/kotlin/com/mosenioring/app/outbox/OutboxPublisher.kt new file mode 100644 index 0000000..3f9ae2c --- /dev/null +++ b/back001/app/src/main/kotlin/com/mosenioring/app/outbox/OutboxPublisher.kt @@ -0,0 +1,43 @@ +package com.mosenioring.app.outbox + +import com.mosenioring.common.Events +import com.mosenioring.common.outbox.OutboxRepository +import org.slf4j.LoggerFactory +import org.springframework.amqp.rabbit.core.RabbitTemplate +import org.springframework.beans.factory.annotation.Value +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class OutboxPublisher( + private val repository: OutboxRepository, + private val rabbitTemplate: RabbitTemplate, + @Value("\${app.outbox.publish-delay-ms:2000}") private val publishDelayMs: Long +) { + private val logger = LoggerFactory.getLogger(javaClass) + + @Scheduled(fixedDelayString = "\${app.outbox.publish-delay-ms:2000}") + @Transactional + fun publishPending() { + val pending = repository.findTop50ByStatusOrderByCreatedAtAsc("PENDING") + if (pending.isEmpty()) { + return + } + pending.forEach { event -> + val exchange = when (event.eventType) { + Events.MEDICATION_PLAN_CREATED -> Events.MEDICATION_EXCHANGE + Events.NOTIFICATION_REQUESTED -> Events.NOTIFICATION_EXCHANGE + else -> null + } + if (exchange == null) { + logger.warn("Unknown event type {}", event.eventType) + event.status = "FAILED" + return@forEach + } + rabbitTemplate.convertAndSend(exchange, event.eventType, event.payload) + event.status = "PUBLISHED" + } + repository.saveAll(pending) + } +} diff --git a/back001/app/src/main/resources/application.yml b/back001/app/src/main/resources/application.yml new file mode 100644 index 0000000..359f0cc --- /dev/null +++ b/back001/app/src/main/resources/application.yml @@ -0,0 +1,81 @@ +spring: + application: + name: mosenioring-backend + datasource: + url: jdbc:postgresql://localhost:5432/mosenioring + username: mosenioring + password: mosenioring + jpa: + open-in-view: false + hibernate: + ddl-auto: validate + properties: + hibernate: + jdbc: + time_zone: UTC + flyway: + enabled: true + rabbitmq: + host: localhost + port: 5672 + username: guest + password: guest + data: + redis: + host: localhost + port: 6379 + security: + oauth2: + resourceserver: + jwt: + issuer-uri: http://localhost:8081/realms/mosenioring + +storage: + s3: + endpoint: http://localhost:9000 + region: us-east-1 + access-key: minio + secret-key: minio123 + bucket: mosenioring + +app: + outbox: + publish-delay-ms: 2000 + tenant: + header: X-Tenant-Id + +management: + endpoints: + web: + exposure: + include: health,info,prometheus + tracing: + sampling: + probability: 1.0 + +logging: + level: + root: INFO + +--- +spring: + config: + activate: + on-profile: local + security: + oauth2: + resourceserver: + jwt: + issuer-uri: http://localhost:8081/realms/mosenioring + +app: + security: + local-auth-enabled: true + +--- +spring: + config: + activate: + on-profile: dev + datasource: + url: jdbc:postgresql://localhost:5432/mosenioring diff --git a/back001/app/src/main/resources/db/migration/V1__init.sql b/back001/app/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 0000000..87e1860 --- /dev/null +++ b/back001/app/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,141 @@ +create table tenants ( + id varchar(64) primary key, + name varchar(255) not null, + tenant_id varchar(64) not null, + created_at timestamptz not null, + updated_at timestamptz not null, + created_by varchar(128), + updated_by varchar(128) +); + +create table users ( + id varchar(64) primary key, + email varchar(255) not null, + role varchar(64) not null, + status varchar(32) not null, + tenant_id varchar(64) not null, + created_at timestamptz not null, + updated_at timestamptz not null, + created_by varchar(128), + updated_by varchar(128) +); + +create table patients ( + id varchar(64) primary key, + full_name varchar(255) not null, + tenant_id varchar(64) not null, + created_at timestamptz not null, + updated_at timestamptz not null, + created_by varchar(128), + updated_by varchar(128) +); + +create table patient_caregivers ( + id varchar(64) primary key, + patient_id varchar(64) not null, + user_id varchar(64) not null, + tenant_id varchar(64) not null, + created_at timestamptz not null, + updated_at timestamptz not null, + created_by varchar(128), + updated_by varchar(128) +); + +create table patient_doctors ( + id varchar(64) primary key, + patient_id varchar(64) not null, + user_id varchar(64) not null, + tenant_id varchar(64) not null, + created_at timestamptz not null, + updated_at timestamptz not null, + created_by varchar(128), + updated_by varchar(128) +); + +create table medication_plans ( + id varchar(64) primary key, + patient_id varchar(64) not null, + description text not null, + tenant_id varchar(64) not null, + created_at timestamptz not null, + updated_at timestamptz not null, + created_by varchar(128), + updated_by varchar(128) +); + +create table tests ( + id varchar(64) primary key, + patient_id varchar(64) not null, + test_name varchar(255) not null, + status varchar(64) not null, + tenant_id varchar(64) not null, + created_at timestamptz not null, + updated_at timestamptz not null, + created_by varchar(128), + updated_by varchar(128) +); + +create table messages ( + id varchar(64) primary key, + patient_id varchar(64) not null, + sender_id varchar(64) not null, + body text not null, + tenant_id varchar(64) not null, + created_at timestamptz not null, + updated_at timestamptz not null, + created_by varchar(128), + updated_by varchar(128) +); + +create table files ( + id varchar(64) primary key, + patient_id varchar(64), + file_name varchar(255) not null, + content_type varchar(255) not null, + storage_key varchar(512) not null, + tenant_id varchar(64) not null, + created_at timestamptz not null, + updated_at timestamptz not null, + created_by varchar(128), + updated_by varchar(128) +); + +create table audit_events ( + id varchar(64) primary key, + actor_id varchar(128) not null, + action varchar(128) not null, + resource varchar(128) not null, + resource_id varchar(64), + patient_id varchar(64), + tenant_id varchar(64) not null, + created_at timestamptz not null, + updated_at timestamptz not null, + created_by varchar(128), + updated_by varchar(128) +); + +create table outbox_events ( + id varchar(64) primary key, + event_type varchar(128) not null, + payload text not null, + status varchar(32) not null, + tenant_id varchar(64) not null, + created_at timestamptz not null, + updated_at timestamptz not null, + created_by varchar(128), + updated_by varchar(128) +); + +create index idx_users_tenant on users(tenant_id); +create index idx_patients_tenant on patients(tenant_id); +create index idx_patient_caregivers_tenant on patient_caregivers(tenant_id); +create index idx_patient_doctors_tenant on patient_doctors(tenant_id); +create index idx_medication_plans_tenant on medication_plans(tenant_id); +create index idx_tests_tenant on tests(tenant_id); +create index idx_messages_tenant on messages(tenant_id); +create index idx_files_tenant on files(tenant_id); +create index idx_audit_events_tenant on audit_events(tenant_id); +create index idx_outbox_events_tenant on outbox_events(tenant_id); + +create unique index idx_patient_caregivers_unique on patient_caregivers(tenant_id, patient_id, user_id); +create unique index idx_patient_doctors_unique on patient_doctors(tenant_id, patient_id, user_id); diff --git a/back001/build.gradle.kts b/back001/build.gradle.kts new file mode 100644 index 0000000..06557c2 --- /dev/null +++ b/back001/build.gradle.kts @@ -0,0 +1,55 @@ +import io.spring.gradle.dependencymanagement.dsl.DependencyManagementExtension +import org.gradle.api.plugins.JavaPluginExtension +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + kotlin("jvm") version "1.9.23" apply false + kotlin("plugin.spring") version "1.9.23" apply false + kotlin("plugin.jpa") version "1.9.23" apply false + id("org.springframework.boot") version "3.2.5" apply false + id("io.spring.dependency-management") version "1.1.4" apply false +} + +allprojects { + group = "com.mosenioring" + version = "0.1.0" + + repositories { + mavenCentral() + } +} + +subprojects { + plugins.withId("io.spring.dependency-management") { + the().apply { + imports { + mavenBom("org.springframework.boot:spring-boot-dependencies:3.2.5") + } + } + } + + tasks.withType { + kotlinOptions { + jvmTarget = "21" + freeCompilerArgs = listOf("-Xjsr305=strict") + } + } + + plugins.withId("java") { + extensions.configure { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } + } + } + + plugins.withId("org.jetbrains.kotlin.jvm") { + extensions.configure { + jvmToolchain(21) + } + } + + tasks.withType { + useJUnitPlatform() + } +} diff --git a/back001/common/build.gradle.kts b/back001/common/build.gradle.kts new file mode 100644 index 0000000..942b203 --- /dev/null +++ b/back001/common/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + kotlin("jvm") + kotlin("plugin.spring") + kotlin("plugin.jpa") + id("io.spring.dependency-management") + id("java-library") +} + +dependencies { + api("org.springframework.boot:spring-boot-starter-web") + api("org.springframework.boot:spring-boot-starter-security") + api("org.springframework.boot:spring-boot-starter-data-jpa") + api("org.springframework.boot:spring-boot-starter-validation") + api("org.springframework.boot:spring-boot-starter-actuator") + api("org.springframework.security:spring-security-oauth2-resource-server") + api("org.springframework.security:spring-security-oauth2-jose") + api("com.fasterxml.jackson.module:jackson-module-kotlin") + api("org.jetbrains.kotlin:kotlin-reflect") + api("io.opentelemetry:opentelemetry-api:1.38.0") + api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.mockito:mockito-core:5.12.0") +} diff --git a/back001/common/src/main/kotlin/com/mosenioring/common/BaseEntity.kt b/back001/common/src/main/kotlin/com/mosenioring/common/BaseEntity.kt new file mode 100644 index 0000000..03cd468 --- /dev/null +++ b/back001/common/src/main/kotlin/com/mosenioring/common/BaseEntity.kt @@ -0,0 +1,38 @@ +package com.mosenioring.common + +import jakarta.persistence.Column +import jakarta.persistence.MappedSuperclass +import jakarta.persistence.PrePersist +import jakarta.persistence.PreUpdate +import java.time.Instant + +@MappedSuperclass +abstract class BaseEntity { + + @Column(name = "tenant_id", nullable = false, updatable = false) + lateinit var tenantId: String + + @Column(name = "created_at", nullable = false, updatable = false) + lateinit var createdAt: Instant + + @Column(name = "updated_at", nullable = false) + lateinit var updatedAt: Instant + + @Column(name = "created_by") + var createdBy: String? = null + + @Column(name = "updated_by") + var updatedBy: String? = null + + @PrePersist + fun onCreate() { + val now = Instant.now() + createdAt = now + updatedAt = now + } + + @PreUpdate + fun onUpdate() { + updatedAt = Instant.now() + } +} diff --git a/back001/common/src/main/kotlin/com/mosenioring/common/Events.kt b/back001/common/src/main/kotlin/com/mosenioring/common/Events.kt new file mode 100644 index 0000000..1f67f87 --- /dev/null +++ b/back001/common/src/main/kotlin/com/mosenioring/common/Events.kt @@ -0,0 +1,10 @@ +package com.mosenioring.common + +object Events { + const val MEDICATION_PLAN_CREATED = "MedicationPlanCreated" + const val NOTIFICATION_REQUESTED = "NotificationRequested" + const val MEDICATION_EXCHANGE = "medication.events" + const val NOTIFICATION_EXCHANGE = "notification.events" + const val MEDICATION_QUEUE = "medication.plan.created" + const val NOTIFICATION_QUEUE = "notification.requested" +} diff --git a/back001/common/src/main/kotlin/com/mosenioring/common/outbox/OutboxEvent.kt b/back001/common/src/main/kotlin/com/mosenioring/common/outbox/OutboxEvent.kt new file mode 100644 index 0000000..ec9113e --- /dev/null +++ b/back001/common/src/main/kotlin/com/mosenioring/common/outbox/OutboxEvent.kt @@ -0,0 +1,24 @@ +package com.mosenioring.common.outbox + +import com.mosenioring.common.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table + +@Entity +@Table(name = "outbox_events") +class OutboxEvent( + @Id + @Column(name = "id") + val id: String, + + @Column(name = "event_type", nullable = false) + val eventType: String, + + @Column(name = "payload", nullable = false, columnDefinition = "text") + val payload: String, + + @Column(name = "status", nullable = false) + var status: String = "PENDING" +) : BaseEntity() diff --git a/back001/common/src/main/kotlin/com/mosenioring/common/outbox/OutboxRepository.kt b/back001/common/src/main/kotlin/com/mosenioring/common/outbox/OutboxRepository.kt new file mode 100644 index 0000000..798550c --- /dev/null +++ b/back001/common/src/main/kotlin/com/mosenioring/common/outbox/OutboxRepository.kt @@ -0,0 +1,7 @@ +package com.mosenioring.common.outbox + +import org.springframework.data.jpa.repository.JpaRepository + +interface OutboxRepository : JpaRepository { + fun findTop50ByStatusOrderByCreatedAtAsc(status: String): List +} diff --git a/back001/common/src/main/kotlin/com/mosenioring/common/outbox/OutboxService.kt b/back001/common/src/main/kotlin/com/mosenioring/common/outbox/OutboxService.kt new file mode 100644 index 0000000..140a726 --- /dev/null +++ b/back001/common/src/main/kotlin/com/mosenioring/common/outbox/OutboxService.kt @@ -0,0 +1,28 @@ +package com.mosenioring.common.outbox + +import com.fasterxml.jackson.databind.ObjectMapper +import com.mosenioring.common.security.SecurityUtils +import com.mosenioring.common.tenant.TenantContext +import org.springframework.context.annotation.Profile +import org.springframework.stereotype.Service +import java.util.UUID + +@Service +@Profile("!worker") +class OutboxService( + private val repository: OutboxRepository, + private val objectMapper: ObjectMapper +) { + fun enqueue(eventType: String, payload: Any): OutboxEvent { + val tenantId = TenantContext.getTenantId() ?: "unknown" + val event = OutboxEvent( + id = UUID.randomUUID().toString(), + eventType = eventType, + payload = objectMapper.writeValueAsString(payload) + ) + event.tenantId = tenantId + event.createdBy = SecurityUtils.currentUserId() + event.updatedBy = SecurityUtils.currentUserId() + return repository.save(event) + } +} diff --git a/back001/common/src/main/kotlin/com/mosenioring/common/security/LocalAuthFilter.kt b/back001/common/src/main/kotlin/com/mosenioring/common/security/LocalAuthFilter.kt new file mode 100644 index 0000000..ecd04e2 --- /dev/null +++ b/back001/common/src/main/kotlin/com/mosenioring/common/security/LocalAuthFilter.kt @@ -0,0 +1,34 @@ +package com.mosenioring.common.security + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.context.annotation.Profile +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter + +@Component +@Profile("local") +class LocalAuthFilter : OncePerRequestFilter() { + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + val userId = request.getHeader("X-Local-User") + val tenantId = request.getHeader("X-Local-Tenant") + val rolesHeader = request.getHeader("X-Local-Roles") + if (!userId.isNullOrBlank() && !tenantId.isNullOrBlank()) { + val roles = rolesHeader?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() }?.toSet() ?: emptySet() + val authorities = roles.map { SimpleGrantedAuthority("ROLE_$it") } + val principal = LocalUserPrincipal(userId, tenantId, roles) + val auth = UsernamePasswordAuthenticationToken(principal, "N/A", authorities) + SecurityContextHolder.getContext().authentication = auth + } + filterChain.doFilter(request, response) + } +} diff --git a/back001/common/src/main/kotlin/com/mosenioring/common/security/PatientAccess.kt b/back001/common/src/main/kotlin/com/mosenioring/common/security/PatientAccess.kt new file mode 100644 index 0000000..7934feb --- /dev/null +++ b/back001/common/src/main/kotlin/com/mosenioring/common/security/PatientAccess.kt @@ -0,0 +1,8 @@ +package com.mosenioring.common.security + +import org.springframework.security.access.prepost.PreAuthorize + +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +@PreAuthorize("@patientAccessChecker.hasAccess(#patientId)") +annotation class PatientAccess diff --git a/back001/common/src/main/kotlin/com/mosenioring/common/security/PatientAccessChecker.kt b/back001/common/src/main/kotlin/com/mosenioring/common/security/PatientAccessChecker.kt new file mode 100644 index 0000000..7d82fc9 --- /dev/null +++ b/back001/common/src/main/kotlin/com/mosenioring/common/security/PatientAccessChecker.kt @@ -0,0 +1,26 @@ +package com.mosenioring.common.security + +import com.mosenioring.common.tenant.TenantContext +import org.springframework.context.annotation.Profile +import org.springframework.stereotype.Component + +interface PatientRelationshipRepository { + fun isCaregiverOf(tenantId: String, patientId: String, userId: String): Boolean + fun isDoctorOf(tenantId: String, patientId: String, userId: String): Boolean +} + +@Component +@Profile("!worker") +class PatientAccessChecker( + private val relationships: PatientRelationshipRepository +) { + fun hasAccess(patientId: String): Boolean { + val tenantId = TenantContext.getTenantId() ?: return false + if (SecurityUtils.hasRole("ADMIN")) { + return true + } + val userId = SecurityUtils.currentUserId() ?: return false + return relationships.isCaregiverOf(tenantId, patientId, userId) || + relationships.isDoctorOf(tenantId, patientId, userId) + } +} diff --git a/back001/common/src/main/kotlin/com/mosenioring/common/security/SecurityUtils.kt b/back001/common/src/main/kotlin/com/mosenioring/common/security/SecurityUtils.kt new file mode 100644 index 0000000..b30dfb9 --- /dev/null +++ b/back001/common/src/main/kotlin/com/mosenioring/common/security/SecurityUtils.kt @@ -0,0 +1,35 @@ +package com.mosenioring.common.security + +import org.springframework.security.core.Authentication +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken + +object SecurityUtils { + fun currentUserId(): String? { + val authentication = SecurityContextHolder.getContext().authentication ?: return null + return when (authentication) { + is JwtAuthenticationToken -> authentication.token.subject + else -> (authentication.principal as? LocalUserPrincipal)?.userId + } + } + + fun currentTenantId(): String? { + val authentication = SecurityContextHolder.getContext().authentication ?: return null + return when (authentication) { + is JwtAuthenticationToken -> authentication.token.getClaimAsString("tenant_id") + else -> (authentication.principal as? LocalUserPrincipal)?.tenantId + } + } + + fun hasRole(role: String, authentication: Authentication? = null): Boolean { + val auth = authentication ?: SecurityContextHolder.getContext().authentication ?: return false + return auth.authorities.any { it.authority == "ROLE_$role" } + } +} + +data class LocalUserPrincipal( + val userId: String, + val tenantId: String, + val roles: Set +) diff --git a/back001/common/src/main/kotlin/com/mosenioring/common/tenant/TenantContext.kt b/back001/common/src/main/kotlin/com/mosenioring/common/tenant/TenantContext.kt new file mode 100644 index 0000000..9f89817 --- /dev/null +++ b/back001/common/src/main/kotlin/com/mosenioring/common/tenant/TenantContext.kt @@ -0,0 +1,38 @@ +package com.mosenioring.common.tenant + +import kotlinx.coroutines.ThreadContextElement +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext + +object TenantContext { + private val current = ThreadLocal() + + fun getTenantId(): String? = current.get() + + fun setTenantId(tenantId: String?) { + current.set(tenantId) + } + + fun clear() { + current.remove() + } + + fun asContextElement(tenantId: String?): CoroutineContext = TenantContextElement(tenantId) +} + +class TenantContextElement( + private val tenantId: String? +) : ThreadContextElement, + AbstractCoroutineContextElement(Key) { + companion object Key : CoroutineContext.Key + + override fun updateThreadContext(context: CoroutineContext): String? { + val previous = TenantContext.getTenantId() + TenantContext.setTenantId(tenantId) + return previous + } + + override fun restoreThreadContext(context: CoroutineContext, oldState: String?) { + TenantContext.setTenantId(oldState) + } +} diff --git a/back001/common/src/main/kotlin/com/mosenioring/common/tenant/TenantFilter.kt b/back001/common/src/main/kotlin/com/mosenioring/common/tenant/TenantFilter.kt new file mode 100644 index 0000000..ad50452 --- /dev/null +++ b/back001/common/src/main/kotlin/com/mosenioring/common/tenant/TenantFilter.kt @@ -0,0 +1,30 @@ +package com.mosenioring.common.tenant + +import com.mosenioring.common.security.SecurityUtils +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter + +@Component +class TenantFilter( + @Value("\${app.tenant.header:X-Tenant-Id}") private val tenantHeader: String +) : OncePerRequestFilter() { + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + val tenantId = SecurityUtils.currentTenantId() + ?: request.getHeader(tenantHeader) + TenantContext.setTenantId(tenantId) + try { + filterChain.doFilter(request, response) + } finally { + TenantContext.clear() + } + } +} diff --git a/back001/common/src/main/kotlin/com/mosenioring/common/web/PageResponse.kt b/back001/common/src/main/kotlin/com/mosenioring/common/web/PageResponse.kt new file mode 100644 index 0000000..04f4255 --- /dev/null +++ b/back001/common/src/main/kotlin/com/mosenioring/common/web/PageResponse.kt @@ -0,0 +1,8 @@ +package com.mosenioring.common.web + +data class PageResponse( + val items: List, + val page: Int, + val size: Int, + val total: Long +) diff --git a/back001/common/src/main/kotlin/com/mosenioring/common/web/ProblemDetailsAdvice.kt b/back001/common/src/main/kotlin/com/mosenioring/common/web/ProblemDetailsAdvice.kt new file mode 100644 index 0000000..df2193f --- /dev/null +++ b/back001/common/src/main/kotlin/com/mosenioring/common/web/ProblemDetailsAdvice.kt @@ -0,0 +1,54 @@ +package com.mosenioring.common.web + +import jakarta.servlet.http.HttpServletRequest +import org.springframework.http.HttpStatus +import org.springframework.http.ProblemDetail +import org.springframework.validation.FieldError +import org.springframework.web.ErrorResponseException +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice + +@RestControllerAdvice +class ProblemDetailsAdvice { + + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleValidation(ex: MethodArgumentNotValidException, request: HttpServletRequest): ProblemDetail { + val detail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST) + detail.title = "Validation failed" + detail.type = java.net.URI.create("https://httpstatuses.io/400") + detail.setProperty("path", request.requestURI) + detail.setProperty("errors", ex.bindingResult.fieldErrors.map { it.toErrorMap() }) + return detail + } + + @ExceptionHandler(ErrorResponseException::class) + fun handleErrorResponse(ex: ErrorResponseException, request: HttpServletRequest): ProblemDetail { + val detail = ex.body + detail.setProperty("path", request.requestURI) + return detail + } + + @ExceptionHandler(IllegalArgumentException::class) + fun handleIllegalArgument(ex: IllegalArgumentException, request: HttpServletRequest): ProblemDetail { + val detail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST) + detail.title = "Invalid request" + detail.detail = ex.message + detail.type = java.net.URI.create("https://httpstatuses.io/400") + detail.setProperty("path", request.requestURI) + return detail + } + + @ExceptionHandler(IllegalStateException::class) + fun handleIllegalState(ex: IllegalStateException, request: HttpServletRequest): ProblemDetail { + val detail = ProblemDetail.forStatus(HttpStatus.CONFLICT) + detail.title = "Conflict" + detail.detail = ex.message + detail.type = java.net.URI.create("https://httpstatuses.io/409") + detail.setProperty("path", request.requestURI) + return detail + } + + private fun FieldError.toErrorMap(): Map = + mapOf("field" to field, "message" to (defaultMessage ?: "invalid")) +} diff --git a/back001/common/src/test/kotlin/com/mosenioring/common/PatientAccessCheckerTest.kt b/back001/common/src/test/kotlin/com/mosenioring/common/PatientAccessCheckerTest.kt new file mode 100644 index 0000000..c09882a --- /dev/null +++ b/back001/common/src/test/kotlin/com/mosenioring/common/PatientAccessCheckerTest.kt @@ -0,0 +1,54 @@ +package com.mosenioring.common + +import com.mosenioring.common.security.LocalUserPrincipal +import com.mosenioring.common.security.PatientAccessChecker +import com.mosenioring.common.security.PatientRelationshipRepository +import com.mosenioring.common.tenant.TenantContext +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder + +class PatientAccessCheckerTest { + + private lateinit var relationships: PatientRelationshipRepository + private lateinit var checker: PatientAccessChecker + + @BeforeEach + fun setup() { + relationships = Mockito.mock(PatientRelationshipRepository::class.java) + checker = PatientAccessChecker(relationships) + TenantContext.setTenantId("t1") + } + + @Test + fun `allows admin`() { + val auth = UsernamePasswordAuthenticationToken("admin", "n/a", listOf(SimpleGrantedAuthority("ROLE_ADMIN"))) + SecurityContextHolder.getContext().authentication = auth + assertTrue(checker.hasAccess("p1")) + } + + @Test + fun `denies when no relationship`() { + val principal = LocalUserPrincipal("user1", "t1", emptySet()) + val auth = UsernamePasswordAuthenticationToken(principal, "n/a", emptyList()) + SecurityContextHolder.getContext().authentication = auth + Mockito.`when`(relationships.isCaregiverOf("t1", "p1", "user1")).thenReturn(false) + Mockito.`when`(relationships.isDoctorOf("t1", "p1", "user1")).thenReturn(false) + assertFalse(checker.hasAccess("p1")) + } + + @Test + fun `allows caregiver relationship`() { + val principal = LocalUserPrincipal("user2", "t1", setOf("CAREGIVER")) + val auth = UsernamePasswordAuthenticationToken(principal, "n/a", emptyList()) + SecurityContextHolder.getContext().authentication = auth + Mockito.`when`(relationships.isCaregiverOf("t1", "p1", "user2")).thenReturn(true) + Mockito.`when`(relationships.isDoctorOf("t1", "p1", "user2")).thenReturn(false) + assertTrue(checker.hasAccess("p1")) + } +} diff --git a/back001/common/src/test/kotlin/com/mosenioring/common/TenantContextTest.kt b/back001/common/src/test/kotlin/com/mosenioring/common/TenantContextTest.kt new file mode 100644 index 0000000..8c7488a --- /dev/null +++ b/back001/common/src/test/kotlin/com/mosenioring/common/TenantContextTest.kt @@ -0,0 +1,29 @@ +package com.mosenioring.common + +import com.mosenioring.common.tenant.TenantContext +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class TenantContextTest { + + @Test + fun `sets and clears tenant context`() { + TenantContext.setTenantId("t1") + assertEquals("t1", TenantContext.getTenantId()) + TenantContext.clear() + assertNull(TenantContext.getTenantId()) + } + + @Test + fun `propagates tenant context to coroutine`() = runBlocking { + TenantContext.setTenantId("t2") + val result = withContext(TenantContext.asContextElement("t2")) { + TenantContext.getTenantId() + } + assertEquals("t2", result) + TenantContext.clear() + } +} diff --git a/back001/docker-compose.yml b/back001/docker-compose.yml new file mode 100644 index 0000000..ce6d3f7 --- /dev/null +++ b/back001/docker-compose.yml @@ -0,0 +1,61 @@ +version: "3.9" + +services: + postgres: + image: postgres:16 + environment: + POSTGRES_DB: mosenioring + POSTGRES_USER: mosenioring + POSTGRES_PASSWORD: mosenioring + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + + keycloak: + image: quay.io/keycloak/keycloak:22.0.5 + command: start-dev --import-realm + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + ports: + - "8081:8080" + volumes: + - ./docker/keycloak/realm.json:/opt/keycloak/data/import/realm.json:ro + + rabbitmq: + image: rabbitmq:3.12-management + ports: + - "5672:5672" + - "15672:15672" + + redis: + image: redis:7 + ports: + - "6379:6379" + + minio: + image: minio/minio:RELEASE.2024-04-06T05-26-02Z + environment: + MINIO_ROOT_USER: minio + MINIO_ROOT_PASSWORD: minio123 + command: server /data --console-address ":9001" + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio:/data + + minio-init: + image: minio/mc:latest + depends_on: + - minio + entrypoint: ["/bin/sh", "-c"] + command: > + "mc alias set local http://minio:9000 minio minio123 && + mc mb -p local/mosenioring && + mc anonymous set public local/mosenioring" + +volumes: + pgdata: + minio: diff --git a/back001/docker/keycloak/realm.json b/back001/docker/keycloak/realm.json new file mode 100644 index 0000000..b656185 --- /dev/null +++ b/back001/docker/keycloak/realm.json @@ -0,0 +1,31 @@ +{ + "realm": "mosenioring", + "enabled": true, + "displayName": "Mosenioring", + "roles": { + "realm": [ + { "name": "ADMIN" }, + { "name": "DOCTOR" }, + { "name": "CAREGIVER" } + ] + }, + "clients": [ + { + "clientId": "mosenioring-backend", + "enabled": true, + "publicClient": true, + "directAccessGrantsEnabled": true, + "standardFlowEnabled": true, + "redirectUris": ["*"] + } + ], + "users": [ + { + "username": "admin", + "enabled": true, + "email": "admin@example.com", + "credentials": [{ "type": "password", "value": "admin", "temporary": false }], + "realmRoles": ["ADMIN"] + } + ] +} diff --git a/back001/gradle.properties b/back001/gradle.properties new file mode 100644 index 0000000..4a42352 --- /dev/null +++ b/back001/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx1g -Dfile.encoding=UTF-8 +kotlin.code.style=official diff --git a/back001/gradle/wrapper/gradle-wrapper.jar b/back001/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e644113 Binary files /dev/null and b/back001/gradle/wrapper/gradle-wrapper.jar differ diff --git a/back001/gradle/wrapper/gradle-wrapper.properties b/back001/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b82aa23 --- /dev/null +++ b/back001/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/back001/gradlew b/back001/gradlew new file mode 100755 index 0000000..1aa94a4 --- /dev/null +++ b/back001/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/back001/gradlew.bat b/back001/gradlew.bat new file mode 100644 index 0000000..25da30d --- /dev/null +++ b/back001/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/back001/modules/audit/build.gradle.kts b/back001/modules/audit/build.gradle.kts new file mode 100644 index 0000000..6acd1d2 --- /dev/null +++ b/back001/modules/audit/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + kotlin("jvm") + kotlin("plugin.spring") + kotlin("plugin.jpa") + id("io.spring.dependency-management") + id("java-library") +} + +dependencies { + api(project(":common")) + implementation("org.springframework.boot:spring-boot-starter-data-jpa") +} diff --git a/back001/modules/audit/src/main/kotlin/com/mosenioring/audit/AuditEvent.kt b/back001/modules/audit/src/main/kotlin/com/mosenioring/audit/AuditEvent.kt new file mode 100644 index 0000000..a071758 --- /dev/null +++ b/back001/modules/audit/src/main/kotlin/com/mosenioring/audit/AuditEvent.kt @@ -0,0 +1,30 @@ +package com.mosenioring.audit + +import com.mosenioring.common.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table + +@Entity +@Table(name = "audit_events") +class AuditEvent( + @Id + @Column(name = "id") + val id: String, + + @Column(name = "actor_id", nullable = false) + val actorId: String, + + @Column(name = "action", nullable = false) + val action: String, + + @Column(name = "resource", nullable = false) + val resource: String, + + @Column(name = "resource_id") + val resourceId: String?, + + @Column(name = "patient_id") + val patientId: String? +) : BaseEntity() diff --git a/back001/modules/audit/src/main/kotlin/com/mosenioring/audit/AuditRepository.kt b/back001/modules/audit/src/main/kotlin/com/mosenioring/audit/AuditRepository.kt new file mode 100644 index 0000000..b5b89d3 --- /dev/null +++ b/back001/modules/audit/src/main/kotlin/com/mosenioring/audit/AuditRepository.kt @@ -0,0 +1,5 @@ +package com.mosenioring.audit + +import org.springframework.data.jpa.repository.JpaRepository + +interface AuditRepository : JpaRepository diff --git a/back001/modules/audit/src/main/kotlin/com/mosenioring/audit/service/AuditService.kt b/back001/modules/audit/src/main/kotlin/com/mosenioring/audit/service/AuditService.kt new file mode 100644 index 0000000..389e02d --- /dev/null +++ b/back001/modules/audit/src/main/kotlin/com/mosenioring/audit/service/AuditService.kt @@ -0,0 +1,28 @@ +package com.mosenioring.audit.service + +import com.mosenioring.audit.AuditEvent +import com.mosenioring.audit.AuditRepository +import com.mosenioring.common.security.SecurityUtils +import com.mosenioring.common.tenant.TenantContext +import org.springframework.stereotype.Service +import java.util.UUID + +@Service +class AuditService( + private val repository: AuditRepository +) { + fun record(action: String, resource: String, resourceId: String?, patientId: String?) { + val event = AuditEvent( + id = UUID.randomUUID().toString(), + actorId = SecurityUtils.currentUserId() ?: "system", + action = action, + resource = resource, + resourceId = resourceId, + patientId = patientId + ) + event.tenantId = TenantContext.getTenantId() ?: "unknown" + event.createdBy = event.actorId + event.updatedBy = event.actorId + repository.save(event) + } +} diff --git a/back001/modules/clinical/build.gradle.kts b/back001/modules/clinical/build.gradle.kts new file mode 100644 index 0000000..22ead93 --- /dev/null +++ b/back001/modules/clinical/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + kotlin("jvm") + kotlin("plugin.spring") + kotlin("plugin.jpa") + id("io.spring.dependency-management") + id("java-library") +} + +dependencies { + api(project(":common")) + implementation(project(":modules:audit")) + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-validation") +} diff --git a/back001/modules/clinical/src/main/kotlin/com/mosenioring/clinical/MedicationPlan.kt b/back001/modules/clinical/src/main/kotlin/com/mosenioring/clinical/MedicationPlan.kt new file mode 100644 index 0000000..962ad79 --- /dev/null +++ b/back001/modules/clinical/src/main/kotlin/com/mosenioring/clinical/MedicationPlan.kt @@ -0,0 +1,21 @@ +package com.mosenioring.clinical + +import com.mosenioring.common.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table + +@Entity +@Table(name = "medication_plans") +class MedicationPlan( + @Id + @Column(name = "id") + val id: String, + + @Column(name = "patient_id", nullable = false) + val patientId: String, + + @Column(name = "description", nullable = false) + val description: String +) : BaseEntity() diff --git a/back001/modules/clinical/src/main/kotlin/com/mosenioring/clinical/TestOrder.kt b/back001/modules/clinical/src/main/kotlin/com/mosenioring/clinical/TestOrder.kt new file mode 100644 index 0000000..97263c1 --- /dev/null +++ b/back001/modules/clinical/src/main/kotlin/com/mosenioring/clinical/TestOrder.kt @@ -0,0 +1,24 @@ +package com.mosenioring.clinical + +import com.mosenioring.common.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table + +@Entity +@Table(name = "tests") +class TestOrder( + @Id + @Column(name = "id") + val id: String, + + @Column(name = "patient_id", nullable = false) + val patientId: String, + + @Column(name = "test_name", nullable = false) + val testName: String, + + @Column(name = "status", nullable = false) + val status: String +) : BaseEntity() diff --git a/back001/modules/clinical/src/main/kotlin/com/mosenioring/clinical/api/ClinicalController.kt b/back001/modules/clinical/src/main/kotlin/com/mosenioring/clinical/api/ClinicalController.kt new file mode 100644 index 0000000..8691e44 --- /dev/null +++ b/back001/modules/clinical/src/main/kotlin/com/mosenioring/clinical/api/ClinicalController.kt @@ -0,0 +1,45 @@ +package com.mosenioring.clinical.api + +import com.mosenioring.clinical.service.MedicationPlanService +import com.mosenioring.clinical.service.TestOrderService +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/v1") +class ClinicalController( + private val medicationPlanService: MedicationPlanService, + private val testOrderService: TestOrderService +) { + @PostMapping("/patients/{id}/medications") + fun createMedicationPlan( + @PathVariable id: String, + @Valid @RequestBody request: CreateMedicationPlanRequest + ): ResponseEntity { + val plan = medicationPlanService.createMedicationPlan(id, request.description) + return ResponseEntity.ok(MedicationPlanResponse(plan.id, plan.patientId, plan.description)) + } + + @PostMapping("/patients/{id}/tests") + fun createTestOrder( + @PathVariable id: String, + @Valid @RequestBody request: CreateTestOrderRequest + ): ResponseEntity { + val order = testOrderService.createTestOrder(id, request.testName) + return ResponseEntity.ok(TestOrderResponse(order.id, order.patientId, order.testName, order.status)) + } +} + +data class CreateMedicationPlanRequest( + @field:NotBlank val description: String +) + +data class CreateTestOrderRequest( + @field:NotBlank val testName: String +) + +data class MedicationPlanResponse(val id: String, val patientId: String, val description: String) + +data class TestOrderResponse(val id: String, val patientId: String, val testName: String, val status: String) diff --git a/back001/modules/clinical/src/main/kotlin/com/mosenioring/clinical/repo/ClinicalRepositories.kt b/back001/modules/clinical/src/main/kotlin/com/mosenioring/clinical/repo/ClinicalRepositories.kt new file mode 100644 index 0000000..0e214c4 --- /dev/null +++ b/back001/modules/clinical/src/main/kotlin/com/mosenioring/clinical/repo/ClinicalRepositories.kt @@ -0,0 +1,13 @@ +package com.mosenioring.clinical.repo + +import com.mosenioring.clinical.MedicationPlan +import com.mosenioring.clinical.TestOrder +import org.springframework.data.jpa.repository.JpaRepository + +interface MedicationPlanRepository : JpaRepository { + fun findByIdAndTenantId(id: String, tenantId: String): MedicationPlan? +} + +interface TestOrderRepository : JpaRepository { + fun findByIdAndTenantId(id: String, tenantId: String): TestOrder? +} diff --git a/back001/modules/clinical/src/main/kotlin/com/mosenioring/clinical/service/ClinicalServices.kt b/back001/modules/clinical/src/main/kotlin/com/mosenioring/clinical/service/ClinicalServices.kt new file mode 100644 index 0000000..b405c14 --- /dev/null +++ b/back001/modules/clinical/src/main/kotlin/com/mosenioring/clinical/service/ClinicalServices.kt @@ -0,0 +1,64 @@ +package com.mosenioring.clinical.service + +import com.mosenioring.audit.service.AuditService +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.Events +import com.mosenioring.common.outbox.OutboxService +import com.mosenioring.common.security.PatientAccess +import com.mosenioring.common.security.SecurityUtils +import com.mosenioring.common.tenant.TenantContext +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@Service +class MedicationPlanService( + private val repository: MedicationPlanRepository, + private val outboxService: OutboxService, + private val auditService: AuditService +) { + @Transactional + @PatientAccess + fun createMedicationPlan(patientId: String, description: String): MedicationPlan { + val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant") + val plan = MedicationPlan(UUID.randomUUID().toString(), patientId, description) + plan.tenantId = tenantId + plan.createdBy = SecurityUtils.currentUserId() + plan.updatedBy = SecurityUtils.currentUserId() + val saved = repository.save(plan) + outboxService.enqueue( + Events.MEDICATION_PLAN_CREATED, + mapOf( + "eventId" to UUID.randomUUID().toString(), + "tenantId" to tenantId, + "patientId" to patientId, + "medicationPlanId" to saved.id + ) + ) + auditService.record("MEDICATION_PLAN_CREATED", "medication_plan", saved.id, patientId) + return saved + } +} + +@Service +class TestOrderService( + private val repository: TestOrderRepository, + private val auditService: AuditService +) { + @Transactional + @PatientAccess + fun createTestOrder(patientId: String, testName: String): TestOrder { + val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant") + val order = TestOrder(UUID.randomUUID().toString(), patientId, testName, "ORDERED") + order.tenantId = tenantId + order.createdBy = SecurityUtils.currentUserId() + order.updatedBy = SecurityUtils.currentUserId() + val saved = repository.save(order) + auditService.record("TEST_ORDER_CREATED", "test", saved.id, patientId) + return saved + } +} diff --git a/back001/modules/identity/build.gradle.kts b/back001/modules/identity/build.gradle.kts new file mode 100644 index 0000000..22ead93 --- /dev/null +++ b/back001/modules/identity/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + kotlin("jvm") + kotlin("plugin.spring") + kotlin("plugin.jpa") + id("io.spring.dependency-management") + id("java-library") +} + +dependencies { + api(project(":common")) + implementation(project(":modules:audit")) + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-validation") +} diff --git a/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/Patient.kt b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/Patient.kt new file mode 100644 index 0000000..451dee0 --- /dev/null +++ b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/Patient.kt @@ -0,0 +1,18 @@ +package com.mosenioring.identity + +import com.mosenioring.common.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table + +@Entity +@Table(name = "patients") +class Patient( + @Id + @Column(name = "id") + val id: String, + + @Column(name = "full_name", nullable = false) + val fullName: String +) : BaseEntity() diff --git a/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/PatientCaregiver.kt b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/PatientCaregiver.kt new file mode 100644 index 0000000..4a8c207 --- /dev/null +++ b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/PatientCaregiver.kt @@ -0,0 +1,21 @@ +package com.mosenioring.identity + +import com.mosenioring.common.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table + +@Entity +@Table(name = "patient_caregivers") +class PatientCaregiver( + @Id + @Column(name = "id") + val id: String, + + @Column(name = "patient_id", nullable = false) + val patientId: String, + + @Column(name = "user_id", nullable = false) + val userId: String +) : BaseEntity() diff --git a/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/PatientDoctor.kt b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/PatientDoctor.kt new file mode 100644 index 0000000..a916eae --- /dev/null +++ b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/PatientDoctor.kt @@ -0,0 +1,21 @@ +package com.mosenioring.identity + +import com.mosenioring.common.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table + +@Entity +@Table(name = "patient_doctors") +class PatientDoctor( + @Id + @Column(name = "id") + val id: String, + + @Column(name = "patient_id", nullable = false) + val patientId: String, + + @Column(name = "user_id", nullable = false) + val userId: String +) : BaseEntity() diff --git a/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/Tenant.kt b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/Tenant.kt new file mode 100644 index 0000000..a9a1c33 --- /dev/null +++ b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/Tenant.kt @@ -0,0 +1,18 @@ +package com.mosenioring.identity + +import com.mosenioring.common.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table + +@Entity +@Table(name = "tenants") +class Tenant( + @Id + @Column(name = "id") + val id: String, + + @Column(name = "name", nullable = false) + val name: String +) : BaseEntity() diff --git a/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/User.kt b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/User.kt new file mode 100644 index 0000000..6f19e19 --- /dev/null +++ b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/User.kt @@ -0,0 +1,24 @@ +package com.mosenioring.identity + +import com.mosenioring.common.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table + +@Entity +@Table(name = "users") +class User( + @Id + @Column(name = "id") + val id: String, + + @Column(name = "email", nullable = false) + val email: String, + + @Column(name = "role", nullable = false) + val role: String, + + @Column(name = "status", nullable = false) + val status: String +) : BaseEntity() diff --git a/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/api/IdentityController.kt b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/api/IdentityController.kt new file mode 100644 index 0000000..994a342 --- /dev/null +++ b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/api/IdentityController.kt @@ -0,0 +1,70 @@ +package com.mosenioring.identity.api + +import com.mosenioring.identity.service.PatientService +import com.mosenioring.identity.service.TenantService +import com.mosenioring.identity.service.UserService +import jakarta.validation.Valid +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/v1") +class IdentityController( + private val tenantService: TenantService, + private val userService: UserService, + private val patientService: PatientService +) { + + @PostMapping("/tenants") + fun createTenant(@Valid @RequestBody request: CreateTenantRequest): ResponseEntity { + val tenant = tenantService.createTenant(request.name) + return ResponseEntity.ok(TenantResponse(tenant.id, tenant.name)) + } + + @PostMapping("/users/invite") + fun inviteUser(@Valid @RequestBody request: InviteUserRequest): ResponseEntity { + val user = userService.inviteUser(request.email, request.role) + return ResponseEntity.ok(UserResponse(user.id, user.email, user.role, user.status)) + } + + @PostMapping("/patients") + fun createPatient(@Valid @RequestBody request: CreatePatientRequest): ResponseEntity { + val patient = patientService.createPatient(request.fullName) + return ResponseEntity.ok(PatientResponse(patient.id, patient.fullName)) + } + + @PostMapping("/patients/{id}/caregivers/{userId}") + fun linkCaregiver(@PathVariable id: String, @PathVariable userId: String): ResponseEntity { + val link = patientService.linkCaregiver(id, userId) + return ResponseEntity.ok(LinkResponse(link.id, link.patientId, link.userId)) + } + + @PostMapping("/patients/{id}/doctors/{userId}") + fun linkDoctor(@PathVariable id: String, @PathVariable userId: String): ResponseEntity { + val link = patientService.linkDoctor(id, userId) + return ResponseEntity.ok(LinkResponse(link.id, link.patientId, link.userId)) + } +} + +data class CreateTenantRequest( + @field:NotBlank val name: String +) + +data class InviteUserRequest( + @field:Email val email: String, + @field:NotBlank val role: String +) + +data class CreatePatientRequest( + @field:NotBlank val fullName: String +) + +data class TenantResponse(val id: String, val name: String) + +data class UserResponse(val id: String, val email: String, val role: String, val status: String) + +data class PatientResponse(val id: String, val fullName: String) + +data class LinkResponse(val id: String, val patientId: String, val userId: String) diff --git a/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/repo/IdentityRepositories.kt b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/repo/IdentityRepositories.kt new file mode 100644 index 0000000..75441a6 --- /dev/null +++ b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/repo/IdentityRepositories.kt @@ -0,0 +1,22 @@ +package com.mosenioring.identity.repo + +import com.mosenioring.identity.* +import org.springframework.data.jpa.repository.JpaRepository + +interface TenantRepository : JpaRepository + +interface UserRepository : JpaRepository { + fun findByIdAndTenantId(id: String, tenantId: String): User? +} + +interface PatientRepository : JpaRepository { + fun findByIdAndTenantId(id: String, tenantId: String): Patient? +} + +interface PatientCaregiverRepository : JpaRepository { + fun existsByTenantIdAndPatientIdAndUserId(tenantId: String, patientId: String, userId: String): Boolean +} + +interface PatientDoctorRepository : JpaRepository { + fun existsByTenantIdAndPatientIdAndUserId(tenantId: String, patientId: String, userId: String): Boolean +} diff --git a/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/repo/PatientRelationshipRepositoryImpl.kt b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/repo/PatientRelationshipRepositoryImpl.kt new file mode 100644 index 0000000..0b8db89 --- /dev/null +++ b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/repo/PatientRelationshipRepositoryImpl.kt @@ -0,0 +1,16 @@ +package com.mosenioring.identity.repo + +import com.mosenioring.common.security.PatientRelationshipRepository +import org.springframework.stereotype.Repository + +@Repository +class PatientRelationshipRepositoryImpl( + private val caregiverRepository: PatientCaregiverRepository, + private val doctorRepository: PatientDoctorRepository +) : PatientRelationshipRepository { + override fun isCaregiverOf(tenantId: String, patientId: String, userId: String): Boolean = + caregiverRepository.existsByTenantIdAndPatientIdAndUserId(tenantId, patientId, userId) + + override fun isDoctorOf(tenantId: String, patientId: String, userId: String): Boolean = + doctorRepository.existsByTenantIdAndPatientIdAndUserId(tenantId, patientId, userId) +} diff --git a/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/service/IdentityServices.kt b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/service/IdentityServices.kt new file mode 100644 index 0000000..b970eac --- /dev/null +++ b/back001/modules/identity/src/main/kotlin/com/mosenioring/identity/service/IdentityServices.kt @@ -0,0 +1,97 @@ +package com.mosenioring.identity.service + +import com.mosenioring.audit.service.AuditService +import com.mosenioring.common.security.SecurityUtils +import com.mosenioring.common.tenant.TenantContext +import com.mosenioring.identity.* +import com.mosenioring.identity.repo.* +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@Service +class TenantService( + private val tenantRepository: TenantRepository, + private val auditService: AuditService +) { + @Transactional + @PreAuthorize("hasRole('ADMIN')") + fun createTenant(name: String): Tenant { + val tenant = Tenant(UUID.randomUUID().toString(), name) + tenant.tenantId = tenant.id + tenant.createdBy = SecurityUtils.currentUserId() + tenant.updatedBy = SecurityUtils.currentUserId() + val saved = tenantRepository.save(tenant) + auditService.record("TENANT_CREATED", "tenant", saved.id, null) + return saved + } +} + +@Service +class UserService( + private val userRepository: UserRepository, + private val auditService: AuditService +) { + @Transactional + @PreAuthorize("hasRole('ADMIN')") + fun inviteUser(email: String, role: String): User { + val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant") + val user = User(UUID.randomUUID().toString(), email, role, "INVITED") + user.tenantId = tenantId + user.createdBy = SecurityUtils.currentUserId() + user.updatedBy = SecurityUtils.currentUserId() + val saved = userRepository.save(user) + auditService.record("USER_INVITED", "user", saved.id, null) + return saved + } +} + +@Service +class PatientService( + private val patientRepository: PatientRepository, + private val caregiverRepository: PatientCaregiverRepository, + private val doctorRepository: PatientDoctorRepository, + private val auditService: AuditService +) { + @Transactional + @PreAuthorize("hasAnyRole('ADMIN','DOCTOR','CAREGIVER')") + fun createPatient(fullName: String): Patient { + val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant") + val patient = Patient(UUID.randomUUID().toString(), fullName) + patient.tenantId = tenantId + patient.createdBy = SecurityUtils.currentUserId() + patient.updatedBy = SecurityUtils.currentUserId() + val saved = patientRepository.save(patient) + auditService.record("PATIENT_CREATED", "patient", saved.id, saved.id) + return saved + } + + @Transactional + @PreAuthorize("hasRole('ADMIN')") + fun linkCaregiver(patientId: String, userId: String): PatientCaregiver { + val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant") + patientRepository.findByIdAndTenantId(patientId, tenantId) ?: throw IllegalArgumentException("Patient not found") + val link = PatientCaregiver(UUID.randomUUID().toString(), patientId, userId) + link.tenantId = tenantId + link.createdBy = SecurityUtils.currentUserId() + link.updatedBy = SecurityUtils.currentUserId() + val saved = caregiverRepository.save(link) + auditService.record("CARE_GIVER_LINKED", "patient_caregiver", saved.id, patientId) + return saved + } + + @Transactional + @PreAuthorize("hasRole('ADMIN')") + fun linkDoctor(patientId: String, userId: String): PatientDoctor { + val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant") + patientRepository.findByIdAndTenantId(patientId, tenantId) ?: throw IllegalArgumentException("Patient not found") + val link = PatientDoctor(UUID.randomUUID().toString(), patientId, userId) + link.tenantId = tenantId + link.createdBy = SecurityUtils.currentUserId() + link.updatedBy = SecurityUtils.currentUserId() + val saved = doctorRepository.save(link) + auditService.record("DOCTOR_LINKED", "patient_doctor", saved.id, patientId) + return saved + } +} diff --git a/back001/modules/integrations/build.gradle.kts b/back001/modules/integrations/build.gradle.kts new file mode 100644 index 0000000..43c81ef --- /dev/null +++ b/back001/modules/integrations/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + kotlin("jvm") + kotlin("plugin.spring") + kotlin("plugin.jpa") + id("io.spring.dependency-management") + id("java-library") +} + +dependencies { + api(project(":common")) + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("software.amazon.awssdk:s3:2.25.63") +} diff --git a/back001/modules/integrations/src/main/kotlin/com/mosenioring/integrations/FileMetadata.kt b/back001/modules/integrations/src/main/kotlin/com/mosenioring/integrations/FileMetadata.kt new file mode 100644 index 0000000..8bf3d78 --- /dev/null +++ b/back001/modules/integrations/src/main/kotlin/com/mosenioring/integrations/FileMetadata.kt @@ -0,0 +1,27 @@ +package com.mosenioring.integrations + +import com.mosenioring.common.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table + +@Entity +@Table(name = "files") +class FileMetadata( + @Id + @Column(name = "id") + val id: String, + + @Column(name = "patient_id") + val patientId: String?, + + @Column(name = "file_name", nullable = false) + val fileName: String, + + @Column(name = "content_type", nullable = false) + val contentType: String, + + @Column(name = "storage_key", nullable = false) + val storageKey: String +) : BaseEntity() diff --git a/back001/modules/integrations/src/main/kotlin/com/mosenioring/integrations/api/FilesController.kt b/back001/modules/integrations/src/main/kotlin/com/mosenioring/integrations/api/FilesController.kt new file mode 100644 index 0000000..4792d95 --- /dev/null +++ b/back001/modules/integrations/src/main/kotlin/com/mosenioring/integrations/api/FilesController.kt @@ -0,0 +1,32 @@ +package com.mosenioring.integrations.api + +import com.mosenioring.integrations.service.FileService +import com.mosenioring.integrations.service.PresignResponse +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/v1") +class FilesController( + private val fileService: FileService +) { + @PostMapping("/files/presign-upload") + fun presignUpload(@Valid @RequestBody request: PresignUploadRequest): ResponseEntity { + val response = fileService.presignUpload(request.fileName, request.contentType, request.patientId) + return ResponseEntity.ok(response) + } + + @GetMapping("/files/{fileId}/presign-download") + fun presignDownload(@PathVariable fileId: String): ResponseEntity { + val response = fileService.presignDownload(fileId) + return ResponseEntity.ok(response) + } +} + +data class PresignUploadRequest( + @field:NotBlank val fileName: String, + @field:NotBlank val contentType: String, + val patientId: String? +) diff --git a/back001/modules/integrations/src/main/kotlin/com/mosenioring/integrations/repo/FileRepository.kt b/back001/modules/integrations/src/main/kotlin/com/mosenioring/integrations/repo/FileRepository.kt new file mode 100644 index 0000000..0940f99 --- /dev/null +++ b/back001/modules/integrations/src/main/kotlin/com/mosenioring/integrations/repo/FileRepository.kt @@ -0,0 +1,8 @@ +package com.mosenioring.integrations.repo + +import com.mosenioring.integrations.FileMetadata +import org.springframework.data.jpa.repository.JpaRepository + +interface FileRepository : JpaRepository { + fun findByIdAndTenantId(id: String, tenantId: String): FileMetadata? +} diff --git a/back001/modules/integrations/src/main/kotlin/com/mosenioring/integrations/service/FhirGateway.kt b/back001/modules/integrations/src/main/kotlin/com/mosenioring/integrations/service/FhirGateway.kt new file mode 100644 index 0000000..c1d97cb --- /dev/null +++ b/back001/modules/integrations/src/main/kotlin/com/mosenioring/integrations/service/FhirGateway.kt @@ -0,0 +1,10 @@ +package com.mosenioring.integrations.service + +import org.springframework.stereotype.Service + +@Service +class FhirGateway { + fun submitObservation(payload: String): String { + return "stub-${payload.hashCode()}" + } +} diff --git a/back001/modules/integrations/src/main/kotlin/com/mosenioring/integrations/service/FileService.kt b/back001/modules/integrations/src/main/kotlin/com/mosenioring/integrations/service/FileService.kt new file mode 100644 index 0000000..daf5138 --- /dev/null +++ b/back001/modules/integrations/src/main/kotlin/com/mosenioring/integrations/service/FileService.kt @@ -0,0 +1,90 @@ +package com.mosenioring.integrations.service + +import com.mosenioring.common.security.SecurityUtils +import com.mosenioring.common.tenant.TenantContext +import com.mosenioring.integrations.FileMetadata +import com.mosenioring.integrations.repo.FileRepository +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +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.s3.S3Configuration +import software.amazon.awssdk.services.s3.presigner.S3Presigner +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest +import software.amazon.awssdk.services.s3.model.GetObjectRequest +import software.amazon.awssdk.services.s3.model.PutObjectRequest +import java.net.URI +import java.time.Duration +import java.util.UUID + +@Service +class FileService( + private val repository: FileRepository, + @Value("\${storage.s3.endpoint}") private val endpoint: String, + @Value("\${storage.s3.region}") private val region: String, + @Value("\${storage.s3.access-key}") private val accessKey: String, + @Value("\${storage.s3.secret-key}") private val secretKey: String, + @Value("\${storage.s3.bucket}") private val bucket: String +) { + private fun presigner(): S3Presigner { + val creds = AwsBasicCredentials.create(accessKey, secretKey) + return S3Presigner.builder() + .endpointOverride(URI.create(endpoint)) + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(creds)) + .serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build()) + .build() + } + + fun presignUpload(fileName: String, contentType: String, patientId: String?): PresignResponse { + val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant") + val fileId = UUID.randomUUID().toString() + val storageKey = "$tenantId/$fileId/$fileName" + val metadata = FileMetadata(fileId, patientId, fileName, contentType, storageKey) + metadata.tenantId = tenantId + metadata.createdBy = SecurityUtils.currentUserId() + metadata.updatedBy = SecurityUtils.currentUserId() + repository.save(metadata) + + val presigner = presigner() + try { + val request = PutObjectRequest.builder() + .bucket(bucket) + .key(storageKey) + .contentType(contentType) + .build() + val presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(15)) + .putObjectRequest(request) + .build() + val presigned = presigner.presignPutObject(presignRequest) + return PresignResponse(fileId, presigned.url().toString()) + } finally { + presigner.close() + } + } + + fun presignDownload(fileId: String): PresignResponse { + val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant") + val metadata = repository.findByIdAndTenantId(fileId, tenantId) ?: throw IllegalArgumentException("File not found") + val presigner = presigner() + try { + val request = GetObjectRequest.builder() + .bucket(bucket) + .key(metadata.storageKey) + .build() + val presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(15)) + .getObjectRequest(request) + .build() + val presigned = presigner.presignGetObject(presignRequest) + return PresignResponse(metadata.id, presigned.url().toString()) + } finally { + presigner.close() + } + } +} + +data class PresignResponse(val fileId: String, val url: String) diff --git a/back001/modules/messaging/build.gradle.kts b/back001/modules/messaging/build.gradle.kts new file mode 100644 index 0000000..22ead93 --- /dev/null +++ b/back001/modules/messaging/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + kotlin("jvm") + kotlin("plugin.spring") + kotlin("plugin.jpa") + id("io.spring.dependency-management") + id("java-library") +} + +dependencies { + api(project(":common")) + implementation(project(":modules:audit")) + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-validation") +} diff --git a/back001/modules/messaging/src/main/kotlin/com/mosenioring/messaging/Message.kt b/back001/modules/messaging/src/main/kotlin/com/mosenioring/messaging/Message.kt new file mode 100644 index 0000000..a603fe9 --- /dev/null +++ b/back001/modules/messaging/src/main/kotlin/com/mosenioring/messaging/Message.kt @@ -0,0 +1,24 @@ +package com.mosenioring.messaging + +import com.mosenioring.common.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table + +@Entity +@Table(name = "messages") +class Message( + @Id + @Column(name = "id") + val id: String, + + @Column(name = "patient_id", nullable = false) + val patientId: String, + + @Column(name = "sender_id", nullable = false) + val senderId: String, + + @Column(name = "body", nullable = false) + val body: String +) : BaseEntity() diff --git a/back001/modules/messaging/src/main/kotlin/com/mosenioring/messaging/api/MessagingController.kt b/back001/modules/messaging/src/main/kotlin/com/mosenioring/messaging/api/MessagingController.kt new file mode 100644 index 0000000..66e2e6d --- /dev/null +++ b/back001/modules/messaging/src/main/kotlin/com/mosenioring/messaging/api/MessagingController.kt @@ -0,0 +1,28 @@ +package com.mosenioring.messaging.api + +import com.mosenioring.messaging.service.MessageService +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/v1") +class MessagingController( + private val messageService: MessageService +) { + @PostMapping("/patients/{id}/messages") + fun addMessage( + @PathVariable id: String, + @Valid @RequestBody request: AddMessageRequest + ): ResponseEntity { + val message = messageService.addMessage(id, request.body) + return ResponseEntity.ok(MessageResponse(message.id, message.patientId, message.senderId, message.body)) + } +} + +data class AddMessageRequest( + @field:NotBlank val body: String +) + +data class MessageResponse(val id: String, val patientId: String, val senderId: String, val body: String) diff --git a/back001/modules/messaging/src/main/kotlin/com/mosenioring/messaging/repo/MessageRepository.kt b/back001/modules/messaging/src/main/kotlin/com/mosenioring/messaging/repo/MessageRepository.kt new file mode 100644 index 0000000..91a3d2c --- /dev/null +++ b/back001/modules/messaging/src/main/kotlin/com/mosenioring/messaging/repo/MessageRepository.kt @@ -0,0 +1,6 @@ +package com.mosenioring.messaging.repo + +import com.mosenioring.messaging.Message +import org.springframework.data.jpa.repository.JpaRepository + +interface MessageRepository : JpaRepository diff --git a/back001/modules/messaging/src/main/kotlin/com/mosenioring/messaging/service/MessageService.kt b/back001/modules/messaging/src/main/kotlin/com/mosenioring/messaging/service/MessageService.kt new file mode 100644 index 0000000..92bdb12 --- /dev/null +++ b/back001/modules/messaging/src/main/kotlin/com/mosenioring/messaging/service/MessageService.kt @@ -0,0 +1,31 @@ +package com.mosenioring.messaging.service + +import com.mosenioring.audit.service.AuditService +import com.mosenioring.common.security.PatientAccess +import com.mosenioring.common.security.SecurityUtils +import com.mosenioring.common.tenant.TenantContext +import com.mosenioring.messaging.Message +import com.mosenioring.messaging.repo.MessageRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@Service +class MessageService( + private val repository: MessageRepository, + private val auditService: AuditService +) { + @Transactional + @PatientAccess + fun addMessage(patientId: String, body: String): Message { + val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant") + val senderId = SecurityUtils.currentUserId() ?: throw IllegalStateException("Missing user") + val message = Message(UUID.randomUUID().toString(), patientId, senderId, body) + message.tenantId = tenantId + message.createdBy = senderId + message.updatedBy = senderId + val saved = repository.save(message) + auditService.record("MESSAGE_ADDED", "message", saved.id, patientId) + return saved + } +} diff --git a/back001/modules/notifications/build.gradle.kts b/back001/modules/notifications/build.gradle.kts new file mode 100644 index 0000000..688b5f1 --- /dev/null +++ b/back001/modules/notifications/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + kotlin("jvm") + kotlin("plugin.spring") + kotlin("plugin.jpa") + id("io.spring.dependency-management") + id("java-library") +} + +dependencies { + api(project(":common")) + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-amqp") +} diff --git a/back001/modules/notifications/src/main/kotlin/com/mosenioring/notifications/api/NotificationsController.kt b/back001/modules/notifications/src/main/kotlin/com/mosenioring/notifications/api/NotificationsController.kt new file mode 100644 index 0000000..e0a9694 --- /dev/null +++ b/back001/modules/notifications/src/main/kotlin/com/mosenioring/notifications/api/NotificationsController.kt @@ -0,0 +1,25 @@ +package com.mosenioring.notifications.api + +import com.mosenioring.notifications.service.NotificationService +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/v1") +class NotificationsController( + private val notificationService: NotificationService +) { + @PostMapping("/notifications/test") + fun enqueueTest(@Valid @RequestBody request: NotificationRequest): ResponseEntity { + val eventId = notificationService.enqueueTestNotification(request.message) + return ResponseEntity.ok(NotificationResponse(eventId)) + } +} + +data class NotificationRequest( + @field:NotBlank val message: String +) + +data class NotificationResponse(val eventId: String) diff --git a/back001/modules/notifications/src/main/kotlin/com/mosenioring/notifications/service/NotificationService.kt b/back001/modules/notifications/src/main/kotlin/com/mosenioring/notifications/service/NotificationService.kt new file mode 100644 index 0000000..ee1463a --- /dev/null +++ b/back001/modules/notifications/src/main/kotlin/com/mosenioring/notifications/service/NotificationService.kt @@ -0,0 +1,30 @@ +package com.mosenioring.notifications.service + +import com.mosenioring.common.Events +import com.mosenioring.common.outbox.OutboxService +import com.mosenioring.common.security.SecurityUtils +import com.mosenioring.common.tenant.TenantContext +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.UUID + +@Service +class NotificationService( + private val outboxService: OutboxService +) { + @Transactional + fun enqueueTestNotification(message: String): String { + val tenantId = TenantContext.getTenantId() ?: throw IllegalStateException("Missing tenant") + val eventId = UUID.randomUUID().toString() + outboxService.enqueue( + Events.NOTIFICATION_REQUESTED, + mapOf( + "eventId" to eventId, + "tenantId" to tenantId, + "message" to message, + "requestedBy" to (SecurityUtils.currentUserId() ?: "unknown") + ) + ) + return eventId + } +} diff --git a/back001/requests.http b/back001/requests.http new file mode 100644 index 0000000..5c2d6e6 --- /dev/null +++ b/back001/requests.http @@ -0,0 +1,55 @@ +### Local tenant setup (use local profile + headers) +POST http://localhost:8080/api/v1/tenants +X-Local-User: admin +X-Local-Tenant: tenant-demo +X-Local-Roles: ADMIN +Content-Type: application/json + +{ + "name": "Demo Tenant" +} + +### Invite user +POST http://localhost:8080/api/v1/users/invite +X-Local-User: admin +X-Local-Tenant: tenant-demo +X-Local-Roles: ADMIN +Content-Type: application/json + +{ + "email": "doc@example.com", + "role": "DOCTOR" +} + +### Create patient +POST http://localhost:8080/api/v1/patients +X-Local-User: admin +X-Local-Tenant: tenant-demo +X-Local-Roles: ADMIN +Content-Type: application/json + +{ + "fullName": "Jane Doe" +} + +### Create medication plan +POST http://localhost:8080/api/v1/patients/{patientId}/medications +X-Local-User: admin +X-Local-Tenant: tenant-demo +X-Local-Roles: ADMIN +Content-Type: application/json + +{ + "description": "Take 5mg daily" +} + +### Test notification +POST http://localhost:8080/api/v1/notifications/test +X-Local-User: admin +X-Local-Tenant: tenant-demo +X-Local-Roles: ADMIN +Content-Type: application/json + +{ + "message": "Test notification" +} diff --git a/back001/settings.gradle.kts b/back001/settings.gradle.kts new file mode 100644 index 0000000..d9a5e9c --- /dev/null +++ b/back001/settings.gradle.kts @@ -0,0 +1,13 @@ +rootProject.name = "mosenioring-backend" + +include( + ":app", + ":common", + ":modules:identity", + ":modules:clinical", + ":modules:messaging", + ":modules:notifications", + ":modules:audit", + ":modules:integrations", + ":workers:notification-worker" +) diff --git a/back001/workers/notification-worker/build.gradle.kts b/back001/workers/notification-worker/build.gradle.kts new file mode 100644 index 0000000..d893875 --- /dev/null +++ b/back001/workers/notification-worker/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + kotlin("jvm") + kotlin("plugin.spring") + id("org.springframework.boot") + id("io.spring.dependency-management") +} + +dependencies { + implementation(project(":common")) + implementation("org.springframework.boot:spring-boot-starter-amqp") + implementation("org.springframework.boot:spring-boot-starter-data-redis") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + + testImplementation("org.springframework.boot:spring-boot-starter-test") +} diff --git a/back001/workers/notification-worker/src/main/kotlin/com/mosenioring/worker/NotificationListener.kt b/back001/workers/notification-worker/src/main/kotlin/com/mosenioring/worker/NotificationListener.kt new file mode 100644 index 0000000..68fa89b --- /dev/null +++ b/back001/workers/notification-worker/src/main/kotlin/com/mosenioring/worker/NotificationListener.kt @@ -0,0 +1,41 @@ +package com.mosenioring.worker + +import com.fasterxml.jackson.databind.ObjectMapper +import com.mosenioring.common.Events +import org.slf4j.LoggerFactory +import org.springframework.amqp.rabbit.annotation.RabbitListener +import org.springframework.amqp.rabbit.core.RabbitTemplate +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.stereotype.Component +import java.time.Duration + +@Component +class NotificationListener( + private val redisTemplate: StringRedisTemplate, + private val rabbitTemplate: RabbitTemplate, + private val objectMapper: ObjectMapper, + @Value("\${app.idempotency.ttl-seconds:3600}") private val ttlSeconds: Long +) { + private val logger = LoggerFactory.getLogger(javaClass) + + @RabbitListener(queues = [Events.MEDICATION_QUEUE]) + fun onMedicationPlanCreated(payload: String) { + val node = objectMapper.readTree(payload) + val eventId = node["eventId"]?.asText() ?: return + val idempotencyKey = "medication:$eventId" + val acquired = redisTemplate.opsForValue().setIfAbsent(idempotencyKey, "1", Duration.ofSeconds(ttlSeconds)) + if (acquired != true) { + logger.info("Duplicate event ignored: {}", eventId) + return + } + val notificationPayload = mapOf( + "eventId" to eventId, + "tenantId" to node["tenantId"]?.asText(), + "patientId" to node["patientId"]?.asText(), + "message" to "Medication plan created" + ) + rabbitTemplate.convertAndSend(Events.NOTIFICATION_EXCHANGE, Events.NOTIFICATION_REQUESTED, objectMapper.writeValueAsString(notificationPayload)) + logger.info("Notification requested for medication plan event {}", eventId) + } +} diff --git a/back001/workers/notification-worker/src/main/kotlin/com/mosenioring/worker/NotificationWorkerApplication.kt b/back001/workers/notification-worker/src/main/kotlin/com/mosenioring/worker/NotificationWorkerApplication.kt new file mode 100644 index 0000000..581ff48 --- /dev/null +++ b/back001/workers/notification-worker/src/main/kotlin/com/mosenioring/worker/NotificationWorkerApplication.kt @@ -0,0 +1,11 @@ +package com.mosenioring.worker + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@SpringBootApplication(scanBasePackages = ["com.mosenioring"]) +class NotificationWorkerApplication + +fun main(args: Array) { + runApplication(*args) +} diff --git a/back001/workers/notification-worker/src/main/kotlin/com/mosenioring/worker/WorkerMessagingConfig.kt b/back001/workers/notification-worker/src/main/kotlin/com/mosenioring/worker/WorkerMessagingConfig.kt new file mode 100644 index 0000000..df789be --- /dev/null +++ b/back001/workers/notification-worker/src/main/kotlin/com/mosenioring/worker/WorkerMessagingConfig.kt @@ -0,0 +1,23 @@ +package com.mosenioring.worker + +import com.mosenioring.common.Events +import org.springframework.amqp.core.Binding +import org.springframework.amqp.core.BindingBuilder +import org.springframework.amqp.core.DirectExchange +import org.springframework.amqp.core.Queue +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class WorkerMessagingConfig { + + @Bean + fun medicationExchange(): DirectExchange = DirectExchange(Events.MEDICATION_EXCHANGE) + + @Bean + fun medicationQueue(): Queue = Queue(Events.MEDICATION_QUEUE, true) + + @Bean + fun medicationBinding(medicationExchange: DirectExchange, medicationQueue: Queue): Binding = + BindingBuilder.bind(medicationQueue).to(medicationExchange).with(Events.MEDICATION_PLAN_CREATED) +} diff --git a/back001/workers/notification-worker/src/main/resources/application.yml b/back001/workers/notification-worker/src/main/resources/application.yml new file mode 100644 index 0000000..0f788a4 --- /dev/null +++ b/back001/workers/notification-worker/src/main/resources/application.yml @@ -0,0 +1,30 @@ +spring: + application: + name: notification-worker + profiles: + active: worker + main: + web-application-type: none + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration + - org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration + rabbitmq: + host: localhost + port: 5672 + username: guest + password: guest + data: + redis: + host: localhost + port: 6379 + +app: + idempotency: + ttl-seconds: 3600 + +management: + endpoints: + web: + exposure: + include: health,info,prometheus