diff --git a/src/main/kotlin/com/ctrlhub/core/datacapture/FormSubmissionVersionsRouter.kt b/src/main/kotlin/com/ctrlhub/core/datacapture/FormSubmissionVersionsRouter.kt new file mode 100644 index 0000000..f916938 --- /dev/null +++ b/src/main/kotlin/com/ctrlhub/core/datacapture/FormSubmissionVersionsRouter.kt @@ -0,0 +1,59 @@ +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.router.Router +import io.ktor.client.HttpClient +import io.ktor.http.ContentType +import com.ctrlhub.core.api.response.PaginatedList + +class FormSubmissionVersionsRouter(httpClient: HttpClient) : Router(httpClient) { + /** + * Create a new form submission version + */ + suspend fun create(organisationId: String, formId: String, schemaId: String, payload: Map): FormSubmissionVersion { + return postJsonApiResource( + "/v3/orgs/$organisationId/data-capture/forms/$formId/submissions", + requestBody = FormSubmissionVersion( + payload = payload, + id = "", + schema = FormSchema( + id = schemaId, + rawSchema = null, + ) + ), + queryParameters = emptyMap(), + contentType = ContentType.parse("application/vnd.api+json"), + FormSubmissionVersion::class.java, + FormSchema::class.java + ) + } + + /** + * Get all submission versions for a given form (paginated) + */ + suspend fun all(organisationId: String, formId: String, submissionId: String): PaginatedList { + return fetchPaginatedJsonApiResources( + "/v3/orgs/$organisationId/data-capture/forms/$formId/submissions/$submissionId/versions", + queryParameters = emptyMap(), + FormSubmissionVersion::class.java, + FormSchema::class.java + ) + } + + /** + * Get a single submission version + */ + suspend fun one(organisationId: String, formId: String, submissionId: String, versionId: String): FormSubmissionVersion { + return fetchJsonApiResource( + "/v3/orgs/$organisationId/data-capture/forms/$formId/submissions/$submissionId/versions/$versionId", + queryParameters = emptyMap(), + FormSubmissionVersion::class.java, + FormSchema::class.java + ) + } +} + +val Api.formSubmissionVersions + get() = FormSubmissionVersionsRouter(httpClient = httpClient) diff --git a/src/main/kotlin/com/ctrlhub/core/datacapture/FormSubmissionsRouter.kt b/src/main/kotlin/com/ctrlhub/core/datacapture/FormSubmissionsRouter.kt index 7b731c3..aba79d8 100644 --- a/src/main/kotlin/com/ctrlhub/core/datacapture/FormSubmissionsRouter.kt +++ b/src/main/kotlin/com/ctrlhub/core/datacapture/FormSubmissionsRouter.kt @@ -1,23 +1,30 @@ 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.FormSubmission +import com.ctrlhub.core.datacapture.response.Form +import com.ctrlhub.core.iam.response.User +import com.ctrlhub.core.projects.response.Organisation import com.ctrlhub.core.router.Router import io.ktor.client.HttpClient -import io.ktor.http.ContentType +import com.ctrlhub.core.api.response.PaginatedList class FormSubmissionsRouter(httpClient: HttpClient) : Router(httpClient) { - suspend fun create(organisationId: String, formId: String, schemaId: String, payload: Map): FormSubmissionVersion { - return postJsonApiResource("/v3/orgs/$organisationId/data-capture/forms/$formId/submissions", requestBody = FormSubmissionVersion( - payload = payload, - id = "", - schema = FormSchema( - id = schemaId, - rawSchema = null, - ) - ), queryParameters = emptyMap(), contentType = ContentType.parse("application/vnd.api+json"), FormSubmissionVersion::class.java, - FormSchema::class.java) + /** + * Get paginated form-submission resources (hydrated with relationships) + * + * @return PaginatedList of FormSubmission + */ + suspend fun all(organisationId: String, formId: String): PaginatedList { + return fetchPaginatedJsonApiResources( + "/v3/orgs/$organisationId/data-capture/forms/$formId/submissions", + queryParameters = emptyMap(), + FormSubmission::class.java, + User::class.java, + Form::class.java, + Organisation::class.java, + com.ctrlhub.core.datacapture.resource.FormSubmissionVersion::class.java + ) } } diff --git a/src/main/kotlin/com/ctrlhub/core/datacapture/response/FormSubmission.kt b/src/main/kotlin/com/ctrlhub/core/datacapture/response/FormSubmission.kt new file mode 100644 index 0000000..a0aa477 --- /dev/null +++ b/src/main/kotlin/com/ctrlhub/core/datacapture/response/FormSubmission.kt @@ -0,0 +1,53 @@ +package com.ctrlhub.core.datacapture.response + +import com.ctrlhub.core.datacapture.resource.FormSubmissionVersion +import com.ctrlhub.core.projects.response.Organisation +import com.ctrlhub.core.iam.response.User +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +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.Relationship +import com.github.jasminb.jsonapi.annotations.Type +import java.time.LocalDateTime + +@Type("form-submissions") +@JsonIgnoreProperties(ignoreUnknown = true) +class FormSubmission @JsonCreator constructor( + @Id(StringIdHandler::class) var id: String = "", + + @Relationship("contributors") + var contributors: java.util.List? = null, + + @Relationship("creator") + var creator: User? = null, + + @Relationship("form") + var form: Form? = null, + + @Relationship("organisation") + var organisation: Organisation? = null, + + @Relationship("versions") + var versions: java.util.List? = null, + + @Meta + var meta: FormSubmissionMeta? = null +) { + constructor(): this(id = "") +} + +@JsonIgnoreProperties(ignoreUnknown = true) +class FormSubmissionMeta @JsonCreator constructor( + @JsonProperty("created_at") var createdAt: LocalDateTime? = null, + @JsonProperty("modified_at") var modifiedAt: LocalDateTime? = null, + @JsonProperty("counts") var counts: FormSubmissionCounts? = null +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class FormSubmissionCounts( + @JsonProperty("versions") val versions: Int = 0, + @JsonProperty("contributors") val contributors: Int = 0 +) diff --git a/src/test/kotlin/com/ctrlhub/core/datacapture/FormSubmissionVersionsRouterTest.kt b/src/test/kotlin/com/ctrlhub/core/datacapture/FormSubmissionVersionsRouterTest.kt new file mode 100644 index 0000000..3c694e9 --- /dev/null +++ b/src/test/kotlin/com/ctrlhub/core/datacapture/FormSubmissionVersionsRouterTest.kt @@ -0,0 +1,112 @@ +package com.ctrlhub.core.datacapture + +import com.ctrlhub.core.configureForTest +import com.ctrlhub.core.datacapture.resource.FormSubmissionVersion +import com.ctrlhub.core.api.response.PaginatedList +import io.ktor.client.* +import io.ktor.client.engine.mock.* +import io.ktor.http.* +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import java.nio.file.Files +import java.nio.file.Paths +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class FormSubmissionVersionsRouterTest { + + @Test + fun `can create form submission versions`() { + val jsonFilePath = Paths.get("src/test/resources/datacapture/form-submission-version-response.json") + val jsonContent = Files.readString(jsonFilePath) + + val mockEngine = MockEngine { _ -> + respond( + content = jsonContent, + status = HttpStatusCode.Created, + headers = headersOf(HttpHeaders.ContentType, "application/vnd.api+json") + ) + } + + val formSubmissionVersionsRouter = FormSubmissionVersionsRouter(httpClient = HttpClient(mockEngine).configureForTest()) + + runBlocking { + val result = formSubmissionVersionsRouter.create( + organisationId = "org-123", + formId = "form-456", + schemaId = "schema-789", + payload = mapOf("Test" to "value") + ) + + assertIs(result) + assertNotNull(result.id) + } + } + + @Test + fun `can get all form submission versions 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", + formId = "form-456", + submissionId = "d3c2b1a0-9f8e-7d6c-5b4a-3e2f1d0c9b8a" + ) + + // verify the paginated wrapper + assertNotNull(result) + assertTrue(result.data.isNotEmpty()) + + // verify hydration of the first item + val first = result.data.first() + assertIs(first) + assertNotNull(first.id) + } + } + + @Test + fun `can get one form submission version 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", + formId = "form-456", + submissionId = "d4c3b2a1-0f9e-8d7c-6b5a-4e3f2d1c0b9a", + versionId = "4a8b7c6d-2e3f-4a1b-9c2d-0e1f2a3b4c5d" + ) + + assertIs(result) + assertEquals("4a8b7c6d-2e3f-4a1b-9c2d-0e1f2a3b4c5d", result.id) + // check payload exists and contains expected keys + assertNotNull(result.payload) + assertTrue(result.payload!!.containsKey("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")) + assertTrue(result.payload!!.containsKey("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")) + } + } +} diff --git a/src/test/kotlin/com/ctrlhub/core/datacapture/FormSubmissionsRouterTest.kt b/src/test/kotlin/com/ctrlhub/core/datacapture/FormSubmissionsRouterTest.kt index 4ac69cd..631d90f 100644 --- a/src/test/kotlin/com/ctrlhub/core/datacapture/FormSubmissionsRouterTest.kt +++ b/src/test/kotlin/com/ctrlhub/core/datacapture/FormSubmissionsRouterTest.kt @@ -1,7 +1,8 @@ package com.ctrlhub.core.datacapture import com.ctrlhub.core.configureForTest -import com.ctrlhub.core.datacapture.resource.FormSubmissionVersion +import com.ctrlhub.core.datacapture.response.FormSubmission +import com.ctrlhub.core.api.response.PaginatedList import io.ktor.client.* import io.ktor.client.engine.mock.* import io.ktor.http.* @@ -16,14 +17,14 @@ import kotlin.test.assertIs class FormSubmissionsRouterTest { @Test - fun `can create form submission`() { - val jsonFilePath = Paths.get("src/test/resources/datacapture/form-submission-response.json") + fun `can retrieve and hydrate submissions`() { + val jsonFilePath = Paths.get("src/test/resources/datacapture/all-form-submissions-response.json") val jsonContent = Files.readString(jsonFilePath) val mockEngine = MockEngine { request -> respond( content = jsonContent, - status = HttpStatusCode.Created, + status = HttpStatusCode.OK, headers = headersOf(HttpHeaders.ContentType, "application/vnd.api+json") ) } @@ -31,15 +32,14 @@ class FormSubmissionsRouterTest { val formSubmissionsRouter = FormSubmissionsRouter(httpClient = HttpClient(mockEngine).configureForTest()) runBlocking { - val result = formSubmissionsRouter.create( - organisationId = "org-123", - formId = "form-456", - schemaId = "schema-789", - payload = mapOf("Test" to "value") - ) + val response = formSubmissionsRouter.all(organisationId = "org-123", formId = "form-456") - assertIs(result) - assertNotNull(result.id) + assertIs>(response) + assertEquals(4, response.data.size) + assertNotNull(response.data[0].id) + // verify pagination counts parsed from meta + assertEquals(4, response.pagination.counts.resources) + assertEquals(1, response.pagination.counts.pages) } } } \ No newline at end of file diff --git a/src/test/resources/datacapture/all-form-submission-versions-response.json b/src/test/resources/datacapture/all-form-submission-versions-response.json new file mode 100644 index 0000000..3ce6284 --- /dev/null +++ b/src/test/resources/datacapture/all-form-submission-versions-response.json @@ -0,0 +1,100 @@ +{ + "data": [ + { + "id": "4f7d2a6b-3c94-4d9a-9f2b-8c5a1a2b3c4d", + "type": "form-submission-versions", + "attributes": { + "iteration": 1, + "payload": { + "11111111-1111-1111-1111-111111111111": "lorem-456", + "22222222-2222-2222-2222-222222222222": true, + "33333333-3333-3333-3333-333333333333": "Lorem ipsum dolor sit amet" + }, + "resources": [] + }, + "relationships": { + "author": { + "data": { + "id": "7c6f1b22-8d44-4a97-9e1b-2f3c4d5e6f70", + "type": "users" + } + }, + "form": { + "data": { + "id": "f1e2d3c4-b5a6-47d8-9123-4567890abcde", + "type": "forms" + } + }, + "organisation": { + "data": { + "id": "0a1b2c3d-4e5f-6789-abcd-ef0123456789", + "type": "organisations" + } + }, + "payload_images": { "data": [] }, + "payload_operations": { "data": [] }, + "payload_properties": { "data": [] }, + "payload_users": { "data": [] }, + "schema": { + "data": { + "id": "9b8c7d6e-5f4a-3b2c-1d0e-9f8e7d6c5b4a", + "type": "form-schemas" + } + }, + "submission": { + "data": { + "id": "d3c2b1a0-9f8e-7d6c-5b4a-3e2f1d0c9b8a", + "type": "form-submissions" + } + } + }, + "meta": { + "created_at": "2025-10-09T12:00:00.000Z", + "latest": "4f7d2a6b-3c94-4d9a-9f2b-8c5a1a2b3c4d", + "is_latest": false + } + } + ], + "meta": { + "pagination": { + "current_page": 1, + "counts": { + "resources": 1, + "pages": 1 + }, + "requested": { + "offset": 0, + "limit": 100 + }, + "offsets": { + "previous": null, + "next": null + } + }, + "features": { + "params": { + "include": { + "options": [ + "author", + "form", + "organisation", + "payload_images", + "payload_operations", + "payload_properties", + "payload_users", + "schema", + "submission" + ] + }, + "sort": { + "default": "", + "options": null + } + } + } + }, + "jsonapi": { + "version": "1.0" + } +} + diff --git a/src/test/resources/datacapture/all-form-submissions-response.json b/src/test/resources/datacapture/all-form-submissions-response.json new file mode 100644 index 0000000..303733e --- /dev/null +++ b/src/test/resources/datacapture/all-form-submissions-response.json @@ -0,0 +1,232 @@ +{ + "data": [ + { + "id": "6a3b9f2d-1c4e-4f6b-8a2d-0e1f2a3b4c5d", + "type": "form-submissions", + "relationships": { + "contributors": { + "data": [ + { + "id": "b2c3d4e5-f6a7-4890-8123-456789abcdef", + "type": "users" + } + ] + }, + "creator": { + "data": { + "id": "b2c3d4e5-f6a7-4890-8123-456789abcdef", + "type": "users" + } + }, + "form": { + "data": { + "id": "c3d4e5f6-1234-5678-9abc-def012345678", + "type": "forms" + } + }, + "organisation": { + "data": { + "id": "d4e5f6a7-2345-6789-abcd-ef0123456789", + "type": "organisations" + } + }, + "versions": { + "data": [ + { + "id": "e5f6a7b8-3456-789a-bcde-f01234567890", + "type": "form-submission-versions" + } + ] + } + }, + "meta": { + "created_at": "2025-06-27T09:58:46.057Z", + "modified_at": "2025-06-27T09:58:46.057Z", + "counts": { + "versions": 1, + "contributors": 1 + }, + "note": "Lorem ipsum dolor sit amet, consectetur adipiscing elit." + } + }, + { + "id": "7b4c0a3e-2d5f-4a7b-9c3e-1f2b3c4d5e6f", + "type": "form-submissions", + "relationships": { + "contributors": { + "data": [ + { + "id": "f6a7b8c9-0123-4567-89ab-cdef01234567", + "type": "users" + } + ] + }, + "creator": { + "data": { + "id": "f6a7b8c9-0123-4567-89ab-cdef01234567", + "type": "users" + } + }, + "form": { + "data": { + "id": "c3d4e5f6-1234-5678-9abc-def012345678", + "type": "forms" + } + }, + "organisation": { + "data": { + "id": "d4e5f6a7-2345-6789-abcd-ef0123456789", + "type": "organisations" + } + }, + "versions": { + "data": [ + { + "id": "a7b8c9d0-4567-89ab-cdef-0123456789ab", + "type": "form-submission-versions" + } + ] + } + }, + "meta": { + "created_at": "2025-07-17T10:07:25.281Z", + "modified_at": "2025-07-17T10:07:25.281Z", + "counts": { + "versions": 1, + "contributors": 1 + }, + "note": "Lorem ipsum dolor sit amet, consectetur adipiscing elit." + } + }, + { + "id": "8c5d1b4f-3e60-4b8c-ade1-2b3c4d5e6f70", + "type": "form-submissions", + "relationships": { + "contributors": { + "data": [ + { + "id": "c8d9e0f1-2345-6789-abcd-ef0123456789", + "type": "users" + } + ] + }, + "creator": { + "data": { + "id": "c8d9e0f1-2345-6789-abcd-ef0123456789", + "type": "users" + } + }, + "form": { + "data": { + "id": "c3d4e5f6-1234-5678-9abc-def012345678", + "type": "forms" + } + }, + "organisation": { + "data": { + "id": "d4e5f6a7-2345-6789-abcd-ef0123456789", + "type": "organisations" + } + }, + "versions": { + "data": [ + { + "id": "b9c0d1e2-5678-9abc-def0-1234567890ab", + "type": "form-submission-versions" + } + ] + } + }, + "meta": { + "created_at": "2025-09-26T09:32:38.428Z", + "modified_at": "2025-09-26T09:32:38.428Z", + "counts": { + "versions": 1, + "contributors": 1 + }, + "note": "Lorem ipsum dolor sit amet, consectetur adipiscing elit." + } + }, + { + "id": "9d6e2c60-4f71-4c9d-bef2-3c4d5e6f7081", + "type": "form-submissions", + "relationships": { + "contributors": { + "data": [ + { + "id": "d0e1f2a3-6789-0abc-def1-234567890abc", + "type": "users" + } + ] + }, + "creator": { + "data": { + "id": "d0e1f2a3-6789-0abc-def1-234567890abc", + "type": "users" + } + }, + "form": { + "data": { + "id": "c3d4e5f6-1234-5678-9abc-def012345678", + "type": "forms" + } + }, + "organisation": { + "data": { + "id": "d4e5f6a7-2345-6789-abcd-ef0123456789", + "type": "organisations" + } + }, + "versions": { + "data": [ + { + "id": "c0d1e2f3-6789-0abc-def1-234567890bcd", + "type": "form-submission-versions" + } + ] + } + }, + "meta": { + "created_at": "2025-09-26T09:34:50.129Z", + "modified_at": "2025-09-26T09:34:50.129Z", + "counts": { + "versions": 1, + "contributors": 1 + }, + "note": "Lorem ipsum dolor sit amet, consectetur adipiscing elit." + } + } + ], + "meta": { + "pagination": { + "current_page": 1, + "counts": { + "resources": 4, + "pages": 1 + }, + "requested": { + "offset": 0, + "limit": 100 + }, + "offsets": { + "previous": null, + "next": null + } + }, + "features": { + "params": { + "include": { + "options": null + }, + "sort": { + "default": "", + "options": null + } + } + } + }, + "jsonapi": { + "version": "1.0" + } +} + diff --git a/src/test/resources/datacapture/form-submission-response.json b/src/test/resources/datacapture/create-form-submission-version-response.json similarity index 99% rename from src/test/resources/datacapture/form-submission-response.json rename to src/test/resources/datacapture/create-form-submission-version-response.json index e466106..7375309 100644 --- a/src/test/resources/datacapture/form-submission-response.json +++ b/src/test/resources/datacapture/create-form-submission-version-response.json @@ -16,4 +16,5 @@ } } } -} \ No newline at end of file +} + diff --git a/src/test/resources/datacapture/form-submission-version-response.json b/src/test/resources/datacapture/form-submission-version-response.json new file mode 100644 index 0000000..7375309 --- /dev/null +++ b/src/test/resources/datacapture/form-submission-version-response.json @@ -0,0 +1,20 @@ +{ + "data": { + "type": "form-submission-versions", + "id": "submission-123", + "attributes": { + "payload": { + "Test": "value" + } + }, + "relationships": { + "schema": { + "data": { + "type": "form-schema", + "id": "schema-789" + } + } + } + } +} + diff --git a/src/test/resources/datacapture/one-form-submission-version-response.json b/src/test/resources/datacapture/one-form-submission-version-response.json new file mode 100644 index 0000000..7e063e1 --- /dev/null +++ b/src/test/resources/datacapture/one-form-submission-version-response.json @@ -0,0 +1,60 @@ +{ + "data": { + "id": "4a8b7c6d-2e3f-4a1b-9c2d-0e1f2a3b4c5d", + "type": "form-submission-versions", + "attributes": { + "iteration": 1, + "payload": { + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa": "lorem-456", + "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb": true, + "cccccccc-cccc-cccc-cccc-cccccccccccc": "Lorem ipsum dolor sit amet" + }, + "resources": [] + }, + "relationships": { + "author": { + "data": { + "id": "9f8e7d6c-5b4a-3c2d-1e0f-9a8b7c6d5e4f", + "type": "users" + } + }, + "form": { + "data": { + "id": "f0e1d2c3-b4a5-6789-0abc-def123456789", + "type": "forms" + } + }, + "organisation": { + "data": { + "id": "0f1e2d3c-4b5a-6789-abcd-ef0123456789", + "type": "organisations" + } + }, + "payload_images": { "data": [] }, + "payload_operations": { "data": [] }, + "payload_properties": { "data": [] }, + "payload_users": { "data": [] }, + "schema": { + "data": { + "id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "type": "form-schemas" + } + }, + "submission": { + "data": { + "id": "d4c3b2a1-0f9e-8d7c-6b5a-4e3f2d1c0b9a", + "type": "form-submissions" + } + } + }, + "meta": { + "created_at": "2025-10-09T12:00:00.000Z", + "latest": "4a8b7c6d-2e3f-4a1b-9c2d-0e1f2a3b4c5d", + "is_latest": false + } + }, + "jsonapi": { + "version": "1.0" + } +} +