Add endpoint deletion
The endpoints screen could only create and edit servers, so a mistyped or retired endpoint stayed in the list forever. Tasks reference endpoints without ON DELETE CASCADE, so a referenced endpoint is refused with 409 and a count of the tasks using it rather than cascading away migration history. The foreign-key violation is mapped to the same status to cover a task created between check and delete. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,11 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/vasyansk/imap-copier/internal/store"
|
"github.com/vasyansk/imap-copier/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,6 +57,36 @@ func (s *Server) handleUpdateEndpoint(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleDeleteEndpoint removes an endpoint that no task references. A referenced
|
||||||
|
// endpoint is refused with 409 rather than a foreign-key error, and the same
|
||||||
|
// status covers the race where a task is created between check and delete.
|
||||||
|
func (s *Server) handleDeleteEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := pathID(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "bad id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
used, err := s.store.CountTasksUsingEndpoint(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if used > 0 {
|
||||||
|
http.Error(w, fmt.Sprintf("endpoint is used by %d task(s) — delete them first", used), http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.DeleteEndpoint(r.Context(), id); err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23503" {
|
||||||
|
http.Error(w, "endpoint is used by a task — delete it first", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleListEndpoints(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListEndpoints(w http.ResponseWriter, r *http.Request) {
|
||||||
eps, err := s.store.ListEndpoints(r.Context())
|
eps, err := s.store.ListEndpoints(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
api.HandleFunc("GET /api/endpoints", s.handleListEndpoints)
|
api.HandleFunc("GET /api/endpoints", s.handleListEndpoints)
|
||||||
api.HandleFunc("POST /api/endpoints", s.handleCreateEndpoint)
|
api.HandleFunc("POST /api/endpoints", s.handleCreateEndpoint)
|
||||||
api.HandleFunc("PUT /api/endpoints/{id}", s.handleUpdateEndpoint)
|
api.HandleFunc("PUT /api/endpoints/{id}", s.handleUpdateEndpoint)
|
||||||
|
api.HandleFunc("DELETE /api/endpoints/{id}", s.handleDeleteEndpoint)
|
||||||
api.HandleFunc("GET /api/tasks", s.handleListTasks)
|
api.HandleFunc("GET /api/tasks", s.handleListTasks)
|
||||||
api.HandleFunc("POST /api/tasks", s.handleCreateTask)
|
api.HandleFunc("POST /api/tasks", s.handleCreateTask)
|
||||||
api.HandleFunc("GET /api/tasks/{id}", s.handleGetTask)
|
api.HandleFunc("GET /api/tasks/{id}", s.handleGetTask)
|
||||||
|
|||||||
@@ -18,6 +18,41 @@ func TestUpdateEndpoint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeleteEndpoint(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
id, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "src", Host: "a.com", Port: 993, TLSMode: "ssl"})
|
||||||
|
if err := s.DeleteEndpoint(ctx, id); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
eps, _ := s.ListEndpoints(ctx)
|
||||||
|
if len(eps) != 0 {
|
||||||
|
t.Fatalf("endpoint not deleted: %d remain", len(eps))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteEndpointUsedByTaskRefused(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
ep1, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "s", Host: "a", Port: 993, TLSMode: "ssl"})
|
||||||
|
ep2, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "d", Host: "b", Port: 993, TLSMode: "ssl"})
|
||||||
|
if _, err := s.CreateTask(ctx, Task{Name: "t", SrcEndpointID: ep1, DstEndpointID: ep2}); err != nil {
|
||||||
|
t.Fatalf("create task: %v", err)
|
||||||
|
}
|
||||||
|
for _, id := range []int64{ep1, ep2} {
|
||||||
|
n, err := s.CountTasksUsingEndpoint(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("count for ep %d = %d, want 1", id, n)
|
||||||
|
}
|
||||||
|
if err := s.DeleteEndpoint(ctx, id); err == nil {
|
||||||
|
t.Fatalf("delete of referenced endpoint %d succeeded, want FK violation", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeleteAccountCascadesJournal(t *testing.T) {
|
func TestDeleteAccountCascadesJournal(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -26,6 +26,23 @@ func (s *Store) UpdateEndpoint(ctx context.Context, e Endpoint) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountTasksUsingEndpoint reports how many tasks reference the endpoint on
|
||||||
|
// either side, so a delete can be refused with a meaningful message instead of
|
||||||
|
// surfacing a raw foreign-key violation.
|
||||||
|
func (s *Store) CountTasksUsingEndpoint(ctx context.Context, id int64) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := s.Pool.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM tasks WHERE src_endpoint_id=$1 OR dst_endpoint_id=$1`, id).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteEndpoint removes an endpoint. Tasks reference endpoints without ON
|
||||||
|
// DELETE CASCADE, so Postgres rejects the delete while any task still uses it.
|
||||||
|
func (s *Store) DeleteEndpoint(ctx context.Context, id int64) error {
|
||||||
|
_, err := s.Pool.Exec(ctx, `DELETE FROM endpoints WHERE id=$1`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) GetEndpoint(ctx context.Context, id int64) (Endpoint, error) {
|
func (s *Store) GetEndpoint(ctx context.Context, id int64) (Endpoint, error) {
|
||||||
var e Endpoint
|
var e Endpoint
|
||||||
err := s.Pool.QueryRow(ctx,
|
err := s.Pool.QueryRow(ctx,
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ export const updateEndpoint = (
|
|||||||
body: { role_label: string; host: string; port: number; tls_mode: TLSMode },
|
body: { role_label: string; host: string; port: number; tls_mode: TLSMode },
|
||||||
) => api(`/api/endpoints/${id}`, { ...jsonBody(body), method: 'PUT' })
|
) => api(`/api/endpoints/${id}`, { ...jsonBody(body), method: 'PUT' })
|
||||||
|
|
||||||
|
export const deleteEndpoint = (id: number) => api(`/api/endpoints/${id}`, { method: 'DELETE' })
|
||||||
|
|
||||||
export const deleteTask = (id: number) => api(`/api/tasks/${id}`, { method: 'DELETE' })
|
export const deleteTask = (id: number) => api(`/api/tasks/${id}`, { method: 'DELETE' })
|
||||||
|
|
||||||
export const deleteAccount = (taskId: number, accountId: number) =>
|
export const deleteAccount = (taskId: number, accountId: number) =>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState, type FormEvent } from 'react'
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
import { createEndpoint, listEndpoints, updateEndpoint, type Endpoint, type TLSMode } from '../api'
|
import { createEndpoint, deleteEndpoint, listEndpoints, updateEndpoint, type Endpoint, type TLSMode } from '../api'
|
||||||
|
import { useConfirm } from '../components/ConfirmProvider'
|
||||||
|
|
||||||
const emptyForm = { role_label: '', host: '', port: '993', tls_mode: 'ssl' as TLSMode }
|
const emptyForm = { role_label: '', host: '', port: '993', tls_mode: 'ssl' as TLSMode }
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ export function Endpoints() {
|
|||||||
const [editingId, setEditingId] = useState<number | null>(null)
|
const [editingId, setEditingId] = useState<number | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
const confirm = useConfirm()
|
||||||
|
|
||||||
function startEdit(ep: Endpoint) {
|
function startEdit(ep: Endpoint) {
|
||||||
setEditingId(ep.id)
|
setEditingId(ep.id)
|
||||||
@@ -29,6 +31,25 @@ export function Endpoints() {
|
|||||||
|
|
||||||
useEffect(reload, [])
|
useEffect(reload, [])
|
||||||
|
|
||||||
|
async function onDelete(ep: Endpoint) {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: 'Delete endpoint',
|
||||||
|
message: `Delete endpoint "${ep.role_label}" (${ep.host}:${ep.port})?`,
|
||||||
|
confirmLabel: 'Delete',
|
||||||
|
danger: true,
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await deleteEndpoint(ep.id)
|
||||||
|
// The form still edits a row that no longer exists — drop back to create mode.
|
||||||
|
if (editingId === ep.id) cancelEdit()
|
||||||
|
reload()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to delete endpoint')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submit(e: FormEvent) {
|
async function submit(e: FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
@@ -159,6 +180,9 @@ export function Endpoints() {
|
|||||||
<td className="num-cell">
|
<td className="num-cell">
|
||||||
<button type="button" className="link-btn" onClick={() => startEdit(ep)} disabled={busy}>
|
<button type="button" className="link-btn" onClick={() => startEdit(ep)} disabled={busy}>
|
||||||
edit
|
edit
|
||||||
|
</button>{' '}
|
||||||
|
<button type="button" className="link-btn danger" onClick={() => onDelete(ep)} disabled={busy}>
|
||||||
|
delete
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
Reference in New Issue
Block a user