diff --git a/src/main/kotlin/com/ctrlhub/core/datacapture/FormSubmissionVersionsRouter.kt b/src/main/kotlin/com/ctrlhub/core/datacapture/FormSubmissionVersionsRouter.kt index f916938..37e9797 100644 --- a/src/main/kotlin/com/ctrlhub/core/datacapture/FormSubmissionVersionsRouter.kt +++ b/src/main/kotlin/com/ctrlhub/core/datacapture/FormSubmissionVersionsRouter.kt @@ -3,14 +3,58 @@ package com.ctrlhub.core.datacapture import com.ctrlhub.core.Api import com.ctrlhub.core.datacapture.resource.FormSubmissionVersion import com.ctrlhub.core.datacapture.response.FormSchema +import com.ctrlhub.core.datacapture.response.Form +import com.ctrlhub.core.iam.response.User +import com.ctrlhub.core.media.response.Image import com.ctrlhub.core.router.Router import io.ktor.client.HttpClient import io.ktor.http.ContentType import com.ctrlhub.core.api.response.PaginatedList +import com.ctrlhub.core.router.request.FilterOption +import com.ctrlhub.core.router.request.JsonApiIncludes +import com.ctrlhub.core.router.request.RequestParametersWithIncludes + +@Suppress("unused") +enum class FormSubmissionVersionIncludes(val key: String) : JsonApiIncludes { + Author("author"), + Form("form"), + Organisation("organisation"), + Schema("schema"), + Submission("submission"), + PayloadImages("payload_images"), + PayloadOperations("payload_operations"), + PayloadProperties("payload_properties"), + PayloadUsers("payload_users"), + PayloadWorkOrders("payload_work_orders"), + PayloadSchemes("payload_schemes"); + + override fun value(): String = key +} + +class FormSubmissionVersionRequestParameters( + offset: Int = 0, + limit: Int = 100, + filterOptions: List = emptyList(), + includes: List = emptyList() +) : RequestParametersWithIncludes( + offset = offset, + limit = limit, + filterOptions = filterOptions, + includes = includes, +) class FormSubmissionVersionsRouter(httpClient: HttpClient) : Router(httpClient) { /** - * Create a new form submission version + * Create a new form submission version. + * + * Sends a POST request to create a submission (and implicitly its version) for the given form. + * + * @param organisationId the organisation UUID the form belongs to + * @param formId the form UUID to create a submission for + * @param schemaId the schema UUID to associate with the created version + * @param payload arbitrary map representing the submission payload (field id -> value) + * @return the created and hydrated FormSubmissionVersion instance + * @throws Exception on network or parsing errors */ suspend fun create(organisationId: String, formId: String, schemaId: String, payload: Map): FormSubmissionVersion { return postJsonApiResource( @@ -31,26 +75,136 @@ class FormSubmissionVersionsRouter(httpClient: HttpClient) : Router(httpClient) } /** - * Get all submission versions for a given form (paginated) + * Get all submission versions for a given form (paginated). + * + * Returns a paginated list of FormSubmissionVersion resources. Supports JSON:API include options + * to hydrate related resources (author, form, schema, images, etc.) via the requestParameters. + * + * @param organisationId the organisation UUID + * @param formId the form UUID + * @param submissionId the submission UUID to list versions for + * @param requestParameters optional paging, filter and include parameters + * @return PaginatedList containing FormSubmissionVersion items and pagination meta */ - suspend fun all(organisationId: String, formId: String, submissionId: String): PaginatedList { + suspend fun all( + organisationId: String, + formId: String, + submissionId: String, + requestParameters: FormSubmissionVersionRequestParameters = FormSubmissionVersionRequestParameters() + ): PaginatedList { return fetchPaginatedJsonApiResources( "/v3/orgs/$organisationId/data-capture/forms/$formId/submissions/$submissionId/versions", - queryParameters = emptyMap(), - FormSubmissionVersion::class.java, - FormSchema::class.java + requestParameters.toMap(), + User::class.java, + Form::class.java, + FormSchema::class.java, + Image::class.java + ) + } + + /** + * Get all submission versions for a specific submission (paginated). + * + * Overload that lists versions by submission id without a form id in the path. + * + * @param organisationId the organisation UUID + * @param submissionId the submission UUID to list versions for + * @param requestParameters optional paging, filter and include parameters + * @return PaginatedList containing FormSubmissionVersion items and pagination meta + */ + suspend fun all( + organisationId: String, + submissionId: String, + requestParameters: FormSubmissionVersionRequestParameters = FormSubmissionVersionRequestParameters() + ): PaginatedList { + return fetchPaginatedJsonApiResources( + "/v3/orgs/$organisationId/data-capture/submissions/$submissionId/versions", + requestParameters.toMap(), + User::class.java, + Form::class.java, + FormSchema::class.java, + Image::class.java + ) + } + + /** + * Get all form submission versions across an organisation (paginated). + * + * Useful for admin-style listing of all submission versions. Supports includes via requestParameters. + * + * @param organisationId the organisation UUID + * @param requestParameters optional paging, filter and include parameters + * @return PaginatedList containing FormSubmissionVersion items and pagination meta + */ + suspend fun all( + organisationId: String, + requestParameters: FormSubmissionVersionRequestParameters = FormSubmissionVersionRequestParameters() + ): PaginatedList { + return fetchPaginatedJsonApiResources( + "/v3/orgs/$organisationId/data-capture/form-submission-versions", + requestParameters.toMap(), + User::class.java, + Form::class.java, + FormSchema::class.java, + Image::class.java ) } /** - * Get a single submission version + * Get a single submission version. + * + * Fetches and hydrates a single FormSubmissionVersion resource by id. Supports JSON:API include + * parameters via requestParameters to hydrate related resources. + * + * @param organisationId the organisation UUID + * @param formId the form UUID the submission belongs to + * @param submissionId the submission UUID + * @param versionId the version UUID to fetch + * @param requestParameters optional paging, filter and include parameters + * @return the hydrated FormSubmissionVersion */ - suspend fun one(organisationId: String, formId: String, submissionId: String, versionId: String): FormSubmissionVersion { + suspend fun one( + organisationId: String, + formId: String, + submissionId: String, + versionId: String, + requestParameters: FormSubmissionVersionRequestParameters = FormSubmissionVersionRequestParameters() + ): FormSubmissionVersion { return fetchJsonApiResource( "/v3/orgs/$organisationId/data-capture/forms/$formId/submissions/$submissionId/versions/$versionId", - queryParameters = emptyMap(), - FormSubmissionVersion::class.java, - FormSchema::class.java + requestParameters.toMap(), + User::class.java, + Form::class.java, + FormSchema::class.java, + Image::class.java + ) + } + + /** + * Get a single submission version by submission id (no form in path). + * + * Fetches and hydrates a single FormSubmissionVersion resource by id using the endpoint that does + * not include the form id in the path. + * + * @param organisationId the organisation UUID + * @param submissionId the submission UUID + * @param versionId the version UUID to fetch + * @param requestParameters optional paging, filter and include parameters + * @return the hydrated FormSubmissionVersion + */ + suspend fun one( + organisationId: String, + submissionId: String, + versionId: String, + requestParameters: FormSubmissionVersionRequestParameters = FormSubmissionVersionRequestParameters() + ): FormSubmissionVersion { + return fetchJsonApiResource( + "/v3/orgs/$organisationId/data-capture/submissions/$submissionId/versions/$versionId", + requestParameters.toMap(), + User::class.java, + Form::class.java, + FormSchema::class.java, + Image::class.java ) } } diff --git a/src/main/kotlin/com/ctrlhub/core/datacapture/resource/FormSubmissionVersion.kt b/src/main/kotlin/com/ctrlhub/core/datacapture/resource/FormSubmissionVersion.kt index 4f15e71..fd40d50 100644 --- a/src/main/kotlin/com/ctrlhub/core/datacapture/resource/FormSubmissionVersion.kt +++ b/src/main/kotlin/com/ctrlhub/core/datacapture/resource/FormSubmissionVersion.kt @@ -1,6 +1,13 @@ package com.ctrlhub.core.datacapture.resource import com.ctrlhub.core.datacapture.response.FormSchema +import com.ctrlhub.core.datacapture.response.Form +import com.ctrlhub.core.geo.Property +import com.ctrlhub.core.iam.response.User +import com.ctrlhub.core.media.response.Image +import com.ctrlhub.core.projects.operations.response.Operation +import com.ctrlhub.core.projects.schemes.response.Scheme +import com.ctrlhub.core.projects.workorders.response.WorkOrder import com.fasterxml.jackson.annotation.JsonCreator import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonProperty @@ -11,7 +18,7 @@ import com.github.jasminb.jsonapi.annotations.Type @JsonIgnoreProperties(ignoreUnknown = true) @Type("form-submission-versions") -data class FormSubmissionVersion @JsonCreator constructor( +class FormSubmissionVersion @JsonCreator constructor( @Id(StringIdHandler::class) var id: String = "", @@ -23,4 +30,31 @@ data class FormSubmissionVersion @JsonCreator constructor( @Relationship("schema") var schema: FormSchema? = null, + + @JsonProperty("meta") + var meta: Map? = null, + + @Relationship("author") + var author: User? = null, + + @Relationship("form") + var form: Form? = null, + + @Relationship("payload_images") + var payloadImages: List? = null, + + @Relationship("payload_operations") + var payloadOperations: List? = null, + + @Relationship("payload_properties") + var payloadProperties: List? = null, + + @Relationship("payload_users") + var payloadUsers: List? = null, + + @Relationship("payload_work_orders") + var payloadWorkOrders: List? = null, + + @Relationship("payload_schemes") + var payloadSchemes: List? = null, ) diff --git a/src/main/kotlin/com/ctrlhub/core/http/KtorClientFactory.kt b/src/main/kotlin/com/ctrlhub/core/http/KtorClientFactory.kt index d6b8525..b4f9dcb 100644 --- a/src/main/kotlin/com/ctrlhub/core/http/KtorClientFactory.kt +++ b/src/main/kotlin/com/ctrlhub/core/http/KtorClientFactory.kt @@ -4,6 +4,7 @@ import com.ctrlhub.core.Config import io.ktor.client.HttpClient import io.ktor.client.HttpClientConfig import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.UserAgent import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest @@ -41,6 +42,11 @@ object KtorClientFactory { encodeDefaults = true }) } + install(HttpTimeout) { + requestTimeoutMillis = 15_000 + connectTimeoutMillis = 15_000 + socketTimeoutMillis = 15_000 + } install(UserAgent) { agent = Config.userAgent } diff --git a/src/main/kotlin/com/ctrlhub/core/media/response/Image.kt b/src/main/kotlin/com/ctrlhub/core/media/response/Image.kt index 74f9968..3ec6b31 100644 --- a/src/main/kotlin/com/ctrlhub/core/media/response/Image.kt +++ b/src/main/kotlin/com/ctrlhub/core/media/response/Image.kt @@ -4,20 +4,51 @@ import com.fasterxml.jackson.annotation.JsonCreator import com.fasterxml.jackson.annotation.JsonProperty import com.github.jasminb.jsonapi.StringIdHandler import com.github.jasminb.jsonapi.annotations.Id +import com.github.jasminb.jsonapi.annotations.Meta import com.github.jasminb.jsonapi.annotations.Type @Type("images") -data class Image @JsonCreator constructor( - @Id(StringIdHandler::class) val id: String = "", - @JsonProperty("mime_type") val mimeType: String, - @JsonProperty("extension") val extension: String, - @JsonProperty("width") val width: Int, - @JsonProperty("height") val height: Int, - @JsonProperty("bytes") val bytes: Long, - @JsonProperty("dimensions") val dimensions: List = emptyList() -) +class Image @JsonCreator constructor( + @Id(StringIdHandler::class) var id: String = "", + @JsonProperty("mime_type") var mimeType: String = "", + @JsonProperty("extension") var extension: String = "", + @JsonProperty("width") var width: Int = 0, + @JsonProperty("height") var height: Int = 0, + @JsonProperty("bytes") var bytes: Long = 0L, + @JsonProperty("dimensions") var dimensions: List = emptyList(), -data class ImageDimensions( - val width: Int, - val height: Int, -) \ No newline at end of file + @Meta var meta: ImageMeta? = null +) { + constructor(): this( + id = "", + mimeType = "", + extension = "", + width = 0, + height = 0, + bytes = 0L, + dimensions = emptyList(), + meta = null + ) +} + +class ImageDimensions( + var width: Int = 0, + var height: Int = 0, +) { + constructor(): this(width = 0, height = 0) +} + +class ImageLink( + var url: String = "", + var width: Int? = null, + var height: Int? = null +) { + constructor(): this(url = "", width = null, height = null) +} + +class ImageMeta( + var links: List = emptyList(), + @JsonProperty("created_at") var createdAt: String? = null +) { + constructor(): this(links = emptyList(), createdAt = null) +} diff --git a/src/test/kotlin/com/ctrlhub/core/datacapture/FormSubmissionVersionsRouterTest.kt b/src/test/kotlin/com/ctrlhub/core/datacapture/FormSubmissionVersionsRouterTest.kt index 3c694e9..9e8af66 100644 --- a/src/test/kotlin/com/ctrlhub/core/datacapture/FormSubmissionVersionsRouterTest.kt +++ b/src/test/kotlin/com/ctrlhub/core/datacapture/FormSubmissionVersionsRouterTest.kt @@ -109,4 +109,66 @@ class FormSubmissionVersionsRouterTest { assertTrue(result.payload!!.containsKey("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")) } } + + // New tests for overloads that use submission-only path + + @Test + fun `can get all form submission versions by submission id and hydrate`() { + val jsonFilePath = Paths.get("src/test/resources/datacapture/all-form-submission-versions-response.json") + val jsonContent = Files.readString(jsonFilePath) + + val mockEngine = MockEngine { _ -> + respond( + content = jsonContent, + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/vnd.api+json") + ) + } + + val formSubmissionVersionsRouter = FormSubmissionVersionsRouter(httpClient = HttpClient(mockEngine).configureForTest()) + + runBlocking { + val result = formSubmissionVersionsRouter.all( + organisationId = "org-123", + submissionId = "d3c2b1a0-9f8e-7d6c-5b4a-3e2f1d0c9b8a" + ) + + assertNotNull(result) + assertTrue(result.data.isNotEmpty()) + + val first = result.data.first() + assertIs(first) + assertNotNull(first.id) + } + } + + @Test + fun `can get one form submission version by submission id and hydrate`() { + val jsonFilePath = Paths.get("src/test/resources/datacapture/one-form-submission-version-response.json") + val jsonContent = Files.readString(jsonFilePath) + + val mockEngine = MockEngine { _ -> + respond( + content = jsonContent, + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/vnd.api+json") + ) + } + + val formSubmissionVersionsRouter = FormSubmissionVersionsRouter(httpClient = HttpClient(mockEngine).configureForTest()) + + runBlocking { + val result = formSubmissionVersionsRouter.one( + organisationId = "org-123", + submissionId = "d4c3b2a1-0f9e-8d7c-6b5a-4e3f2d1c0b9a", + versionId = "4a8b7c6d-2e3f-4a1b-9c2d-0e1f2a3b4c5d" + ) + + assertIs(result) + assertEquals("4a8b7c6d-2e3f-4a1b-9c2d-0e1f2a3b4c5d", result.id) + assertNotNull(result.payload) + assertTrue(result.payload!!.containsKey("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")) + assertTrue(result.payload!!.containsKey("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")) + } + } }