Files
kandev/apps/backend/internal/office/repository/sqlite/labels.go
T
2026-05-16 20:07:57 +01:00

184 lines
5.7 KiB
Go

package sqlite
import (
"context"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
// Label represents a workspace-scoped label in the catalog.
type Label struct {
ID string `json:"id" db:"id"`
WorkspaceID string `json:"workspace_id" db:"workspace_id"`
Name string `json:"name" db:"name"`
Color string `json:"color" db:"color"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// defaultColors is the round-robin palette for auto-created labels.
var defaultColors = []string{
"#ef4444", "#f59e0b", "#10b981", "#3b82f6",
"#8b5cf6", "#ec4899", "#14b8a6", "#f97316",
}
// nextLabelColor picks a color from the palette based on the current label count.
func nextLabelColor(count int) string {
return defaultColors[count%len(defaultColors)]
}
// GetOrCreateLabel returns the existing label by name, or creates a new one.
// The color is assigned round-robin based on the workspace label count.
func (r *Repository) GetOrCreateLabel(ctx context.Context, workspaceID, name string) (*Label, error) {
// Attempt idempotent insert.
var count int
if err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(
`SELECT COUNT(*) FROM office_labels WHERE workspace_id = ?`), workspaceID).Scan(&count); err != nil {
return nil, fmt.Errorf("count labels: %w", err)
}
color := nextLabelColor(count)
now := time.Now().UTC()
id := uuid.New().String()
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT OR IGNORE INTO office_labels (id, workspace_id, name, color, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`), id, workspaceID, name, color, now, now)
if err != nil {
return nil, fmt.Errorf("insert label: %w", err)
}
return r.GetLabelByName(ctx, workspaceID, name)
}
// GetLabelByName returns a label by workspace and name.
func (r *Repository) GetLabelByName(ctx context.Context, workspaceID, name string) (*Label, error) {
var label Label
err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(
`SELECT * FROM office_labels WHERE workspace_id = ? AND name = ?`),
workspaceID, name).StructScan(&label)
if err != nil {
return nil, fmt.Errorf("get label by name: %w", err)
}
return &label, nil
}
// AddLabelToTask creates a junction row linking a task to a label.
// Uses INSERT OR IGNORE so it is idempotent.
func (r *Repository) AddLabelToTask(ctx context.Context, taskID, labelID string) error {
now := time.Now().UTC()
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT OR IGNORE INTO office_task_labels (task_id, label_id, created_at)
VALUES (?, ?, ?)
`), taskID, labelID, now)
return err
}
// RemoveLabelFromTask deletes the junction row for a task/label pair.
func (r *Repository) RemoveLabelFromTask(ctx context.Context, taskID, labelID string) error {
_, err := r.db.ExecContext(ctx, r.db.Rebind(
`DELETE FROM office_task_labels WHERE task_id = ? AND label_id = ?`),
taskID, labelID)
return err
}
// ListLabelsForTask returns all labels attached to a task.
func (r *Repository) ListLabelsForTask(ctx context.Context, taskID string) ([]*Label, error) {
var labels []*Label
err := r.ro.SelectContext(ctx, &labels, r.ro.Rebind(`
SELECT l.*
FROM office_labels l
JOIN office_task_labels tl ON tl.label_id = l.id
WHERE tl.task_id = ?
ORDER BY l.name
`), taskID)
if err != nil {
return nil, fmt.Errorf("list labels for task: %w", err)
}
if labels == nil {
labels = []*Label{}
}
return labels, nil
}
// ListLabelsForTasks returns a map of taskID → labels for a batch of task IDs.
func (r *Repository) ListLabelsForTasks(ctx context.Context, taskIDs []string) (map[string][]*Label, error) {
result := make(map[string][]*Label, len(taskIDs))
if len(taskIDs) == 0 {
return result, nil
}
placeholders := make([]string, len(taskIDs))
args := make([]interface{}, len(taskIDs))
for i, id := range taskIDs {
placeholders[i] = "?"
args[i] = id
}
type row struct {
TaskID string `db:"task_id"`
Label
}
var rows []row
q := fmt.Sprintf(`
SELECT tl.task_id, l.id, l.workspace_id, l.name, l.color, l.created_at, l.updated_at
FROM office_labels l
JOIN office_task_labels tl ON tl.label_id = l.id
WHERE tl.task_id IN (%s)
ORDER BY tl.task_id, l.name
`, strings.Join(placeholders, ","))
if err := r.ro.SelectContext(ctx, &rows, r.ro.Rebind(q), args...); err != nil {
return nil, fmt.Errorf("batch list labels: %w", err)
}
for _, row := range rows {
lbl := row.Label
result[row.TaskID] = append(result[row.TaskID], &lbl)
}
return result, nil
}
// ListLabelsByWorkspace returns all labels in a workspace catalog.
func (r *Repository) ListLabelsByWorkspace(ctx context.Context, workspaceID string) ([]*Label, error) {
var labels []*Label
err := r.ro.SelectContext(ctx, &labels, r.ro.Rebind(
`SELECT * FROM office_labels WHERE workspace_id = ? ORDER BY name`),
workspaceID)
if err != nil {
return nil, fmt.Errorf("list workspace labels: %w", err)
}
if labels == nil {
labels = []*Label{}
}
return labels, nil
}
// UpdateLabel updates the name and color of a label by ID.
func (r *Repository) UpdateLabel(ctx context.Context, id, name, color string) error {
now := time.Now().UTC()
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE office_labels SET name = ?, color = ?, updated_at = ? WHERE id = ?
`), name, color, now, id)
if err != nil {
return fmt.Errorf("update label: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("label not found: %s", id)
}
return nil
}
// DeleteLabel deletes a label from the catalog. The ON DELETE CASCADE on
// office_task_labels removes junction rows automatically.
func (r *Repository) DeleteLabel(ctx context.Context, id string) error {
_, err := r.db.ExecContext(ctx, r.db.Rebind(
`DELETE FROM office_labels WHERE id = ?`), id)
return err
}