Skip to content

Commit 6c96c2a

Browse files
authored
Merge pull request #2 from ctrl-hub/feat/support-appointments
feat: support appointments with an operation
2 parents be93139 + 85c3073 commit 6c96c2a

File tree

7 files changed

+745
-2
lines changed

7 files changed

+745
-2
lines changed

.github/workflows/ci.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# .github/workflows/ci.yml
2+
name: CI
3+
4+
on:
5+
push:
6+
branches: [ main ]
7+
paths: ['**/*.kt', '**/*.kts', '.github/workflows/ci.yml']
8+
pull_request:
9+
branches: [ main ]
10+
11+
jobs:
12+
build:
13+
if: github.event_name == 'push' || github.event_name == 'pull_request'
14+
runs-on: ubuntu-latest
15+
name: "Run Tests"
16+
17+
env:
18+
GH_USERNAME: ${{ github.actor }}
19+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
20+
21+
steps:
22+
- name: Checkout code
23+
uses: actions/checkout@v4
24+
25+
- name: Set up JDK 23
26+
uses: actions/setup-java@v4
27+
with:
28+
distribution: 'temurin'
29+
java-version: 23
30+
31+
- name: Cache Gradle dependencies
32+
uses: actions/cache@v4
33+
with:
34+
path: |
35+
~/.gradle/caches
36+
~/.gradle/wrapper
37+
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
38+
restore-keys: |
39+
gradle-${{ runner.os }}-
40+
41+
- name: Grant execute permission for gradlew
42+
run: chmod +x gradlew
43+
44+
- name: Test
45+
run: ./gradlew test

.github/workflows/gemini-pr-review.yaml

Lines changed: 417 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.ctrlhub.core.projects.appointments.response
2+
3+
import com.fasterxml.jackson.annotation.JsonCreator
4+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
5+
import com.fasterxml.jackson.annotation.JsonProperty
6+
import com.github.jasminb.jsonapi.StringIdHandler
7+
import com.github.jasminb.jsonapi.annotations.Id
8+
import com.github.jasminb.jsonapi.annotations.Type
9+
10+
@Type("appointments")
11+
@JsonIgnoreProperties(ignoreUnknown = true)
12+
class Appointment @JsonCreator constructor(
13+
@Id(StringIdHandler::class) var id: String = "",
14+
@JsonProperty("animals") val animals: Boolean = false,
15+
@JsonProperty("end_time") val endTime: String = "",
16+
@JsonProperty("medical_dependency") val medicalDependency: Boolean = false,
17+
@JsonProperty("notes") val notes: String = "",
18+
@JsonProperty("on_ecr") val onEcr: Boolean = false,
19+
@JsonProperty("on_ecr_notes") val onEcrNotes: String = "",
20+
@JsonProperty("start_time") val startTime: String = ""
21+
) {
22+
constructor() : this(
23+
id = "",
24+
animals = false,
25+
endTime = "",
26+
medicalDependency = false,
27+
notes = "",
28+
onEcr = false,
29+
onEcrNotes = "",
30+
startTime = ""
31+
)
32+
}

src/main/kotlin/com/ctrlhub/core/projects/operations/OperationsRouter.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,18 @@ import com.ctrlhub.core.api.response.PaginatedList
55
import com.ctrlhub.core.projects.workorders.WorkOrdersRouter
66
import com.ctrlhub.core.projects.operations.response.Operation
77
import com.ctrlhub.core.iam.response.User
8+
import com.ctrlhub.core.projects.appointments.response.Appointment
89
import com.ctrlhub.core.projects.operations.templates.response.OperationTemplate
910
import com.ctrlhub.core.router.Router
1011
import com.ctrlhub.core.router.request.FilterOption
1112
import com.ctrlhub.core.router.request.JsonApiIncludes
12-
import com.ctrlhub.core.router.request.RequestParameters
1313
import com.ctrlhub.core.router.request.RequestParametersWithIncludes
1414
import io.ktor.client.HttpClient
1515

1616
enum class OperationIncludes(val value: String) : JsonApiIncludes {
1717
Template("template"),
18+
Appointments("appointment"),
19+
Properties("properties"),
1820
Forms("forms");
1921

2022
override fun value(): String {
@@ -46,7 +48,7 @@ class OperationsRouter(httpClient: HttpClient) : Router(httpClient) {
4648

4749
return fetchPaginatedJsonApiResources(
4850
endpoint, requestParameters.toMap(), Operation::class.java,
49-
OperationTemplate::class.java, User::class.java
51+
OperationTemplate::class.java, User::class.java, Appointment::class.java
5052
)
5153
}
5254

src/main/kotlin/com/ctrlhub/core/projects/operations/response/Operation.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.ctrlhub.core.projects.operations.response
22

33
import com.ctrlhub.core.api.Assignable
44
import com.ctrlhub.core.geo.Property
5+
import com.ctrlhub.core.projects.appointments.response.Appointment
56
import com.ctrlhub.core.projects.operations.templates.response.OperationTemplate
67
import com.ctrlhub.core.projects.response.Label
78
import com.fasterxml.jackson.annotation.JsonCreator
@@ -36,6 +37,9 @@ class Operation @JsonCreator constructor(
3637

3738
@Relationship("properties")
3839
var properties: java.util.List<Property>? = null,
40+
41+
@Relationship("appointment")
42+
var appointment: Appointment? = null
3943
) {
4044
constructor(): this(
4145
name = "",

src/test/kotlin/com/ctrlhub/core/projects/operations/OperationsRouterTest.kt

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,44 @@ class OperationsRouterTest {
9797
assertEquals("Example Template Name", response.data[0].template?.name)
9898
}
9999
}
100+
101+
@Test
102+
fun `can retrieve all operations with included appointments`() {
103+
val jsonFilePath = Paths.get("src/test/resources/projects/operations/all-operations-with-included-appointments.json")
104+
val jsonContent = Files.readString(jsonFilePath)
105+
106+
val mockEngine = MockEngine { request ->
107+
respond(
108+
content = jsonContent,
109+
status = HttpStatusCode.OK,
110+
headers = headersOf(HttpHeaders.ContentType, "application/json")
111+
)
112+
}
113+
114+
val operationsRouter = OperationsRouter(httpClient = HttpClient(mockEngine).configureForTest())
115+
116+
runBlocking {
117+
val response = operationsRouter.all(
118+
organisationId = "123",
119+
requestParameters = OperationRequestParameters(
120+
includes = listOf(
121+
OperationIncludes.Appointments
122+
)
123+
)
124+
)
125+
126+
assertIs<PaginatedList<Operation>>(response)
127+
128+
// Find the operation that has an appointment
129+
val operationWithAppointment = response.data.find { it.appointment != null }
130+
assertNotNull(operationWithAppointment, "Should have at least one operation with an appointment")
131+
132+
// Validate the appointment is properly hydrated
133+
val appointment = operationWithAppointment?.appointment
134+
assertNotNull(appointment, "Appointment should not be null")
135+
assertEquals("appointment-1", appointment?.id)
136+
assertEquals("2025-08-15T07:00:00Z", appointment?.startTime)
137+
assertEquals("2025-08-15T11:00:00Z", appointment?.endTime)
138+
}
139+
}
100140
}
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
{
2+
"data": [
3+
{
4+
"id": "operation-1",
5+
"type": "operations",
6+
"attributes": {
7+
"code": "ANON-TK0002",
8+
"dates": {
9+
"scheduled": {}
10+
},
11+
"description": "",
12+
"labels": [
13+
{
14+
"key": "Time band",
15+
"value": "AM"
16+
}
17+
],
18+
"name": "Anonymised Task 1",
19+
"requirements": {
20+
"forms": [
21+
{
22+
"id": "form-1",
23+
"required": true
24+
}
25+
]
26+
}
27+
},
28+
"relationships": {
29+
"appointment": {
30+
"data": null
31+
},
32+
"assignees": {
33+
"data": [
34+
{ "id": "user-1", "type": "users" },
35+
{ "id": "user-2", "type": "users" },
36+
{ "id": "user-3", "type": "users" },
37+
{ "id": "user-4", "type": "users" }
38+
]
39+
},
40+
"forms": {
41+
"data": [
42+
{ "id": "form-1", "type": "forms" }
43+
]
44+
},
45+
"organisation": {
46+
"data": { "id": "org-1", "type": "organisations" }
47+
},
48+
"permits": { "data": [] },
49+
"properties": {
50+
"data": [
51+
{ "id": "property-1", "type": "properties" },
52+
{ "id": "property-2", "type": "properties" },
53+
{ "id": "property-3", "type": "properties" },
54+
{ "id": "property-4", "type": "properties" },
55+
{ "id": "property-5", "type": "properties" },
56+
{ "id": "property-6", "type": "properties" },
57+
{ "id": "property-7", "type": "properties" }
58+
]
59+
},
60+
"scheme": {
61+
"data": { "id": "scheme-1", "type": "schemes" }
62+
},
63+
"streets": { "data": [] },
64+
"teams": { "data": [] },
65+
"template": { "data": null },
66+
"work_order": {
67+
"data": { "id": "workorder-1", "type": "work-orders" }
68+
}
69+
},
70+
"meta": {
71+
"created_at": "2025-03-13T00:00:00.000Z",
72+
"updated_at": "2025-07-16T00:00:00.000Z",
73+
"counts": { "properties": 7, "streets": 0 }
74+
}
75+
},
76+
{
77+
"id": "operation-2",
78+
"type": "operations",
79+
"attributes": {
80+
"code": "",
81+
"dates": {
82+
"scheduled": {
83+
"start": "2025-08-15T07:00:00Z",
84+
"end": "2025-08-15T11:00:00Z"
85+
}
86+
},
87+
"description": "",
88+
"labels": [],
89+
"name": "Anonymised Operation 2",
90+
"requirements": {
91+
"forms": [
92+
{ "id": "form-1", "required": true }
93+
]
94+
}
95+
},
96+
"relationships": {
97+
"appointment": {
98+
"data": { "id": "appointment-1", "type": "appointments" }
99+
},
100+
"assignees": {
101+
"data": [ { "id": "user-4", "type": "users" } ]
102+
},
103+
"forms": {
104+
"data": [ { "id": "form-1", "type": "forms" } ]
105+
},
106+
"organisation": {
107+
"data": { "id": "org-1", "type": "organisations" }
108+
},
109+
"permits": { "data": [] },
110+
"properties": { "data": [] },
111+
"scheme": { "data": { "id": "scheme-2", "type": "schemes" } },
112+
"streets": { "data": [] },
113+
"teams": { "data": [] },
114+
"template": { "data": null },
115+
"work_order": { "data": { "id": "workorder-2", "type": "work-orders" } }
116+
},
117+
"meta": {
118+
"created_at": "2025-08-14T00:00:00.000Z",
119+
"updated_at": "2025-08-14T00:00:00.000Z",
120+
"counts": { "properties": 0, "streets": 0 }
121+
}
122+
},
123+
{
124+
"id": "operation-3",
125+
"type": "operations",
126+
"attributes": {
127+
"code": "",
128+
"dates": { "scheduled": {} },
129+
"description": "",
130+
"labels": [],
131+
"name": "Anonymised Operation 3",
132+
"requirements": {
133+
"forms": [ { "id": "form-1", "required": true } ]
134+
}
135+
},
136+
"relationships": {
137+
"appointment": { "data": null },
138+
"assignees": { "data": [ { "id": "user-4", "type": "users" } ] },
139+
"forms": { "data": [ { "id": "form-1", "type": "forms" } ] },
140+
"organisation": { "data": { "id": "org-1", "type": "organisations" } },
141+
"permits": { "data": [] },
142+
"properties": { "data": [] },
143+
"scheme": { "data": { "id": "scheme-2", "type": "schemes" } },
144+
"streets": { "data": [] },
145+
"teams": { "data": [] },
146+
"template": { "data": null },
147+
"work_order": { "data": { "id": "workorder-2", "type": "work-orders" } }
148+
},
149+
"meta": {
150+
"created_at": "2025-08-14T00:00:00.000Z",
151+
"updated_at": "2025-08-14T00:00:00.000Z",
152+
"counts": { "properties": 0, "streets": 0 }
153+
}
154+
}
155+
],
156+
"meta": {
157+
"pagination": {
158+
"current_page": 1,
159+
"counts": { "resources": 3, "pages": 1 },
160+
"requested": { "offset": 0, "limit": 100 },
161+
"offsets": { "previous": null, "next": null }
162+
},
163+
"features": {
164+
"params": {
165+
"include": {
166+
"options": [
167+
"appointment", "assignees", "forms", "organisation", "permits", "properties", "scheme", "streets", "teams", "template", "work_order"
168+
]
169+
},
170+
"sort": { "default": "", "options": null }
171+
}
172+
}
173+
},
174+
"jsonapi": { "version": "1.0" },
175+
"included": [
176+
{
177+
"id": "appointment-1",
178+
"type": "appointments",
179+
"attributes": {
180+
"animals": false,
181+
"end_time": "2025-08-15T11:00:00Z",
182+
"medical_dependency": false,
183+
"notes": "",
184+
"on_ecr": false,
185+
"on_ecr_notes": "",
186+
"start_time": "2025-08-15T07:00:00Z"
187+
},
188+
"relationships": {
189+
"author": { "data": { "id": "user-4", "type": "authors" } },
190+
"interaction": { "data": { "id": "interaction-1", "type": "customer-interactions" } },
191+
"operation": { "data": { "id": "operation-2", "type": "operations" } },
192+
"organisation": { "data": { "id": "org-1", "type": "organisations" } },
193+
"time_band": { "data": { "id": "timeband-1", "type": "time-bands" } }
194+
},
195+
"meta": {
196+
"created_at": "2025-08-14T00:00:00.000Z",
197+
"updated_at": "2025-08-14T00:00:00.000Z",
198+
"modified_at": "2025-08-14T00:00:00.000Z"
199+
}
200+
}
201+
]
202+
}
203+

0 commit comments

Comments
 (0)