diff --git a/.gitignore b/.gitignore index e47c98ab..50063b07 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ app/.idea/ /composeApp/google-services.json /iosApp/iosApp/GoogleService-Info.plist /iosApp/Configuration/Config.xcconfig + +# Cloned reference repo +/chukchuk-haksa/ diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/portal/datasource/PortalRemoteDataSource.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/portal/datasource/PortalRemoteDataSource.kt new file mode 100644 index 00000000..a1d0747e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/portal/datasource/PortalRemoteDataSource.kt @@ -0,0 +1,18 @@ +package com.chukchukhaksa.mobile.data.portal.datasource + +import com.chukchukhaksa.mobile.remote.portal.model.AcceptedResponseDto +import com.chukchukhaksa.mobile.remote.portal.model.JobStatusResponseDto +import com.chukchukhaksa.mobile.remote.portal.model.JobSummaryResponseDto + +interface PortalRemoteDataSource { + suspend fun createLinkJob( + portalType: String, + username: String, + password: String, + idempotencyKey: String, + ): AcceptedResponseDto + + suspend fun getJobStatus(jobId: String): JobStatusResponseDto + + suspend fun getJobSummary(jobId: String): JobSummaryResponseDto +} diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/portal/di/PortalRepositoryModule.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/portal/di/PortalRepositoryModule.kt new file mode 100644 index 00000000..f5ef3ba2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/portal/di/PortalRepositoryModule.kt @@ -0,0 +1,12 @@ +package com.chukchukhaksa.mobile.data.portal.di + +import com.chukchukhaksa.mobile.data.portal.datasource.PortalRemoteDataSource +import com.chukchukhaksa.mobile.data.portal.repository.PortalRepositoryImpl +import com.chukchukhaksa.mobile.domain.portal.repository.PortalRepository +import com.chukchukhaksa.mobile.remote.portal.PortalRemoteDataSourceImpl +import org.koin.dsl.module + +val portalRepositoryModule = module { + single { PortalRemoteDataSourceImpl(get()) } + single { PortalRepositoryImpl(get()) } +} diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/portal/repository/PortalRepositoryImpl.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/portal/repository/PortalRepositoryImpl.kt new file mode 100644 index 00000000..f6709362 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/data/portal/repository/PortalRepositoryImpl.kt @@ -0,0 +1,124 @@ +package com.chukchukhaksa.mobile.data.portal.repository + +import com.chukchukhaksa.mobile.data.portal.datasource.PortalRemoteDataSource +import com.chukchukhaksa.mobile.domain.portal.model.PortalScrapingError +import com.chukchukhaksa.mobile.domain.portal.model.PortalScrapingException +import com.chukchukhaksa.mobile.domain.portal.model.ScrapingProgress +import com.chukchukhaksa.mobile.domain.portal.model.ScrapingResult +import com.chukchukhaksa.mobile.domain.portal.model.StudentInfo +import com.chukchukhaksa.mobile.domain.portal.repository.PortalRepository +import com.chukchukhaksa.mobile.remote.portal.mapToPortalScrapingError +import com.chukchukhaksa.mobile.remote.portal.model.JobStatusResponseDto +import com.chukchukhaksa.mobile.remote.portal.model.JobSummaryResponseDto +import com.chukchukhaksa.mobile.remote.portal.model.StudentInfoDto +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +class PortalRepositoryImpl( + private val portalRemoteDataSource: PortalRemoteDataSource, +) : PortalRepository { + + override fun linkPortal( + portalType: String, + username: String, + password: String, + idempotencyKey: String, + ): Flow = flow { + val accepted = portalRemoteDataSource.createLinkJob( + portalType = portalType, + username = username, + password = password, + idempotencyKey = idempotencyKey, + ) + val jobId = accepted.jobId ?: throw PortalScrapingException( + error = PortalScrapingError.Unknown(httpStatus = null, appCode = null), + httpStatus = null, + appCode = null, + retryable = null, + message = "포털 연동 job id가 비어있습니다.", + ) + + emit(ScrapingProgress.Accepted(jobId = jobId)) + + repeat(MAX_POLL_COUNT) { + delay(POLL_INTERVAL_MS) + + val statusDto = portalRemoteDataSource.getJobStatus(jobId) + val jobStatus = statusDto.status.orEmpty() + + when { + jobStatus.isTerminalSuccess() -> { + val summary = portalRemoteDataSource.getJobSummary(jobId) + emit( + ScrapingProgress.Completed( + jobId = jobId, + result = summary.toDomain(), + ), + ) + return@flow + } + + jobStatus.isTerminalFailure() -> { + throw statusDto.toException() + } + + else -> emit( + ScrapingProgress.InProgress( + jobId = jobId, + status = jobStatus, + ), + ) + } + } + + throw PortalScrapingException( + error = PortalScrapingError.Unknown(httpStatus = null, appCode = TIMEOUT_APP_CODE), + httpStatus = null, + appCode = TIMEOUT_APP_CODE, + retryable = null, + message = "포털 연동이 시간 내에 완료되지 않았습니다.", + ) + } + + companion object { + private const val POLL_INTERVAL_MS = 2_000L + private const val MAX_POLL_COUNT = 60 + private const val TIMEOUT_APP_CODE = "PORTAL_POLL_TIMEOUT" + } +} + +private fun String.isTerminalSuccess(): Boolean = + equals("succeeded", ignoreCase = true) + +private fun String.isTerminalFailure(): Boolean = + equals("failed", ignoreCase = true) + +private fun JobStatusResponseDto.toException(): PortalScrapingException { + val appCode = errorCode + val error = mapToPortalScrapingError(httpStatus = null, appCode = appCode) + return PortalScrapingException( + error = error, + httpStatus = null, + appCode = appCode, + retryable = retryable, + message = errorMessage ?: error.defaultMessage, + ) +} + +private fun JobSummaryResponseDto.toDomain(): ScrapingResult = ScrapingResult( + jobId = jobId, + studentInfo = studentInfo?.toDomain(), + status = status, + finishedAt = finishedAt, +) + +private fun StudentInfoDto.toDomain(): StudentInfo = StudentInfo( + name = name, + school = school, + majorName = majorName, + studentCode = studentCode, + gradeLevel = gradeLevel, + status = status, + completedSemesterType = completedSemesterType, +) diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/DomainModules.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/DomainModules.kt index 75ee476d..1f803cad 100644 --- a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/DomainModules.kt +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/DomainModules.kt @@ -6,6 +6,7 @@ import com.chukchukhaksa.mobile.domain.auth.usecase.AppleLoginUseCase import com.chukchukhaksa.mobile.domain.auth.usecase.CheckAuthStateUseCase import com.chukchukhaksa.mobile.domain.auth.usecase.KakaoLoginUseCase import com.chukchukhaksa.mobile.domain.config.usecase.CheckNeedForceUpdateUseCase +import com.chukchukhaksa.mobile.domain.portal.usecase.LinkPortalUseCase import com.chukchukhaksa.mobile.domain.profile.usecase.GetProfileUseCase import com.chukchukhaksa.mobile.domain.timetable.usecase.DeleteTimetableCellUseCase import com.chukchukhaksa.mobile.domain.timetable.usecase.DeleteTimetableUseCase @@ -58,4 +59,7 @@ val domainModule = module { // Academic use cases factory { GetAcademicSummaryUseCase(get()) } factory { GetAcademicRecordUseCase(get()) } + + // Portal use cases + factory { LinkPortalUseCase(get()) } } diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/InitKoin.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/InitKoin.kt index dc9f485a..cd3de6b0 100644 --- a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/InitKoin.kt +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/di/InitKoin.kt @@ -5,6 +5,7 @@ import com.chukchukhaksa.mobile.data.auth.di.authRepositoryModule import com.chukchukhaksa.mobile.data.config.di.appConfigRepositoryModule import com.chukchukhaksa.mobile.data.openlecture.di.openLectureRepositoryModule import com.chukchukhaksa.mobile.data.openmajor.di.openMajorRepositoryModule +import com.chukchukhaksa.mobile.data.portal.di.portalRepositoryModule import com.chukchukhaksa.mobile.data.profile.di.ProfileRepositoryModule import com.chukchukhaksa.mobile.data.timetable.di.timetableRepositoryModule import com.chukchukhaksa.mobile.local.database.common.di.openLectureDatabaseModule @@ -48,6 +49,7 @@ fun initKoin(config: KoinAppDeclaration? = null) { authRepositoryModule, ProfileRepositoryModule, AcademicRepositoryModule, + portalRepositoryModule, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/PortalScrapingError.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/PortalScrapingError.kt new file mode 100644 index 00000000..a4f0e232 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/PortalScrapingError.kt @@ -0,0 +1,39 @@ +package com.chukchukhaksa.mobile.domain.portal.model + +sealed class PortalScrapingError(val defaultMessage: String) { + + data object InvalidCredentials : PortalScrapingError( + "아이디나 비밀번호가 일치하지 않습니다.\n학교 홈페이지에서 확인해주세요.", + ) + + data object AccountLocked : PortalScrapingError( + "계정이 잠겼습니다. 포털사이트에서 비밀번호 재설정을 진행해주세요.", + ) + + data object InvalidAcademicRecord : PortalScrapingError( + "입력하신 학적 정보로는 현재 처리가 불가능합니다.\n세부 사유를 확인해주세요.", + ) + + data object AlreadyConnected : PortalScrapingError( + "이미 포털 연동된 학생 정보가 존재합니다.\n다른 계정으로 로그인했는지 확인해주세요.", + ) + + data object GraduationDataNotFound : PortalScrapingError( + "사용자에게 맞는 졸업 요건 데이터가 존재하지 않습니다.\n학과/입학년도 정보를 확인해주세요.", + ) + + data object DoubleMajorInfoMissing : PortalScrapingError( + "복수전공 이수 구분 정보가 존재하지 않아 처리할 수 없습니다.\n학사정보를 확인해주세요.", + ) + + data object TransferStudentNotSupported : PortalScrapingError( + "편입생 학적 정보는 현재 지원되지 않습니다.\n추후 지원 예정입니다.", + ) + + data class Unknown( + val httpStatus: Int?, + val appCode: String?, + ) : PortalScrapingError( + "알 수 없는 오류가 발생했어요.\n잠시 후 다시 시도해주세요.", + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/PortalScrapingException.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/PortalScrapingException.kt new file mode 100644 index 00000000..86c76283 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/PortalScrapingException.kt @@ -0,0 +1,9 @@ +package com.chukchukhaksa.mobile.domain.portal.model + +class PortalScrapingException( + val error: PortalScrapingError, + val httpStatus: Int?, + val appCode: String?, + val retryable: Boolean? = null, + override val message: String = error.defaultMessage, +) : RuntimeException(message) diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/ScrapingProgress.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/ScrapingProgress.kt new file mode 100644 index 00000000..c3bf896e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/ScrapingProgress.kt @@ -0,0 +1,19 @@ +package com.chukchukhaksa.mobile.domain.portal.model + +sealed interface ScrapingProgress { + val jobId: String + + data class Accepted( + override val jobId: String, + ) : ScrapingProgress + + data class InProgress( + override val jobId: String, + val status: String, + ) : ScrapingProgress + + data class Completed( + override val jobId: String, + val result: ScrapingResult, + ) : ScrapingProgress +} diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/ScrapingResult.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/ScrapingResult.kt new file mode 100644 index 00000000..24d2bd4a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/model/ScrapingResult.kt @@ -0,0 +1,18 @@ +package com.chukchukhaksa.mobile.domain.portal.model + +data class ScrapingResult( + val jobId: String?, + val studentInfo: StudentInfo?, + val status: String?, + val finishedAt: String?, +) + +data class StudentInfo( + val name: String?, + val school: String?, + val majorName: String?, + val studentCode: String?, + val gradeLevel: Int?, + val status: String?, + val completedSemesterType: Int?, +) diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/repository/PortalRepository.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/repository/PortalRepository.kt new file mode 100644 index 00000000..58ac8ae1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/repository/PortalRepository.kt @@ -0,0 +1,13 @@ +package com.chukchukhaksa.mobile.domain.portal.repository + +import com.chukchukhaksa.mobile.domain.portal.model.ScrapingProgress +import kotlinx.coroutines.flow.Flow + +interface PortalRepository { + fun linkPortal( + portalType: String, + username: String, + password: String, + idempotencyKey: String, + ): Flow +} diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/usecase/LinkPortalUseCase.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/usecase/LinkPortalUseCase.kt new file mode 100644 index 00000000..42b6804d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/domain/portal/usecase/LinkPortalUseCase.kt @@ -0,0 +1,28 @@ +package com.chukchukhaksa.mobile.domain.portal.usecase + +import com.chukchukhaksa.mobile.domain.portal.model.ScrapingProgress +import com.chukchukhaksa.mobile.domain.portal.repository.PortalRepository +import kotlinx.coroutines.flow.Flow +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class LinkPortalUseCase( + private val portalRepository: PortalRepository, +) { + @OptIn(ExperimentalUuidApi::class) + operator fun invoke( + username: String, + password: String, + portalType: String = DEFAULT_PORTAL_TYPE, + idempotencyKey: String = Uuid.random().toString(), + ): Flow = portalRepository.linkPortal( + portalType = portalType, + username = username, + password = password, + idempotencyKey = idempotencyKey, + ) + + companion object { + const val DEFAULT_PORTAL_TYPE = "suwon" + } +} diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/PortalRemoteDataSourceImpl.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/PortalRemoteDataSourceImpl.kt new file mode 100644 index 00000000..e1a9ce8a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/PortalRemoteDataSourceImpl.kt @@ -0,0 +1,92 @@ +package com.chukchukhaksa.mobile.remote.portal + +import com.chukchukhaksa.mobile.data.portal.datasource.PortalRemoteDataSource +import com.chukchukhaksa.mobile.domain.portal.model.PortalScrapingException +import com.chukchukhaksa.mobile.remote.common.ApiResponse +import com.chukchukhaksa.mobile.remote.portal.model.AcceptedResponseDto +import com.chukchukhaksa.mobile.remote.portal.model.JobStatusResponseDto +import com.chukchukhaksa.mobile.remote.portal.model.JobSummaryResponseDto +import com.chukchukhaksa.mobile.remote.portal.model.LinkRequestDto +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.request.url +import io.ktor.client.statement.HttpResponse +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.path + +class PortalRemoteDataSourceImpl( + private val httpClient: HttpClient, +) : PortalRemoteDataSource { + + override suspend fun createLinkJob( + portalType: String, + username: String, + password: String, + idempotencyKey: String, + ): AcceptedResponseDto { + val httpResponse = httpClient.post { + url { path("portal", "link") } + header(IDEMPOTENCY_KEY_HEADER, idempotencyKey) + contentType(ContentType.Application.Json) + setBody( + LinkRequestDto( + portalType = portalType, + username = username, + password = password, + ), + ) + } + return parseOrThrow(httpResponse) + } + + override suspend fun getJobStatus(jobId: String): JobStatusResponseDto { + val httpResponse = httpClient.get { + url { path("portal", "link", "jobs", jobId) } + } + return parseOrThrow(httpResponse) + } + + override suspend fun getJobSummary(jobId: String): JobSummaryResponseDto { + val httpResponse = httpClient.get { + url { path("portal", "link", "jobs", jobId, "summary") } + } + return parseOrThrow(httpResponse) + } + + private suspend inline fun parseOrThrow(httpResponse: HttpResponse): T { + val status = httpResponse.status.value + val apiResponse = runCatching { + httpResponse.body>() + }.getOrElse { cause -> + throw PortalScrapingException( + error = mapToPortalScrapingError(httpStatus = status, appCode = null), + httpStatus = status, + appCode = null, + message = cause.message ?: "응답 파싱에 실패했습니다.", + ) + } + + val data = apiResponse.data + if (apiResponse.success && data != null) { + return data + } + + val appCode = apiResponse.error?.code + val error = mapToPortalScrapingError(httpStatus = status, appCode = appCode) + throw PortalScrapingException( + error = error, + httpStatus = status, + appCode = appCode, + message = apiResponse.error?.message ?: error.defaultMessage, + ) + } + + companion object { + private const val IDEMPOTENCY_KEY_HEADER = "Idempotency-Key" + } +} diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/PortalScrapingErrorMapper.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/PortalScrapingErrorMapper.kt new file mode 100644 index 00000000..5e567920 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/PortalScrapingErrorMapper.kt @@ -0,0 +1,19 @@ +package com.chukchukhaksa.mobile.remote.portal + +import com.chukchukhaksa.mobile.domain.portal.model.PortalScrapingError + +internal fun mapToPortalScrapingError( + httpStatus: Int?, + appCode: String?, +): PortalScrapingError = when { + appCode == "S04" -> PortalScrapingError.AlreadyConnected + appCode == "D01" -> PortalScrapingError.DoubleMajorInfoMissing + appCode == "T13" -> PortalScrapingError.TransferStudentNotSupported + appCode == "G02" -> PortalScrapingError.GraduationDataNotFound + httpStatus == 401 -> PortalScrapingError.InvalidCredentials + httpStatus == 404 -> PortalScrapingError.GraduationDataNotFound + httpStatus == 409 -> PortalScrapingError.AlreadyConnected + httpStatus == 422 -> PortalScrapingError.InvalidAcademicRecord + httpStatus == 423 -> PortalScrapingError.AccountLocked + else -> PortalScrapingError.Unknown(httpStatus = httpStatus, appCode = appCode) +} diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/model/AcceptedResponseDto.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/model/AcceptedResponseDto.kt new file mode 100644 index 00000000..018bdf36 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/model/AcceptedResponseDto.kt @@ -0,0 +1,11 @@ +package com.chukchukhaksa.mobile.remote.portal.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AcceptedResponseDto( + @SerialName("job_id") val jobId: String? = null, + @SerialName("polling_endpoint") val pollingEndpoint: String? = null, + val status: String? = null, +) diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/model/JobStatusResponseDto.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/model/JobStatusResponseDto.kt new file mode 100644 index 00000000..3d57a5a6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/model/JobStatusResponseDto.kt @@ -0,0 +1,17 @@ +package com.chukchukhaksa.mobile.remote.portal.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class JobStatusResponseDto( + @SerialName("job_id") val jobId: String? = null, + @SerialName("portal_type") val portalType: String? = null, + @SerialName("error_code") val errorCode: String? = null, + @SerialName("error_message") val errorMessage: String? = null, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("updated_at") val updatedAt: String? = null, + @SerialName("finished_at") val finishedAt: String? = null, + val status: String? = null, + val retryable: Boolean? = null, +) diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/model/JobSummaryResponseDto.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/model/JobSummaryResponseDto.kt new file mode 100644 index 00000000..fe23a5c3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/model/JobSummaryResponseDto.kt @@ -0,0 +1,23 @@ +package com.chukchukhaksa.mobile.remote.portal.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class JobSummaryResponseDto( + @SerialName("job_id") val jobId: String? = null, + val studentInfo: StudentInfoDto? = null, + @SerialName("finished_at") val finishedAt: String? = null, + val status: String? = null, +) + +@Serializable +data class StudentInfoDto( + val name: String? = null, + val school: String? = null, + val majorName: String? = null, + val studentCode: String? = null, + val gradeLevel: Int? = null, + val status: String? = null, + val completedSemesterType: Int? = null, +) diff --git a/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/model/LinkRequestDto.kt b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/model/LinkRequestDto.kt new file mode 100644 index 00000000..1e80128e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/chukchukhaksa/mobile/remote/portal/model/LinkRequestDto.kt @@ -0,0 +1,11 @@ +package com.chukchukhaksa.mobile.remote.portal.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class LinkRequestDto( + @SerialName("portal_type") val portalType: String, + val username: String, + val password: String, +)