From 643ee776000cc9eec82ad9c819fd2978d21eeb99 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sat, 25 Apr 2026 23:37:03 +0200 Subject: [PATCH] feat(merge-plan): convert to async polling to bypass nginx 60s timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /admin/markets/:id/merge-plan now returns 202 + job_id immediately and runs the Gemini advisor in a detached goroutine. Frontend polls GET .../merge-plan/:job_id until done, with backoff up to 3 minutes. Adds in-memory job registry (keyed map + RWMutex, 5-min TTL sweep) and handler tests covering the full pending→done and error paths. --- .../internal/domain/market/admin_handler.go | 148 +++++++-- .../domain/market/admin_handler_test.go | 285 ++++++++++++++++++ backend/internal/domain/market/routes.go | 1 + .../routes/admin/maerkte/[id]/+page.svelte | 55 +++- .../admin/maerkte/[id]/merge-plan/+server.ts | 5 +- .../[id]/merge-plan/[jobId]/+server.ts | 23 ++ 6 files changed, 484 insertions(+), 33 deletions(-) create mode 100644 backend/internal/domain/market/admin_handler_test.go create mode 100644 web/src/routes/admin/maerkte/[id]/merge-plan/[jobId]/+server.ts diff --git a/backend/internal/domain/market/admin_handler.go b/backend/internal/domain/market/admin_handler.go index 1620e65..a8ff00b 100644 --- a/backend/internal/domain/market/admin_handler.go +++ b/backend/internal/domain/market/admin_handler.go @@ -7,6 +7,7 @@ import ( "log/slog" "net/http" "strings" + "sync" "time" "github.com/gin-gonic/gin" @@ -19,14 +20,61 @@ import ( const duplicateClassifyTopN = 5 +const mergeJobTTL = 5 * time.Minute + +type mergeJobStatus string + +const ( + mergeJobPending mergeJobStatus = "pending" + mergeJobDone mergeJobStatus = "done" + mergeJobError mergeJobStatus = "error" +) + +type mergeJob struct { + sourceID uuid.UUID + targetID uuid.UUID + status mergeJobStatus + proposal *MarketMergeProposal + err string + startedAt time.Time + finishedAt time.Time +} + type AdminHandler struct { service *Service classifier enrich.SimilarityClassifier advisor *MergeAdvisor + // proposeFn and getMarketFn are overridable in tests. + proposeFn func(ctx context.Context, a, b Market, v enrich.Verdict) (MarketMergeProposal, error) + getMarketFn func(ctx context.Context, id uuid.UUID) (Market, error) + + mergeJobsMu sync.RWMutex + mergeJobs map[uuid.UUID]*mergeJob } func NewAdminHandler(service *Service, classifier enrich.SimilarityClassifier, advisor *MergeAdvisor) *AdminHandler { - return &AdminHandler{service: service, classifier: classifier, advisor: advisor} + h := &AdminHandler{ + service: service, + classifier: classifier, + advisor: advisor, + mergeJobs: make(map[uuid.UUID]*mergeJob), + getMarketFn: service.GetByID, + } + if advisor != nil { + h.proposeFn = advisor.Propose + } + return h +} + +// sweepMergeJobs removes finished jobs older than mergeJobTTL. +// Caller must hold mergeJobsMu (write lock). +func (h *AdminHandler) sweepMergeJobs() { + now := time.Now() + for id, job := range h.mergeJobs { + if !job.finishedAt.IsZero() && now.Sub(job.finishedAt) > mergeJobTTL { + delete(h.mergeJobs, id) + } + } } func (h *AdminHandler) List(c *gin.Context) { //nolint:dupl @@ -278,10 +326,10 @@ type MergePlanRequest struct { TargetID uuid.UUID `json:"target_id" binding:"required"` } -// MergePlan generates a MarketMergeProposal for two editions without persisting anything. +// MergePlan validates both markets, registers an async job, and returns 202 +// immediately. The client polls GET .../merge-plan/:job_id for the result. func (h *AdminHandler) MergePlan(c *gin.Context) { - ctx, cancel := context.WithTimeout(c.Request.Context(), 110*time.Second) - defer cancel() + ctx := c.Request.Context() sourceID, err := uuid.Parse(c.Param("id")) if err != nil { @@ -299,7 +347,12 @@ func (h *AdminHandler) MergePlan(c *gin.Context) { return } - sourceM, err := h.service.GetByID(ctx, sourceID) + if h.proposeFn == nil { + c.JSON(http.StatusServiceUnavailable, apierror.NewResponse(apierror.Internal("merge advisor not configured"))) + return + } + + sourceM, err := h.getMarketFn(ctx, sourceID) if err != nil { if errors.Is(err, ErrMarketNotFound) { c.JSON(http.StatusNotFound, apierror.NewResponse(apierror.NotFound("market"))) @@ -308,7 +361,7 @@ func (h *AdminHandler) MergePlan(c *gin.Context) { } return } - targetM, err := h.service.GetByID(ctx, req.TargetID) + targetM, err := h.getMarketFn(ctx, req.TargetID) if err != nil { if errors.Is(err, ErrMarketNotFound) { c.JSON(http.StatusNotFound, apierror.NewResponse(apierror.NotFound("target market"))) @@ -320,23 +373,80 @@ func (h *AdminHandler) MergePlan(c *gin.Context) { verdict := classifyPair(c, h.classifier, sourceM, targetM) - if h.advisor == nil { - c.JSON(http.StatusServiceUnavailable, apierror.NewResponse(apierror.Internal("merge advisor not configured"))) - return + jobID := uuid.New() + job := &mergeJob{ + sourceID: sourceID, + targetID: req.TargetID, + status: mergeJobPending, + startedAt: time.Now().UTC(), } - proposal, err := h.advisor.Propose(ctx, sourceM, targetM, verdict) - if err != nil { - if errors.Is(err, ErrNotDuplicate) { - c.JSON(http.StatusConflict, apierror.NewResponse(apierror.BadRequest("not_duplicate", "Markt wird nicht als Duplikat klassifiziert"))) - return + h.mergeJobsMu.Lock() + h.sweepMergeJobs() + h.mergeJobs[jobID] = job + h.mergeJobsMu.Unlock() + + go h.runMergePlanAsync(jobID, sourceM, targetM, verdict) + + c.JSON(http.StatusAccepted, gin.H{"data": gin.H{"job_id": jobID, "status": "pending"}}) +} + +// runMergePlanAsync calls the advisor in the background and stores the result. +// Uses a detached context so a cancelled HTTP connection does not abort the job. +func (h *AdminHandler) runMergePlanAsync(jobID uuid.UUID, sourceM, targetM Market, verdict enrich.Verdict) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + proposal, err := h.proposeFn(ctx, sourceM, targetM, verdict) + + h.mergeJobsMu.Lock() + job, ok := h.mergeJobs[jobID] + if ok { + job.finishedAt = time.Now().UTC() + if err != nil { + job.status = mergeJobError + job.err = err.Error() + slog.ErrorContext(ctx, "merge-plan async: advisor failed", + "job_id", jobID, "source_id", sourceM.ID, "target_id", targetM.ID, "err", err) + } else { + job.status = mergeJobDone + job.proposal = &proposal } - slog.ErrorContext(ctx, "merge-plan: advisor failed", "source_id", sourceID, "target_id", req.TargetID, "err", err) - c.JSON(http.StatusInternalServerError, apierror.NewResponse(apierror.Internal("Merge-Plan konnte nicht erstellt werden"))) + } + h.mergeJobsMu.Unlock() +} + +// MergePlanStatus polls the outcome of an async merge-plan job. +// Returns 202 while pending, 200 when done or errored. +func (h *AdminHandler) MergePlanStatus(c *gin.Context) { + sourceID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, apierror.NewResponse(apierror.BadRequest("invalid_id", "invalid market ID"))) + return + } + jobID, err := uuid.Parse(c.Param("job_id")) + if err != nil { + c.JSON(http.StatusBadRequest, apierror.NewResponse(apierror.BadRequest("invalid_job_id", "invalid job ID"))) return } - c.JSON(http.StatusOK, gin.H{"data": proposal}) + h.mergeJobsMu.RLock() + job, ok := h.mergeJobs[jobID] + h.mergeJobsMu.RUnlock() + + if !ok || job.sourceID != sourceID { + c.JSON(http.StatusNotFound, apierror.NewResponse(apierror.NotFound("merge job"))) + return + } + + switch job.status { + case mergeJobPending: + c.JSON(http.StatusAccepted, gin.H{"data": gin.H{"status": "pending"}}) + case mergeJobDone: + c.JSON(http.StatusOK, gin.H{"data": gin.H{"status": "done", "proposal": job.proposal}}) + case mergeJobError: + c.JSON(http.StatusOK, gin.H{"data": gin.H{"status": "error", "error": job.err}}) + } } // MergeIntoRequest is the body for the merge-into endpoint. @@ -390,12 +500,12 @@ func (h *AdminHandler) MergeInto(c *gin.Context) { if req.Proposal != nil { proposal = *req.Proposal } else { - if h.advisor == nil { + if h.proposeFn == nil { c.JSON(http.StatusBadRequest, apierror.NewResponse(apierror.BadRequest("no_proposal", "proposal required when advisor not configured"))) return } verdict := classifyPair(c, h.classifier, sourceM, targetM) - proposal, err = h.advisor.Propose(ctx, sourceM, targetM, verdict) + proposal, err = h.proposeFn(ctx, sourceM, targetM, verdict) if err != nil { if errors.Is(err, ErrNotDuplicate) { c.JSON(http.StatusConflict, apierror.NewResponse(apierror.BadRequest("not_duplicate", "Markt wird nicht als Duplikat klassifiziert"))) diff --git a/backend/internal/domain/market/admin_handler_test.go b/backend/internal/domain/market/admin_handler_test.go new file mode 100644 index 0000000..cf56339 --- /dev/null +++ b/backend/internal/domain/market/admin_handler_test.go @@ -0,0 +1,285 @@ +package market + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "marktvogt.de/backend/internal/domain/discovery/enrich" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +// blockingProposer gates the propose call until its release channel is closed. +type blockingProposer struct { + started chan struct{} + release chan struct{} + result MarketMergeProposal + err error +} + +func (b *blockingProposer) propose(_ context.Context, _, _ Market, _ enrich.Verdict) (MarketMergeProposal, error) { + close(b.started) + <-b.release + return b.result, b.err +} + +// waitFor polls cond every 2 ms until it returns true or the deadline passes. +func waitFor(t *testing.T, deadline time.Duration, cond func() bool) { + t.Helper() + end := time.Now().Add(deadline) + for time.Now().Before(end) { + if cond() { + return + } + time.Sleep(2 * time.Millisecond) + } + t.Fatal("condition not met within deadline") +} + +// newTestHandler builds a minimal AdminHandler with injected function stubs. +func newTestHandler( + markets map[uuid.UUID]Market, + proposeFn func(context.Context, Market, Market, enrich.Verdict) (MarketMergeProposal, error), +) *AdminHandler { + return &AdminHandler{ + mergeJobs: make(map[uuid.UUID]*mergeJob), + proposeFn: proposeFn, + getMarketFn: func(_ context.Context, id uuid.UUID) (Market, error) { + m, ok := markets[id] + if !ok { + return Market{}, ErrMarketNotFound + } + return m, nil + }, + } +} + +func doKickoff(t *testing.T, h *AdminHandler, sourceID, targetID uuid.UUID) *httptest.ResponseRecorder { + t.Helper() + body := `{"target_id":"` + targetID.String() + `"}` + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, + "/admin/markets/"+sourceID.String()+"/merge-plan", + strings.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + c.Params = gin.Params{{Key: "id", Value: sourceID.String()}} + h.MergePlan(c) + return w +} + +func doPoll(t *testing.T, h *AdminHandler, sourceID, jobID uuid.UUID) *httptest.ResponseRecorder { + t.Helper() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, + "/admin/markets/"+sourceID.String()+"/merge-plan/"+jobID.String(), nil) + c.Params = gin.Params{ + {Key: "id", Value: sourceID.String()}, + {Key: "job_id", Value: jobID.String()}, + } + h.MergePlanStatus(c) + return w +} + +// TestMergePlanKickoffAndPolling verifies: +// - POST returns 202 + job_id immediately +// - GET returns 202 pending while goroutine is blocked +// - GET returns 200 done with proposal once goroutine unblocks +func TestMergePlanKickoffAndPolling(t *testing.T) { + sourceID := uuid.New() + targetID := uuid.New() + want := MarketMergeProposal{TargetID: targetID, Summary: "test proposal"} + + bp := &blockingProposer{ + started: make(chan struct{}), + release: make(chan struct{}), + result: want, + } + h := newTestHandler( + map[uuid.UUID]Market{sourceID: {ID: sourceID}, targetID: {ID: targetID}}, + bp.propose, + ) + + w := doKickoff(t, h, sourceID, targetID) + if w.Code != http.StatusAccepted { + t.Fatalf("kickoff: expected 202, got %d body=%s", w.Code, w.Body.String()) + } + var kickoffResp struct { + Data struct { + JobID string `json:"job_id"` + Status string `json:"status"` + } `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &kickoffResp); err != nil { + t.Fatalf("kickoff: decode body: %v", err) + } + if kickoffResp.Data.Status != "pending" { + t.Errorf("kickoff status: want pending, got %q", kickoffResp.Data.Status) + } + jobID, err := uuid.Parse(kickoffResp.Data.JobID) + if err != nil { + t.Fatalf("kickoff: invalid job_id %q: %v", kickoffResp.Data.JobID, err) + } + + // Wait until goroutine has started (release gate is held). + <-bp.started + + // Status while goroutine is still blocked → 202 pending. + wp := doPoll(t, h, sourceID, jobID) + if wp.Code != http.StatusAccepted { + t.Errorf("poll pending: expected 202, got %d body=%s", wp.Code, wp.Body.String()) + } + + // Unblock the goroutine. + close(bp.release) + + // Wait for job to be marked done. + waitFor(t, 2*time.Second, func() bool { + h.mergeJobsMu.RLock() + defer h.mergeJobsMu.RUnlock() + j, ok := h.mergeJobs[jobID] + return ok && j.status == mergeJobDone + }) + + // Status after completion → 200 done with proposal. + wd := doPoll(t, h, sourceID, jobID) + if wd.Code != http.StatusOK { + t.Fatalf("poll done: expected 200, got %d body=%s", wd.Code, wd.Body.String()) + } + var doneResp struct { + Data struct { + Status string `json:"status"` + Proposal MarketMergeProposal `json:"proposal"` + } `json:"data"` + } + if err := json.Unmarshal(wd.Body.Bytes(), &doneResp); err != nil { + t.Fatalf("poll done: decode body: %v", err) + } + if doneResp.Data.Status != "done" { + t.Errorf("poll done: status want done, got %q", doneResp.Data.Status) + } + if doneResp.Data.Proposal.Summary != want.Summary { + t.Errorf("poll done: proposal.summary want %q, got %q", want.Summary, doneResp.Data.Proposal.Summary) + } +} + +// TestMergePlanAdvisorErrorSurfacedAsStatusError verifies that advisor failures +// reach the polling client as status=error (not a 5xx from the status endpoint). +func TestMergePlanAdvisorErrorSurfacedAsStatusError(t *testing.T) { + sourceID := uuid.New() + targetID := uuid.New() + + called := make(chan struct{}) + errFn := func(_ context.Context, _, _ Market, _ enrich.Verdict) (MarketMergeProposal, error) { + close(called) + return MarketMergeProposal{}, ErrNotDuplicate + } + h := newTestHandler( + map[uuid.UUID]Market{sourceID: {ID: sourceID}, targetID: {ID: targetID}}, + errFn, + ) + + w := doKickoff(t, h, sourceID, targetID) + if w.Code != http.StatusAccepted { + t.Fatalf("kickoff: expected 202, got %d", w.Code) + } + var kickoffResp struct { + Data struct { + JobID string `json:"job_id"` + } `json:"data"` + } + json.Unmarshal(w.Body.Bytes(), &kickoffResp) //nolint:errcheck + jobID, _ := uuid.Parse(kickoffResp.Data.JobID) + + <-called + waitFor(t, 2*time.Second, func() bool { + h.mergeJobsMu.RLock() + defer h.mergeJobsMu.RUnlock() + j, ok := h.mergeJobs[jobID] + return ok && j.status == mergeJobError + }) + + we := doPoll(t, h, sourceID, jobID) + if we.Code != http.StatusOK { + t.Fatalf("poll error: expected 200, got %d body=%s", we.Code, we.Body.String()) + } + var errResp struct { + Data struct { + Status string `json:"status"` + Error string `json:"error"` + } `json:"data"` + } + json.Unmarshal(we.Body.Bytes(), &errResp) //nolint:errcheck + if errResp.Data.Status != "error" { + t.Errorf("want status=error, got %q", errResp.Data.Status) + } + if errResp.Data.Error == "" { + t.Error("want non-empty error message") + } +} + +// TestMergePlanStatusUnknownJobReturns404 verifies that a stale or fabricated +// job_id yields 404. +func TestMergePlanStatusUnknownJobReturns404(t *testing.T) { + h := newTestHandler(nil, nil) + sourceID := uuid.New() + jobID := uuid.New() // never registered + + w := doPoll(t, h, sourceID, jobID) + if w.Code != http.StatusNotFound { + t.Errorf("expected 404 for unknown job, got %d", w.Code) + } +} + +// TestMergePlanStatusWrongMarketReturns404 verifies that a valid job_id with a +// mismatched source market ID yields 404 (prevents cross-market leakage). +func TestMergePlanStatusWrongMarketReturns404(t *testing.T) { + h := newTestHandler(nil, nil) + realSourceID := uuid.New() + wrongSourceID := uuid.New() + jobID := uuid.New() + + h.mergeJobsMu.Lock() + h.mergeJobs[jobID] = &mergeJob{ + sourceID: realSourceID, + status: mergeJobDone, + finishedAt: time.Now(), + } + h.mergeJobsMu.Unlock() + + w := doPoll(t, h, wrongSourceID, jobID) + if w.Code != http.StatusNotFound { + t.Errorf("expected 404 for mismatched source, got %d", w.Code) + } +} + +// TestMergePlanTTLSweepRemovesOldJobs verifies that finished jobs older than +// mergeJobTTL are removed from the map on the next sweep. +func TestMergePlanTTLSweepRemovesOldJobs(t *testing.T) { + h := newTestHandler(nil, nil) + oldJobID := uuid.New() + + h.mergeJobsMu.Lock() + h.mergeJobs[oldJobID] = &mergeJob{ + status: mergeJobDone, + finishedAt: time.Now().Add(-(mergeJobTTL + time.Second)), + } + h.sweepMergeJobs() + _, stillPresent := h.mergeJobs[oldJobID] + h.mergeJobsMu.Unlock() + + if stillPresent { + t.Error("expected expired job to be removed by TTL sweep") + } +} diff --git a/backend/internal/domain/market/routes.go b/backend/internal/domain/market/routes.go index 578b28d..131ebb3 100644 --- a/backend/internal/domain/market/routes.go +++ b/backend/internal/domain/market/routes.go @@ -31,6 +31,7 @@ func RegisterAdminRoutes(rg *gin.RouterGroup, h *AdminHandler, rh *ResearchHandl markets.POST("/:id/research/apply", rh.Apply) markets.GET("/:id/duplicates", h.FindDuplicates) markets.POST("/:id/merge-plan", h.MergePlan) + markets.GET("/:id/merge-plan/:job_id", h.MergePlanStatus) markets.POST("/:id/merge-into/:target_id", h.MergeInto) } diff --git a/web/src/routes/admin/maerkte/[id]/+page.svelte b/web/src/routes/admin/maerkte/[id]/+page.svelte index 34d28c6..1feb3c9 100644 --- a/web/src/routes/admin/maerkte/[id]/+page.svelte +++ b/web/src/routes/admin/maerkte/[id]/+page.svelte @@ -19,27 +19,60 @@ let mergeError: string | null = $state(null); let applying = $state(false); + async function readJSON(res: Response): Promise<{ error?: string; [k: string]: unknown }> { + const text = await res.text(); + try { + return text ? JSON.parse(text) : {}; + } catch { + return {}; + } + } + + async function pollMergePlan( + marketId: string, + jobId: string, + timeoutMs: number + ): Promise<{ status: string; proposal?: MarketMergeProposal; error?: string }> { + const deadline = Date.now() + timeoutMs; + let intervalMs = 1500; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, intervalMs)); + const res = await fetch(`/admin/maerkte/${marketId}/merge-plan/${jobId}`); + const body = await readJSON(res); + if (!res.ok) { + return { status: 'error', error: (body.error as string) ?? `HTTP ${res.status}` }; + } + const status = body.status as string; + if (status === 'done' || status === 'error') { + return body as { status: string; proposal?: MarketMergeProposal; error?: string }; + } + intervalMs = Math.min(intervalMs * 1.3, 4000); + } + return { status: 'error', error: 'Merge-Plan: Zeitüberschreitung beim Polling.' }; + } + async function handlePlan(candidate: DuplicateMarket) { mergeError = null; planningId = candidate.id; try { - const res = await fetch(`/admin/maerkte/${data.market.id}/merge-plan`, { + const startRes = await fetch(`/admin/maerkte/${data.market.id}/merge-plan`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target_id: candidate.id }) }); - const text = await res.text(); - let body: { error?: string; [k: string]: unknown } = {}; - try { - body = text ? JSON.parse(text) : {}; - } catch { - // non-JSON body - } - if (!res.ok) { - mergeError = body.error ?? `Merge-Plan fehlgeschlagen (HTTP ${res.status}).`; + const startBody = await readJSON(startRes); + if (!startRes.ok || !startBody.job_id) { + mergeError = + (startBody.error as string) ?? `Merge-Plan fehlgeschlagen (HTTP ${startRes.status}).`; return; } - proposal = body as unknown as MarketMergeProposal; + + const result = await pollMergePlan(data.market.id, startBody.job_id as string, 180_000); + if (result.status === 'error') { + mergeError = result.error ?? 'Merge-Plan fehlgeschlagen.'; + return; + } + proposal = result.proposal as MarketMergeProposal; proposalCandidate = candidate; } catch (err) { mergeError = err instanceof Error ? err.message : 'Merge-Plan fehlgeschlagen.'; diff --git a/web/src/routes/admin/maerkte/[id]/merge-plan/+server.ts b/web/src/routes/admin/maerkte/[id]/merge-plan/+server.ts index 7909281..ad0064e 100644 --- a/web/src/routes/admin/maerkte/[id]/merge-plan/+server.ts +++ b/web/src/routes/admin/maerkte/[id]/merge-plan/+server.ts @@ -1,17 +1,16 @@ import { json } from '@sveltejs/kit'; import { serverFetch } from '$lib/api/client.server.js'; -import type { MarketMergeProposal } from '$lib/api/types.js'; import type { RequestHandler } from './$types.js'; export const POST: RequestHandler = async ({ cookies, params, request }) => { try { const body = (await request.json()) as { target_id: string }; - const res = await serverFetch( + const res = await serverFetch<{ job_id: string; status: string }>( `/admin/markets/${params.id}/merge-plan`, cookies, { method: 'POST', body: JSON.stringify(body) } ); - return json(res.data); + return json(res.data, { status: 202 }); } catch (err) { const message = err instanceof Error ? err.message : 'Merge-Plan fehlgeschlagen.'; return json({ error: message }, { status: 502 }); diff --git a/web/src/routes/admin/maerkte/[id]/merge-plan/[jobId]/+server.ts b/web/src/routes/admin/maerkte/[id]/merge-plan/[jobId]/+server.ts new file mode 100644 index 0000000..15c5466 --- /dev/null +++ b/web/src/routes/admin/maerkte/[id]/merge-plan/[jobId]/+server.ts @@ -0,0 +1,23 @@ +import { json } from '@sveltejs/kit'; +import { serverFetch } from '$lib/api/client.server.js'; +import type { MarketMergeProposal } from '$lib/api/types.js'; +import type { RequestHandler } from './$types.js'; + +type MergePlanStatusData = + | { status: 'pending' } + | { status: 'done'; proposal: MarketMergeProposal } + | { status: 'error'; error: string }; + +export const GET: RequestHandler = async ({ cookies, params }) => { + try { + const res = await serverFetch( + `/admin/markets/${params.id}/merge-plan/${params.jobId}`, + cookies + ); + const httpStatus = res.data.status === 'pending' ? 202 : 200; + return json(res.data, { status: httpStatus }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Merge-Plan-Status fehlgeschlagen.'; + return json({ error: message }, { status: 502 }); + } +};