diff --git a/internal/httpapi/endpoints.go b/internal/httpapi/endpoints.go index 74a997e..078a99d 100644 --- a/internal/httpapi/endpoints.go +++ b/internal/httpapi/endpoints.go @@ -2,8 +2,11 @@ package httpapi import ( "encoding/json" + "errors" + "fmt" "net/http" + "github.com/jackc/pgx/v5/pgconn" "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) } +// 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) { eps, err := s.store.ListEndpoints(r.Context()) if err != nil { diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index ffad46d..5b11755 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -15,6 +15,7 @@ func (s *Server) Router() http.Handler { api.HandleFunc("GET /api/endpoints", s.handleListEndpoints) api.HandleFunc("POST /api/endpoints", s.handleCreateEndpoint) 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("POST /api/tasks", s.handleCreateTask) api.HandleFunc("GET /api/tasks/{id}", s.handleGetTask) diff --git a/internal/store/crud_test.go b/internal/store/crud_test.go index fdf3392..baaa37b 100644 --- a/internal/store/crud_test.go +++ b/internal/store/crud_test.go @@ -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) { s := testStore(t) ctx := context.Background() diff --git a/internal/store/endpoints.go b/internal/store/endpoints.go index f09e52c..0b1d674 100644 --- a/internal/store/endpoints.go +++ b/internal/store/endpoints.go @@ -26,6 +26,23 @@ func (s *Store) UpdateEndpoint(ctx context.Context, e Endpoint) error { 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) { var e Endpoint err := s.Pool.QueryRow(ctx, diff --git a/web/src/api.ts b/web/src/api.ts index db5c1cd..d86e583 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -82,6 +82,8 @@ export const updateEndpoint = ( body: { role_label: string; host: string; port: number; tls_mode: TLSMode }, ) => 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 deleteAccount = (taskId: number, accountId: number) => diff --git a/web/src/pages/Endpoints.tsx b/web/src/pages/Endpoints.tsx index bf1b311..b6759c1 100644 --- a/web/src/pages/Endpoints.tsx +++ b/web/src/pages/Endpoints.tsx @@ -1,5 +1,6 @@ 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 } @@ -9,6 +10,7 @@ export function Endpoints() { const [editingId, setEditingId] = useState(null) const [error, setError] = useState(null) const [busy, setBusy] = useState(false) + const confirm = useConfirm() function startEdit(ep: Endpoint) { setEditingId(ep.id) @@ -29,6 +31,25 @@ export function Endpoints() { 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) { e.preventDefault() setBusy(true) @@ -159,6 +180,9 @@ export function Endpoints() { {' '} +