diff --git a/backend/src/main/kotlin/com/opendatamask/controller/PostJobActionController.kt b/backend/src/main/kotlin/com/opendatamask/controller/PostJobActionController.kt index e752d95..b220fa8 100644 --- a/backend/src/main/kotlin/com/opendatamask/controller/PostJobActionController.kt +++ b/backend/src/main/kotlin/com/opendatamask/controller/PostJobActionController.kt @@ -1,5 +1,6 @@ package com.opendatamask.controller +import com.opendatamask.dto.PostJobActionRequest import com.opendatamask.model.PostJobAction import com.opendatamask.service.PostJobActionService import org.springframework.http.HttpStatus @@ -18,12 +19,25 @@ class PostJobActionController( @PostMapping fun createAction( @PathVariable workspaceId: Long, - @RequestBody action: PostJobAction + @RequestBody request: PostJobActionRequest ): ResponseEntity { - val toSave = action.copy(workspaceId = workspaceId) + val toSave = PostJobAction( + workspaceId = workspaceId, + actionType = request.actionType, + config = request.config, + enabled = request.enabled + ) return ResponseEntity.status(HttpStatus.CREATED).body(service.createAction(toSave)) } + @PutMapping("/{actionId}") + fun updateAction( + @PathVariable workspaceId: Long, + @PathVariable actionId: Long, + @RequestBody request: PostJobActionRequest + ): ResponseEntity = + ResponseEntity.ok(service.updateAction(workspaceId, actionId, request)) + @DeleteMapping("/{actionId}") fun deleteAction( @PathVariable workspaceId: Long, diff --git a/backend/src/main/kotlin/com/opendatamask/dto/PostJobActionDto.kt b/backend/src/main/kotlin/com/opendatamask/dto/PostJobActionDto.kt new file mode 100644 index 0000000..b916b38 --- /dev/null +++ b/backend/src/main/kotlin/com/opendatamask/dto/PostJobActionDto.kt @@ -0,0 +1,9 @@ +package com.opendatamask.dto + +import com.opendatamask.model.ActionType + +data class PostJobActionRequest( + val actionType: ActionType, + val config: String = "{}", + val enabled: Boolean = true +) diff --git a/backend/src/main/kotlin/com/opendatamask/service/PostJobActionService.kt b/backend/src/main/kotlin/com/opendatamask/service/PostJobActionService.kt index 892341d..b9d6ac3 100644 --- a/backend/src/main/kotlin/com/opendatamask/service/PostJobActionService.kt +++ b/backend/src/main/kotlin/com/opendatamask/service/PostJobActionService.kt @@ -103,5 +103,16 @@ class PostJobActionService( fun createAction(action: PostJobAction): PostJobAction = repository.save(action) fun listActions(workspaceId: Long): List = repository.findByWorkspaceId(workspaceId) + fun updateAction(workspaceId: Long, id: Long, request: com.opendatamask.dto.PostJobActionRequest): PostJobAction { + val existing = repository.findById(id) + .orElseThrow { NoSuchElementException("PostJobAction not found: $id") } + if (existing.workspaceId != workspaceId) { + throw NoSuchElementException("PostJobAction $id does not belong to workspace $workspaceId") + } + existing.actionType = request.actionType + existing.config = request.config + existing.enabled = request.enabled + return repository.save(existing) + } fun deleteAction(id: Long) = repository.deleteById(id) } diff --git a/backend/src/test/kotlin/com/opendatamask/controller/PostJobActionControllerTest.kt b/backend/src/test/kotlin/com/opendatamask/controller/PostJobActionControllerTest.kt index 2c5729a..7357051 100644 --- a/backend/src/test/kotlin/com/opendatamask/controller/PostJobActionControllerTest.kt +++ b/backend/src/test/kotlin/com/opendatamask/controller/PostJobActionControllerTest.kt @@ -2,6 +2,7 @@ package com.opendatamask.controller import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.opendatamask.dto.PostJobActionRequest import com.opendatamask.model.ActionType import com.opendatamask.model.PostJobAction import com.opendatamask.security.JwtAuthenticationFilter @@ -51,12 +52,13 @@ class PostJobActionControllerTest { @Test fun `POST create action returns 201`() { - val action = PostJobAction(workspaceId = 1L, actionType = ActionType.WEBHOOK, config = """{"url":"http://example.com"}""") - whenever(service.createAction(any())).thenReturn(action.copy(id = 1L)) + val request = PostJobActionRequest(actionType = ActionType.WEBHOOK, config = """{"url":"http://example.com"}""") + val saved = PostJobAction(id = 1L, workspaceId = 1L, actionType = ActionType.WEBHOOK, config = """{"url":"http://example.com"}""") + whenever(service.createAction(any())).thenReturn(saved) mockMvc.perform( post("/api/workspaces/1/actions") .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(action)) + .content(mapper.writeValueAsString(request)) ).andExpect(status().isCreated) } @@ -66,4 +68,30 @@ class PostJobActionControllerTest { .andExpect(status().isNoContent) verify(service).deleteAction(42L) } + + @Test + fun `PUT update action returns 200 with correct response body`() { + val request = PostJobActionRequest( + actionType = ActionType.WEBHOOK, + config = """{"url":"http://example.com"}""" + ) + val returned = PostJobAction( + id = 42L, + workspaceId = 1L, + actionType = ActionType.WEBHOOK, + config = """{"url":"http://example.com"}""" + ) + whenever(service.updateAction(eq(1L), eq(42L), any())).thenReturn(returned) + mockMvc.perform( + put("/api/workspaces/1/actions/42") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.id").value(42)) + .andExpect(jsonPath("$.workspaceId").value(1)) + .andExpect(jsonPath("$.actionType").value("WEBHOOK")) + .andExpect(jsonPath("$.config").value("""{"url":"http://example.com"}""")) + verify(service).updateAction(eq(1L), eq(42L), any()) + } } diff --git a/backend/src/test/kotlin/com/opendatamask/service/PostJobActionServiceTest.kt b/backend/src/test/kotlin/com/opendatamask/service/PostJobActionServiceTest.kt index 1a290cb..c987be6 100644 --- a/backend/src/test/kotlin/com/opendatamask/service/PostJobActionServiceTest.kt +++ b/backend/src/test/kotlin/com/opendatamask/service/PostJobActionServiceTest.kt @@ -1,5 +1,6 @@ package com.opendatamask.service +import com.opendatamask.dto.PostJobActionRequest import com.opendatamask.model.ActionType import com.opendatamask.model.Job import com.opendatamask.model.JobStatus @@ -7,6 +8,7 @@ import com.opendatamask.model.PostJobAction import com.opendatamask.repository.PostJobActionRepository import org.junit.jupiter.api.Test import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.assertThrows import org.mockito.kotlin.* import java.time.LocalDateTime @@ -68,4 +70,39 @@ class PostJobActionServiceTest { service.deleteAction(42L) verify(repository).deleteById(42L) } + + @Test + fun `updateAction updates fields and saves`() { + val existing = PostJobAction( + id = 1L, workspaceId = 1L, + actionType = ActionType.EMAIL, + config = """{"to":"old@example.com"}""" + ) + val request = PostJobActionRequest( + actionType = ActionType.WEBHOOK, + config = """{"url":"http://new.example.com"}""", + enabled = false + ) + whenever(repository.findById(1L)).thenReturn(java.util.Optional.of(existing)) + whenever(repository.save(any())).thenReturn(existing) + service.updateAction(1L, 1L, request) + verify(repository).save(existing) + assertEquals(ActionType.WEBHOOK, existing.actionType) + assertEquals("""{"url":"http://new.example.com"}""", existing.config) + assertEquals(false, existing.enabled) + } + + @Test + fun `updateAction throws when action belongs to different workspace`() { + val existing = PostJobAction( + id = 1L, workspaceId = 99L, + actionType = ActionType.EMAIL, + config = """{"to":"other@example.com"}""" + ) + whenever(repository.findById(1L)).thenReturn(java.util.Optional.of(existing)) + assertThrows { + service.updateAction(1L, 1L, PostJobActionRequest(actionType = ActionType.EMAIL, config = "{}")) + } + verify(repository, never()).save(any()) + } } diff --git a/frontend/src/api/actions.ts b/frontend/src/api/actions.ts new file mode 100644 index 0000000..bc42c43 --- /dev/null +++ b/frontend/src/api/actions.ts @@ -0,0 +1,34 @@ +import apiClient from './client' +import type { PostJobAction, PostJobActionRequest } from '@/types' + +export async function listActions(workspaceId: number): Promise { + const { data } = await apiClient.get(`/workspaces/${workspaceId}/actions`) + return data +} + +export async function createAction( + workspaceId: number, + payload: PostJobActionRequest +): Promise { + const { data } = await apiClient.post( + `/workspaces/${workspaceId}/actions`, + payload + ) + return data +} + +export async function updateAction( + workspaceId: number, + actionId: number, + payload: PostJobActionRequest +): Promise { + const { data } = await apiClient.put( + `/workspaces/${workspaceId}/actions/${actionId}`, + payload + ) + return data +} + +export async function deleteAction(workspaceId: number, actionId: number): Promise { + await apiClient.delete(`/workspaces/${workspaceId}/actions/${actionId}`) +} diff --git a/frontend/src/components/AppModal.vue b/frontend/src/components/AppModal.vue new file mode 100644 index 0000000..07d77a8 --- /dev/null +++ b/frontend/src/components/AppModal.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index ca8139f..9310537 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -8,6 +8,7 @@ import WorkspaceDetailView from '@/views/WorkspaceDetailView.vue' import ConnectionsView from '@/views/ConnectionsView.vue' import TablesView from '@/views/TablesView.vue' import JobsView from '@/views/JobsView.vue' +import ActionsView from '@/views/ActionsView.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -60,6 +61,12 @@ const router = createRouter({ name: 'jobs', component: JobsView, meta: { requiresAuth: true } + }, + { + path: '/workspaces/:id/actions', + name: 'actions', + component: ActionsView, + meta: { requiresAuth: true } } ] }) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index eacf1d6..6248537 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -204,6 +204,29 @@ export interface JobLog { timestamp: string } +// ── Post-Job Action ─────────────────────────────────────────────────────── + +export enum ActionType { + WEBHOOK = 'WEBHOOK', + EMAIL = 'EMAIL', + SCRIPT = 'SCRIPT' +} + +export interface PostJobAction { + id: number + workspaceId: number + actionType: ActionType + config: string + enabled: boolean + createdAt: string +} + +export interface PostJobActionRequest { + actionType: ActionType + config: string + enabled?: boolean +} + // ── Pagination ──────────────────────────────────────────────────────────── export interface Page { diff --git a/frontend/src/views/ActionsView.vue b/frontend/src/views/ActionsView.vue new file mode 100644 index 0000000..c371bfc --- /dev/null +++ b/frontend/src/views/ActionsView.vue @@ -0,0 +1,285 @@ + + +