From 21f3acadf0fe335413861e06c04a4e22d8597856 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Sun, 5 Apr 2026 23:31:12 +0300 Subject: [PATCH] feat: add PATCH /tags/{id}/rules/{then_id} to activate/deactivate rules - openapi.yaml: new PATCH endpoint with is_active body, returns TagRule - backend/service: SetRuleActive calls repo.SetActive then returns updated rule - backend/handler: PatchRule validates body and delegates to service - backend/router: register PATCH /:tag_id/rules/:then_tag_id - frontend: TagRuleEditor uses PATCH instead of delete+recreate - mock: handle PATCH /tags/{id}/rules/{then_id} Co-Authored-By: Claude Sonnet 4.6 --- backend/internal/handler/router.go | 1 + backend/internal/handler/tag_handler.go | 33 +++++++++++++++++++ backend/internal/service/tag_service.go | 17 ++++++++++ .../lib/components/tag/TagRuleEditor.svelte | 8 ++--- frontend/vite-mock-plugin.ts | 13 ++++++++ openapi.yaml | 31 +++++++++++++++++ 6 files changed, 97 insertions(+), 6 deletions(-) diff --git a/backend/internal/handler/router.go b/backend/internal/handler/router.go index 74134a0..f465a38 100644 --- a/backend/internal/handler/router.go +++ b/backend/internal/handler/router.go @@ -93,6 +93,7 @@ func NewRouter( tags.GET("/:tag_id/rules", tagHandler.ListRules) tags.POST("/:tag_id/rules", tagHandler.CreateRule) + tags.PATCH("/:tag_id/rules/:then_tag_id", tagHandler.PatchRule) tags.DELETE("/:tag_id/rules/:then_tag_id", tagHandler.DeleteRule) } diff --git a/backend/internal/handler/tag_handler.go b/backend/internal/handler/tag_handler.go index 3239689..59bc1f7 100644 --- a/backend/internal/handler/tag_handler.go +++ b/backend/internal/handler/tag_handler.go @@ -354,6 +354,39 @@ func (h *TagHandler) CreateRule(c *gin.Context) { respondJSON(c, http.StatusCreated, toTagRuleJSON(*rule)) } +// --------------------------------------------------------------------------- +// PATCH /tags/:tag_id/rules/:then_tag_id +// --------------------------------------------------------------------------- + +func (h *TagHandler) PatchRule(c *gin.Context) { + whenTagID, ok := parseTagID(c) + if !ok { + return + } + + thenTagID, err := uuid.Parse(c.Param("then_tag_id")) + if err != nil { + respondError(c, domain.ErrValidation) + return + } + + var body struct { + IsActive *bool `json:"is_active"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.IsActive == nil { + respondError(c, domain.ErrValidation) + return + } + + rule, err := h.tagSvc.SetRuleActive(c.Request.Context(), whenTagID, thenTagID, *body.IsActive) + if err != nil { + respondError(c, err) + return + } + + respondJSON(c, http.StatusOK, toTagRuleJSON(*rule)) +} + // --------------------------------------------------------------------------- // DELETE /tags/:tag_id/rules/:then_tag_id // --------------------------------------------------------------------------- diff --git a/backend/internal/service/tag_service.go b/backend/internal/service/tag_service.go index 311f1db..c0959e4 100644 --- a/backend/internal/service/tag_service.go +++ b/backend/internal/service/tag_service.go @@ -191,6 +191,23 @@ func (s *TagService) CreateRule(ctx context.Context, whenTagID, thenTagID uuid.U }) } +// SetRuleActive toggles a rule's is_active flag and returns the updated rule. +func (s *TagService) SetRuleActive(ctx context.Context, whenTagID, thenTagID uuid.UUID, active bool) (*domain.TagRule, error) { + if err := s.rules.SetActive(ctx, whenTagID, thenTagID, active); err != nil { + return nil, err + } + rules, err := s.rules.ListByTag(ctx, whenTagID) + if err != nil { + return nil, err + } + for _, r := range rules { + if r.ThenTagID == thenTagID { + return &r, nil + } + } + return nil, domain.ErrNotFound +} + // DeleteRule removes a tag rule. func (s *TagService) DeleteRule(ctx context.Context, whenTagID, thenTagID uuid.UUID) error { return s.rules.Delete(ctx, whenTagID, thenTagID) diff --git a/frontend/src/lib/components/tag/TagRuleEditor.svelte b/frontend/src/lib/components/tag/TagRuleEditor.svelte index a27438d..6ef2c47 100644 --- a/frontend/src/lib/components/tag/TagRuleEditor.svelte +++ b/frontend/src/lib/components/tag/TagRuleEditor.svelte @@ -62,13 +62,9 @@ busy = true; error = ''; const thenTagId = rule.then_tag_id!; - const newActive = !rule.is_active; try { - await api.delete(`/tags/${tagId}/rules/${thenTagId}`); - const updated = await api.post(`/tags/${tagId}/rules`, { - then_tag_id: thenTagId, - is_active: newActive, - apply_to_existing: false, + const updated = await api.patch(`/tags/${tagId}/rules/${thenTagId}`, { + is_active: !rule.is_active, }); onRulesChange(rules.map((r) => r.then_tag_id === thenTagId ? updated : r)); } catch (e) { diff --git a/frontend/vite-mock-plugin.ts b/frontend/vite-mock-plugin.ts index 4d087c2..1b2374d 100644 --- a/frontend/vite-mock-plugin.ts +++ b/frontend/vite-mock-plugin.ts @@ -402,6 +402,19 @@ export function mockApiPlugin(): Plugin { return json(res, 201, { tag_id: tid, then_tag_id: thenId, then_tag_name: t?.name ?? null, is_active: isActive }); } + // PATCH /tags/{id}/rules/{then_id} — activate / deactivate + const tagRulesPatchMatch = path.match(/^\/tags\/([^/]+)\/rules\/([^/]+)$/); + if (method === 'PATCH' && tagRulesPatchMatch) { + const [, tid, thenId] = tagRulesPatchMatch; + const body = (await readBody(req)) as Record; + const isActive = body.is_active as boolean; + const ruleMap = tagRules.get(tid); + if (!ruleMap?.has(thenId)) return json(res, 404, { code: 'not_found', message: 'Rule not found' }); + ruleMap.set(thenId, isActive); + const t = MOCK_TAGS.find((x) => x.id === thenId); + return json(res, 200, { tag_id: tid, then_tag_id: thenId, then_tag_name: t?.name ?? null, is_active: isActive }); + } + // DELETE /tags/{id}/rules/{then_id} const tagRulesDelMatch = path.match(/^\/tags\/([^/]+)\/rules\/([^/]+)$/); if (method === 'DELETE' && tagRulesDelMatch) { diff --git a/openapi.yaml b/openapi.yaml index 92aaa8e..4d7cc3e 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -825,6 +825,37 @@ paths: $ref: '#/components/schemas/TagRule' /tags/{tag_id}/rules/{then_tag_id}: + patch: + tags: [Tags] + summary: Update a tag rule (activate / deactivate) + parameters: + - $ref: '#/components/parameters/tag_id' + - name: then_tag_id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [is_active] + properties: + is_active: + type: boolean + responses: + '200': + description: Rule updated + content: + application/json: + schema: + $ref: '#/components/schemas/TagRule' + '404': + $ref: '#/components/responses/NotFound' + delete: tags: [Tags] summary: Remove a tag rule