fix: prevent duplicate label creation from Android sync engine#310
Merged
fix: prevent duplicate label creation from Android sync engine#310
Conversation
The Android app's LabelCreatedResponse expects {label: int} but the
server returned {label: {id, name, color}}. Gson failed to deserialize
the object as an Int, causing the outbox to record a failure and retry
on every sync cycle — each retry creating another copy on the server.
Changes:
- Fix CreateLabel response to return just the label ID (consistent
with task creation pattern)
- Update frontend to handle ID-only create response, reconstructing
the label from request data + returned ID
- Add migration 008: deduplicate existing labels and add unique
constraint on (created_by, name)
- Add server-side duplicate checking in CreateLabel and UpdateLabel,
returning HTTP 409 on conflict
- Add isDuplicateKeyError helper to catch DB constraint violations
as 409 instead of 500
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Contributor
There was a problem hiding this comment.
Pull request overview
This PR fixes duplicate label creation caused by a client/server response-shape mismatch during label creation, and adds server-side + DB-level enforcement to prevent same-user duplicate label names.
Changes:
- Align label creation API response to return
{ label: id }(matching task creation), and update the frontend to reconstruct the label object from the request + returned id. - Add server-side duplicate-name checks for label create/update and map duplicates to HTTP 409 Conflict (including DB-constraint violations).
- Add migration 008 to deduplicate existing labels and introduce a unique index on
(created_by, name)for both SQLite and MySQL.
Show a summary per file
| File | Description |
|---|---|
| frontend/src/store/labelsSlice.ts | Reconstructs label object from returned label id during create, matching existing task-create pattern. |
| frontend/src/api/labels.ts | Updates CreateLabel HTTP transport typing to expect { label: number }. |
| apiserver/internal/services/labels/label.go | Adds duplicate checks + 409 mapping; changes CreateLabel response format to { label: id }. |
| apiserver/internal/repos/label/label.go | Adds LabelExistsByName helper used by the service layer. |
| apiserver/internal/models/label.go | Documents uniqueness intention via GORM tags for (created_by, name). |
| apiserver/internal/migrations/008_unique_label_names.go | Deduplicates existing labels and adds unique index on (created_by, name) across SQLite/MySQL. |
Copilot's findings
- Files reviewed: 6/6 changed files
- Comments generated: 3
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Creating a label from the Android app resulted in many duplicate copies being created.
Root Cause
Response format mismatch (primary): The server's
CreateLabelendpoint returned{"label": {"id": ..., "name": ..., "color": ...}}(a full object), but the Android client'sLabelCreatedResponseexpected{"label": 123}(just an integer ID, matching the task creation pattern). Gson failed to deserialize the nested object as anInt, soresponse.body()?.labelwas null, the outbox recorded a failure ("Empty create response"), and retried on every sync cycle -- each retry creating another copy on the server.No uniqueness enforcement (secondary): There was no unique constraint on
(created_by, name)in the labels table and no server-side duplicate check, so every retry succeeded in creating another label.Changes
Server (apiserver)
CreateLabelnow returns{"label": id}(just the ID), consistent with task creation. The WebSocket broadcast still sends the full label object for real-time sync.labels(created_by, name). Supports both SQLite and MySQL.CreateLabelandUpdateLabelnow check for existing labels with the same name (per user) before insert/update, returning HTTP 409 Conflict. DB constraint violations are also caught and mapped to 409.Labelstruct tags with uniqueIndex for documentation.Frontend
CreateLabelAPI to useLabelIdResponse({label: number}) and reconstruct the full label from request data + returned ID in the Redux slice (matching the existing task creation pattern).