feat: edit and delete settings history rows
Backend:
- SettingsStore: Add GetByID, Update, Delete, Count methods
- SettingsService: Add UpdateSettings (validates same rules as Upsert),
DeleteSettings (guards against deleting the last row → 409)
- New sentinels: ErrSettingsNotFound, ErrLastSettingsRow
- Handler: PUT /api/settings/history/{id} → 200 updated row
DELETE /api/settings/history/{id} → 204 / 404 / 409
Frontend:
- API client: settings.update(id, body) and settings.delete(id)
- Settings page: history table gains edit (pencil) and delete (×) buttons
- Inline edit form expands in place within the table row
- Delete button disabled and hint shown when only one row remains
- maskLabel() helper shows workday names instead of raw bitmask
- After save/delete: full reload to reflect changes in 'current' section
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/wotra/wotra/internal/service"
|
||||
@@ -21,6 +23,8 @@ func (h *SettingsHandler) Routes(r chi.Router) {
|
||||
r.Get("/settings", h.Current)
|
||||
r.Put("/settings", h.Upsert)
|
||||
r.Get("/settings/history", h.History)
|
||||
r.Put("/settings/history/{id}", h.UpdateHistoryRow)
|
||||
r.Delete("/settings/history/{id}", h.DeleteHistoryRow)
|
||||
}
|
||||
|
||||
// Current GET /api/settings
|
||||
@@ -76,3 +80,62 @@ func (h *SettingsHandler) History(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
writeJSON(w, http.StatusOK, history)
|
||||
}
|
||||
|
||||
// UpdateHistoryRow PUT /api/settings/history/{id}
|
||||
func (h *SettingsHandler) UpdateHistoryRow(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
EffectiveFrom string `json:"effective_from"`
|
||||
HoursPerWeek float64 `json:"hours_per_week"`
|
||||
WorkdaysMask int `json:"workdays_mask"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
set, err := h.svc.UpdateSettings(r.Context(), id, service.UpdateSettingsInput{
|
||||
EffectiveFrom: body.EffectiveFrom,
|
||||
HoursPerWeek: body.HoursPerWeek,
|
||||
WorkdaysMask: body.WorkdaysMask,
|
||||
Timezone: body.Timezone,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrSettingsNotFound), errors.Is(err, sql.ErrNoRows):
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
case errors.Is(err, service.ErrInvalidHours), errors.Is(err, service.ErrInvalidWorkdaysMask):
|
||||
writeError(w, http.StatusUnprocessableEntity, err.Error())
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, set)
|
||||
}
|
||||
|
||||
// DeleteHistoryRow DELETE /api/settings/history/{id}
|
||||
func (h *SettingsHandler) DeleteHistoryRow(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := h.svc.DeleteSettings(r.Context(), id); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrSettingsNotFound):
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
case errors.Is(err, service.ErrLastSettingsRow):
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
@@ -14,6 +15,8 @@ var (
|
||||
ErrNoSettings = errors.New("no settings found")
|
||||
ErrInvalidWorkdaysMask = errors.New("workdays_mask must be between 1 and 127")
|
||||
ErrInvalidHours = errors.New("hours_per_week must be > 0")
|
||||
ErrSettingsNotFound = errors.New("settings row not found")
|
||||
ErrLastSettingsRow = errors.New("cannot delete the only settings row")
|
||||
)
|
||||
|
||||
// SettingsService manages settings with effective-from history.
|
||||
@@ -87,3 +90,67 @@ func (s *SettingsService) Upsert(ctx context.Context, input UpsertSettingsInput)
|
||||
}
|
||||
return set, nil
|
||||
}
|
||||
|
||||
// UpdateSettingsInput is the payload for editing an existing settings row.
|
||||
type UpdateSettingsInput struct {
|
||||
EffectiveFrom string
|
||||
HoursPerWeek float64
|
||||
WorkdaysMask int
|
||||
Timezone string
|
||||
}
|
||||
|
||||
// UpdateSettings edits an existing settings row in-place.
|
||||
func (s *SettingsService) UpdateSettings(ctx context.Context, id int64, input UpdateSettingsInput) (*domain.Settings, error) {
|
||||
if input.HoursPerWeek <= 0 {
|
||||
return nil, ErrInvalidHours
|
||||
}
|
||||
if input.WorkdaysMask < 1 || input.WorkdaysMask > 127 {
|
||||
return nil, ErrInvalidWorkdaysMask
|
||||
}
|
||||
if input.Timezone == "" {
|
||||
input.Timezone = "UTC"
|
||||
}
|
||||
if _, err := time.LoadLocation(input.Timezone); err != nil {
|
||||
return nil, fmt.Errorf("invalid timezone: %w", err)
|
||||
}
|
||||
if _, err := time.Parse("2006-01-02", input.EffectiveFrom); err != nil {
|
||||
return nil, fmt.Errorf("invalid effective_from: %w", err)
|
||||
}
|
||||
|
||||
set, err := s.store.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrSettingsNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
set.EffectiveFrom = input.EffectiveFrom
|
||||
set.HoursPerWeek = input.HoursPerWeek
|
||||
set.WorkdaysMask = input.WorkdaysMask
|
||||
set.Timezone = input.Timezone
|
||||
|
||||
if err := s.store.Update(ctx, set); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return set, nil
|
||||
}
|
||||
|
||||
// DeleteSettings removes a settings row. Refuses if it is the only row.
|
||||
func (s *SettingsService) DeleteSettings(ctx context.Context, id int64) error {
|
||||
count, err := s.store.Count(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count <= 1 {
|
||||
return ErrLastSettingsRow
|
||||
}
|
||||
// Confirm row exists before deleting.
|
||||
if _, err := s.store.GetByID(ctx, id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrSettingsNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return s.store.Delete(ctx, id)
|
||||
}
|
||||
|
||||
@@ -71,6 +71,37 @@ func (s *SettingsStore) Insert(ctx context.Context, set *domain.Settings) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update overwrites an existing settings row by ID.
|
||||
func (s *SettingsStore) Update(ctx context.Context, set *domain.Settings) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE settings_history
|
||||
SET effective_from=?, hours_per_week=?, workdays_mask=?, timezone=?
|
||||
WHERE id=?`,
|
||||
set.EffectiveFrom, set.HoursPerWeek, set.WorkdaysMask, set.Timezone, set.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete removes a settings row by ID.
|
||||
func (s *SettingsStore) Delete(ctx context.Context, id int64) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM settings_history WHERE id=?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// Count returns the total number of settings rows.
|
||||
func (s *SettingsStore) Count(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM settings_history`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// GetByID returns a single settings row by ID.
|
||||
func (s *SettingsStore) GetByID(ctx context.Context, id int64) (*domain.Settings, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, effective_from, hours_per_week, workdays_mask, timezone, created_at
|
||||
FROM settings_history WHERE id=?`, id)
|
||||
return scanSettings(row)
|
||||
}
|
||||
|
||||
func scanSettings(row *sql.Row) (*domain.Settings, error) {
|
||||
var s domain.Settings
|
||||
err := row.Scan(&s.ID, &s.EffectiveFrom, &s.HoursPerWeek, &s.WorkdaysMask, &s.Timezone, &s.CreatedAt)
|
||||
|
||||
Reference in New Issue
Block a user