fix(labels): harden stack labels with nodeId filtering, concurrency guard, and test coverage (#552)

* fix(labels): add nodeId filter, existence check, stale cleanup, concurrency guard, and body validation

- Fix getStacksForLabel to filter by node_id (prevents cross-node data leak)
- Add getLabel(id, nodeId) for single-label existence check
- Add getLabelCount(nodeId) for enforcing per-node label limit (50)
- Add cleanupStaleAssignments to remove orphaned assignments for deleted stacks
- Add label/assignment cleanup to deleteNode transaction
- Add label existence check on bulk action endpoint (returns 404 for missing labels)
- Add concurrency guard on bulk actions (returns 429 if already in-flight)
- Add requireBody guard on all mutation endpoints
- Extract isSqliteUniqueViolation helper to deduplicate constraint checks
- Add MAX_LABELS_PER_NODE constant (50) with limit enforcement on create
- Add diagnostic logging on all label endpoints (gated behind developer_mode)
- Add operational log line for bulk action results

* fix(labels): use ScrollArea, show failure details, add loading feedback, deduplicate constants

- Replace overflow-y-auto div with ScrollArea in LabelAssignPopover (design system)
- Show failed stack names in bulk action error toast
- Add loading toast for context menu label toggle
- Disable bulk action menu items while a bulk action is running
- Disable "New Label" button at 50-label limit with "Limit reached" text
- Export LABEL_COLORS and MAX_LABELS_PER_NODE from LabelPill (single source of truth)
- Import shared constants in LabelAssignPopover and LabelsSection (remove duplicates)
- Add BulkActionResult interface to replace inline type assertion

* test(labels): add comprehensive coverage for label CRUD, assignments, and bulk edge cases

42 tests covering:
- getLabels: empty, ordered, node isolation
- getLabel: found, wrong node, nonexistent
- createLabel: returns with ID, duplicate name constraint
- getLabelCount: correct count, zero for empty node
- updateLabel: name, color, both, not found, wrong node
- deleteLabel: removes label, cascades assignments, wrong node no-op
- setStackLabels: assign, replace, clear, invalid ID throws
- getLabelsForStacks: correct mapping, empty result
- getStacksForLabel: correct results, node filter, empty for nonexistent
- cleanupStaleAssignments: removes stale, preserves valid, handles empty
- deleteNode: cascades labels and assignments
- Edge cases: atomicity, cascade across stacks, multi-label assignment

* docs(labels): document 50-label limit and bulk action failure details

* fix(labels): add missing LabelColor type imports and explicit parameter types

* refactor(labels): extract label types and constants to label-types.ts

Moves LabelColor, Label, LABEL_COLORS, and MAX_LABELS_PER_NODE out of
LabelPill.tsx into a dedicated non-component file. This fixes the
react-refresh/only-export-components lint error caused by mixing
constant exports with component exports.
This commit is contained in:
Anso
2026-04-13 12:49:03 -04:00
committed by GitHub
parent faabbea350
commit a695251f38
10 changed files with 805 additions and 77 deletions
@@ -23,9 +23,8 @@ import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { PaidGate } from '../PaidGate';
import { CapabilityGate } from '../CapabilityGate';
import { LabelDot, type Label, type LabelColor } from '../LabelPill';
const LABEL_COLORS: LabelColor[] = ['teal', 'blue', 'purple', 'rose', 'amber', 'green', 'orange', 'pink', 'cyan', 'slate'];
import { LabelDot } from '../LabelPill';
import { LABEL_COLORS, MAX_LABELS_PER_NODE, type Label, type LabelColor } from '../label-types';
export function LabelsSection() {
const [labels, setLabels] = useState<Label[]>([]);
@@ -131,9 +130,9 @@ export function LabelsSection() {
<h2 className="text-lg font-semibold tracking-tight">Stack Labels</h2>
<p className="text-sm text-muted-foreground">Organize stacks with colored labels for filtering and bulk actions.</p>
</div>
<Button size="sm" onClick={openCreate}>
<Button size="sm" onClick={openCreate} disabled={labels.length >= MAX_LABELS_PER_NODE}>
<Plus className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
New Label
{labels.length >= MAX_LABELS_PER_NODE ? 'Limit reached' : 'New Label'}
</Button>
</div>