Ctrl K
ComunidadGuías…

specs/010-brief-ordering/design.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Brief Ordering Design

## Overview

Add drag-and-drop reordering for briefs in the sidebar. Users drag briefs to reorder, changes persist to backend immediately with optimistic UI updates.

## Architecture

```
┌─────────────────────────────────────────────────────────────────────┐
│                         Frontend                                     │
│  ┌─────────────────────────────────────────────────────────────┐   │
│  │ SidebarBriefsList                                            │   │
│  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │   │
│  │  │ DndContext  │─▶│SortableCtx  │─▶│ SortableBriefItem   │  │   │
│  │  │ (sensors,   │  │ (items,     │  │ (useSortable hook)  │  │   │
│  │  │  collision) │  │  strategy)  │  │                     │  │   │
│  │  └─────────────┘  └─────────────┘  └─────────────────────┘  │   │
│  └─────────────────────────────────────────────────────────────┘   │
│                              │                                       │
│                              ▼ onDragEnd                            │
│                    ┌─────────────────────┐                          │
│                    │ apiClient.reorder() │                          │
│                    └─────────────────────┘                          │
└─────────────────────────────────────────────────────────────────────┘
                               │
                               ▼ POST /daybriefs/reorder
┌─────────────────────────────────────────────────────────────────────┐
│                         Backend                                      │
│  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────────┐ │
│  │ DayBriefController│─▶│ DayBriefService │─▶│ DayBriefRepository │ │
│  │ reorderBriefs() │  │ reorderBriefs() │  │ saveAll()          │ │
│  └─────────────────┘  └─────────────────┘  └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```

## Components and Interfaces

### Frontend

#### New Dependencies

```json
{
  "@dnd-kit/core": "^6.1.0",
  "@dnd-kit/sortable": "^8.0.0",
  "@dnd-kit/utilities": "^3.2.2"
}
```

#### SidebarBriefsList.tsx (Modified)

Wrap brief items with DnD context and sortable components.

```tsx
import {
  DndContext,
  closestCenter,
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors,
  DragEndEvent,
} from '@dnd-kit/core';
import {
  SortableContext,
  sortableKeyboardCoordinates,
  verticalListSortingStrategy,
  arrayMove,
} from '@dnd-kit/sortable';

// In component:
const sensors = useSensors(
  useSensor(PointerSensor, {
    activationConstraint: { distance: 8 },
  }),
  useSensor(KeyboardSensor, {
    coordinateGetter: sortableKeyboardCoordinates,
  })
);

const handleDragEnd = async (event: DragEndEvent) => {
  const { active, over } = event;
  if (!over || active.id === over.id) return;

  const oldIndex = briefs.findIndex(b => b.id === active.id);
  const newIndex = briefs.findIndex(b => b.id === over.id);

  // Optimistic update
  const newOrder = arrayMove(briefs, oldIndex, newIndex);
  setBriefs(newOrder);

  try {
    await apiClient.reorderBriefs(newOrder.map(b => b.id));
  } catch (error) {
    // Revert on failure
    setBriefs(briefs);
    toast({ title: 'Failed to save order', variant: 'destructive' });
  }
};
```

#### SortableBriefItem.tsx (New Component)

Extracted brief item with sortable behavior.

```tsx
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';

interface SortableBriefItemProps {
  brief: DayBriefDTO;
  isActive: boolean;
  onClick: () => void;
}

export function SortableBriefItem({ brief, isActive, onClick }: SortableBriefItemProps) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id: brief.id });

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isDragging ? 0.5 : 1,
  };

  return (
    <SidebarMenuItem ref={setNodeRef} style={style} {...attributes} {...listeners}>
      <SidebarMenuButton isActive={isActive} onClick={onClick} tooltip={brief.title}>
        <FileText className="size-4" />
        <span>{brief.title}</span>
      </SidebarMenuButton>
    </SidebarMenuItem>
  );
}
```

#### apiClient (Modified)

Add reorder method to `src/lib/api.ts`.

```tsx
async reorderBriefs(briefIds: string[]): Promise<void> {
  return this.request('/daybriefs/reorder', {
    method: 'POST',
    body: JSON.stringify({ briefIds }),
  });
}
```

#### Types (Modified)

Add position to DayBriefDTO in `src/types/index.ts`.

```tsx
export interface DayBriefDTO {
  // ... existing fields
  position: number;
}
```

### Backend

#### Database Migration

New file: `V21__add_brief_position.sql`

```sql
-- Add position column for user-defined ordering
ALTER TABLE day_briefs ADD COLUMN position INTEGER;

-- Initialize positions based on creation order (oldest first = lowest position)
WITH ranked AS (
  SELECT id, user_id, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) - 1 AS pos
  FROM day_briefs
)
UPDATE day_briefs SET position = ranked.pos
FROM ranked WHERE day_briefs.id = ranked.id;

-- Make position non-nullable after backfill
ALTER TABLE day_briefs ALTER COLUMN position SET NOT NULL;

-- Index for efficient ordering queries
CREATE INDEX idx_day_briefs_user_position ON day_briefs(user_id, position);
```

#### DayBrief Entity (Modified)

Add position field to `DayBrief.java`.

```java
@Column(nullable = false)
private Integer position;
```

#### DayBriefDTO (Modified)

Add position field.

```java
private Integer position;
```

#### ReorderBriefsRequest (New DTO)

```java
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ReorderBriefsRequest {
    @NotNull
    @Size(min = 1)
    private List<UUID> briefIds;
}
```

#### DayBriefRepository (Modified)

Add query methods.

```java
// Replace existing methods with ordered versions
Page<DayBrief> findByUserIdAndStatusOrderByPositionAsc(UUID userId, DayBriefStatus status, Pageable pageable);

Page<DayBrief> findByUserIdOrderByPositionAsc(UUID userId, Pageable pageable);

// Get max position for new briefs
@Query("SELECT COALESCE(MAX(d.position), -1) FROM DayBrief d WHERE d.userId = :userId")
Integer findMaxPositionByUserId(@Param("userId") UUID userId);

// Fetch all briefs for reorder (existing method, returns List instead of Page)
List<DayBrief> findByUserId(UUID userId);
```

#### DayBriefService (Modified)

Add reorder method and update create/list methods.

```java
@Transactional
public void reorderBriefs(UUID userId, List<UUID> briefIds) {
    List<DayBrief> briefs = dayBriefRepository.findByUserId(userId);

    // Verify request contains all user's briefs
    if (briefs.size() != briefIds.size()) {
        throw new IllegalArgumentException("Request must contain all briefs");
    }

    // Map for quick lookup
    Map<UUID, DayBrief> briefMap = briefs.stream()
        .collect(Collectors.toMap(DayBrief::getId, Function.identity()));

    // Verify all IDs exist and update positions
    for (int i = 0; i < briefIds.size(); i++) {
        DayBrief brief = briefMap.get(briefIds.get(i));
        if (brief == null) {
            throw new IllegalArgumentException("Invalid brief ID: " + briefIds.get(i));
        }
        brief.setPosition(i);
    }

    dayBriefRepository.saveAll(briefs);
    log.info("Reordered {} briefs for user {}", briefIds.size(), userId);
}

// Update createDayBrief to set position at end
@Transactional
public DayBriefDTO createDayBrief(...) {
    Integer maxPosition = dayBriefRepository.findMaxPositionByUserId(userId);

    DayBrief dayBrief = DayBrief.builder()
        // ... existing fields
        .position(maxPosition + 1)
        .build();
    // ...
}

// Update listDayBriefs to use ordered queries
@Transactional(readOnly = true)
public Page<DayBriefDTO> listDayBriefs(UUID userId, DayBriefStatus status, Pageable pageable) {
    Page<DayBrief> dayBriefs;
    if (status != null) {
        dayBriefs = dayBriefRepository.findByUserIdAndStatusOrderByPositionAsc(userId, status, pageable);
    } else {
        dayBriefs = dayBriefRepository.findByUserIdOrderByPositionAsc(userId, pageable);
    }
    return dayBriefs.map(this::mapToDTO);
}
```

#### DayBriefController (Modified)

Add reorder endpoint.

```java
@PostMapping("/reorder")
public ResponseEntity<Void> reorderBriefs(
    @AuthenticationPrincipal User user,
    @Valid @RequestBody ReorderBriefsRequest request
) {
    dayBriefService.reorderBriefs(user.getId(), request.getBriefIds());
    return ResponseEntity.noContent().build();
}
```

## Data Models

### DayBrief Entity Changes

| Field | Type | Change |
|-------|------|--------|
| position | Integer | NEW - User-defined order (0-based) |

### DayBriefDTO Changes

| Field | Type | Change |
|-------|------|--------|
| position | Integer | NEW - Included in list responses |

### New Request DTO

**ReorderBriefsRequest**
| Field | Type | Validation |
|-------|------|------------|
| briefIds | List<UUID> | @NotNull, @Size(min=1) |

## Error Handling

| Scenario | Frontend | Backend |
|----------|----------|---------|
| Network failure | Revert to previous order, show error toast | N/A |
| Invalid brief ID | Show error toast | 400 Bad Request |
| Brief deleted during drag | Refresh list via BRIEFS_CHANGED event | 400 Bad Request |
| Concurrent modification | Last write wins (acceptable for personal ordering) | N/A |

## Testing Strategy

### Backend Tests

**Unit Tests (DayBriefServiceTest)**
- `reorderBriefs_validIds_updatesPositions`
- `reorderBriefs_invalidId_throwsException`
- `reorderBriefs_differentUser_throwsException`
- `createDayBrief_setsPositionAtEnd`

**Integration Tests (DayBriefIT)**
- `reorderBriefs_returnsNoContent`
- `listBriefs_orderedByPosition`
- `createBrief_appearsAtEnd`

### Frontend Tests

**Component Tests (SidebarBriefsList.test.tsx)**
- Renders briefs in position order
- Drag and drop updates local state
- API failure reverts order
- Loading state disables drag

## Performance Considerations

- **Batch update**: Single `saveAll()` call for all position changes
- **Indexed query**: `idx_day_briefs_user_position` enables fast ordered retrieval
- **Optimistic UI**: Instant visual feedback, no loading state during reorder
- **Minimal payload**: Reorder request only sends brief IDs, not full objects

## Security Considerations

- **Ownership validation**: Service verifies all brief IDs belong to requesting user
- **No position gaps**: Positions are consecutive integers, no manipulation allowed
- **Rate limiting**: Standard API rate limits apply

## Monitoring and Observability

- **Logging**: Existing `log.info` pattern for reorder operations
- **Metrics**: No additional metrics required (standard request latency applies)
- **Error tracking**: Failed reorders logged with user context