Skip to content

Commit 6167dff

Browse files
committed
fix: preserve omitted workspace settings
1 parent 914c5b3 commit 6167dff

5 files changed

Lines changed: 173 additions & 158 deletions

File tree

frontend/src/lib/workspaces/WorkspaceLookAndFeel.svelte

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -114,12 +114,6 @@
114114
115115
await Promise.all([
116116
api.workspaces.update(workspaceId, {
117-
name: workspace.name,
118-
key: workspace.key,
119-
description: workspace.description || '',
120-
active: workspace.active,
121-
time_project_id: workspace.time_project_id || null,
122-
default_view: workspace.default_view || 'board',
123117
icon,
124118
color,
125119
avatar_url: avatarUrl

internal/handlers/workspaces_handler.go

Lines changed: 65 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
type WorkspaceHandler struct {
1919
db database.Database
2020
repo *repository.WorkspaceRepository
21+
workspaceService *services.WorkspaceService
2122
permissionService *services.PermissionService
2223
authz *authz.Authz
2324
activityTracker *services.ActivityTracker
@@ -41,32 +42,45 @@ type CreateWorkspaceRequest struct {
4142

4243
// UpdateWorkspaceRequest represents the request payload for updating a workspace
4344
type UpdateWorkspaceRequest struct {
44-
Name string `json:"name" validate:"required,max=100"`
45-
Key string `json:"key" validate:"omitempty,min=2,max=10,alphanum"` // Optional - if not provided, keeps existing key
46-
Description string `json:"description" validate:"max=500"`
47-
Active bool `json:"active"`
48-
TimeProjectID *int `json:"time_project_id,omitempty"`
49-
IsPersonal bool `json:"is_personal"`
50-
OwnerID *int `json:"owner_id,omitempty"`
51-
Icon string `json:"icon,omitempty"`
52-
Color string `json:"color,omitempty"`
53-
AvatarURL string `json:"avatar_url,omitempty"`
54-
DefaultView string `json:"default_view,omitempty"` // Default view when entering workspace (board, backlog, list, tree, map)
55-
InternalCommentsEnabled bool `json:"internal_comments_enabled"`
56-
TimeProjectCategories []int `json:"time_project_categories,omitempty"`
45+
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=100"`
46+
Key *string `json:"key,omitempty" validate:"omitempty,min=2,max=10,alphanum"`
47+
Description *string `json:"description,omitempty" validate:"omitempty,max=500"`
48+
Active *bool `json:"active,omitempty"`
49+
TimeProjectID models.NullableIntPatch `json:"time_project_id,omitempty"`
50+
IsPersonal *bool `json:"is_personal,omitempty"`
51+
OwnerID models.NullableIntPatch `json:"owner_id,omitempty"`
52+
Icon *string `json:"icon,omitempty"`
53+
Color *string `json:"color,omitempty"`
54+
AvatarURL models.NullableStringPatch `json:"avatar_url,omitempty"`
55+
DefaultView *string `json:"default_view,omitempty"`
56+
InternalCommentsEnabled *bool `json:"internal_comments_enabled,omitempty"`
57+
TimeProjectCategories *[]int `json:"time_project_categories,omitempty"`
5758
}
5859

5960
func NewWorkspaceHandler(db database.Database, permissionService *services.PermissionService, activityTracker *services.ActivityTracker, keyCache *WorkspaceKeyCache) *WorkspaceHandler {
6061
return &WorkspaceHandler{
6162
db: db,
6263
repo: repository.NewWorkspaceRepository(db),
64+
workspaceService: services.NewWorkspaceService(db),
6365
permissionService: permissionService,
6466
authz: authz.New(db, permissionService),
6567
activityTracker: activityTracker,
6668
keyCache: keyCache,
6769
}
6870
}
6971

72+
func sanitizeWorkspaceUpdateField(value *string, sanitizer func(string) string) *string {
73+
if value == nil {
74+
return nil
75+
}
76+
sanitized := sanitizer(*value)
77+
return &sanitized
78+
}
79+
80+
func nullableWorkspaceUpdate[T any](present bool, value *T) services.NullableUpdate[T] {
81+
return services.NullableUpdate[T]{Present: present, Value: value}
82+
}
83+
7084
func (h *WorkspaceHandler) GetAll(w http.ResponseWriter, r *http.Request) {
7185
// Get user from context
7286
currentUser, ok := RequireAuth(w, r)
@@ -315,7 +329,7 @@ func (h *WorkspaceHandler) Update(w http.ResponseWriter, r *http.Request) {
315329
}
316330

317331
// Get the old workspace for audit logging
318-
oldWorkspace, err := h.repo.FindByIDBasic(id)
332+
oldWorkspace, err := h.repo.FindByID(id)
319333
if err == repository.ErrNotFound {
320334
respondNotFound(w, r, "workspace")
321335
return
@@ -337,37 +351,34 @@ func (h *WorkspaceHandler) Update(w http.ResponseWriter, r *http.Request) {
337351
return
338352
}
339353

340-
// Sanitize user input for defense in depth
341-
req.Name = sanitize.ShortIdentifier.Sanitize(req.Name)
342-
req.Description = sanitize.RichText.Sanitize(req.Description)
343-
344-
// Sanitize key to match Create behavior
345-
req.Key = sanitize.ShortIdentifier.Sanitize(req.Key)
346-
347-
// If key is not provided, use the existing key
348-
keyToUse := req.Key
349-
if keyToUse == "" {
350-
keyToUse = oldWorkspace.Key
351-
}
354+
// Sanitize supplied user input for defense in depth.
355+
req.Name = sanitizeWorkspaceUpdateField(req.Name, sanitize.ShortIdentifier.Sanitize)
356+
req.Key = sanitizeWorkspaceUpdateField(req.Key, sanitize.ShortIdentifier.Sanitize)
357+
req.Description = sanitizeWorkspaceUpdateField(req.Description, sanitize.RichText.Sanitize)
358+
req.Icon = sanitizeWorkspaceUpdateField(req.Icon, sanitize.ShortIdentifier.Sanitize)
359+
req.Color = sanitizeWorkspaceUpdateField(req.Color, sanitize.ShortIdentifier.Sanitize)
352360

353-
avatarURL := req.AvatarURL
354-
updatedWs := &models.Workspace{
361+
workspace, err := h.workspaceService.Update(services.UpdateWorkspaceParams{
355362
ID: id,
356363
Name: req.Name,
357-
Key: keyToUse,
364+
Key: req.Key,
358365
Description: req.Description,
359366
Active: req.Active,
360-
TimeProjectID: req.TimeProjectID,
367+
TimeProjectID: nullableWorkspaceUpdate(req.TimeProjectID.Present, req.TimeProjectID.Value),
361368
IsPersonal: req.IsPersonal,
362-
OwnerID: req.OwnerID,
369+
OwnerID: nullableWorkspaceUpdate(req.OwnerID.Present, req.OwnerID.Value),
363370
Icon: req.Icon,
364371
Color: req.Color,
365-
AvatarURL: &avatarURL,
372+
AvatarURL: nullableWorkspaceUpdate(req.AvatarURL.Present, req.AvatarURL.Value),
366373
DefaultView: req.DefaultView,
367374
InternalCommentsEnabled: req.InternalCommentsEnabled,
368-
}
369-
err = h.repo.Update(updatedWs)
375+
TimeProjectCategories: req.TimeProjectCategories,
376+
})
370377
if err != nil {
378+
if errors.Is(err, repository.ErrNotFound) {
379+
respondNotFound(w, r, "workspace")
380+
return
381+
}
371382
respondInternalError(w, r, err)
372383
return
373384
}
@@ -376,21 +387,6 @@ func (h *WorkspaceHandler) Update(w http.ResponseWriter, r *http.Request) {
376387
h.permissionService.OnEveryoneAccessChanged()
377388
}
378389

379-
// Save time project categories if provided
380-
if req.TimeProjectCategories != nil {
381-
if err = h.repo.SaveTimeProjectCategories(id, req.TimeProjectCategories); err != nil {
382-
slog.Error("failed to save time project categories", slog.String("component", "workspaces"), slog.Int("workspace_id", id), slog.Any("error", err))
383-
// Don't fail the entire update, just log the error
384-
}
385-
}
386-
387-
// Return the updated workspace with joined data
388-
workspace, err := h.repo.FindByID(id)
389-
if err != nil {
390-
respondInternalError(w, r, err)
391-
return
392-
}
393-
394390
// Load time project categories for the response
395391
timeProjectCats, err := h.repo.GetTimeProjectCategories(id)
396392
if err != nil {
@@ -452,6 +448,12 @@ func (h *WorkspaceHandler) Update(w http.ResponseWriter, r *http.Request) {
452448
"new": workspace.Color,
453449
}
454450
}
451+
if !workspaceStringPointersEqual(oldWorkspace.AvatarURL, workspace.AvatarURL) {
452+
details["avatar_url_changed"] = map[string]any{
453+
"old": workspaceStringPointerValue(oldWorkspace.AvatarURL),
454+
"new": workspaceStringPointerValue(workspace.AvatarURL),
455+
}
456+
}
455457
if oldWorkspace.InternalCommentsEnabled != workspace.InternalCommentsEnabled {
456458
details["internal_comments_enabled_changed"] = map[string]any{
457459
"old": oldWorkspace.InternalCommentsEnabled,
@@ -476,6 +478,20 @@ func (h *WorkspaceHandler) Update(w http.ResponseWriter, r *http.Request) {
476478
respondJSONOK(w, workspace)
477479
}
478480

481+
func workspaceStringPointersEqual(a, b *string) bool {
482+
if a == nil || b == nil {
483+
return a == b
484+
}
485+
return *a == *b
486+
}
487+
488+
func workspaceStringPointerValue(value *string) any {
489+
if value == nil {
490+
return nil
491+
}
492+
return *value
493+
}
494+
479495
func (h *WorkspaceHandler) Delete(w http.ResponseWriter, r *http.Request) {
480496
id, ok := h.requireWorkspaceAdminAccess(w, r)
481497
if !ok {

internal/repository/workspace_repository.go

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -288,22 +288,6 @@ func (r *WorkspaceRepository) CreateTx(tx database.Tx, workspace *models.Workspa
288288
return id, nil
289289
}
290290

291-
// Update updates an existing workspace
292-
func (r *WorkspaceRepository) Update(workspace *models.Workspace) error {
293-
now := time.Now()
294-
_, err := r.db.ExecWrite(`
295-
UPDATE workspaces
296-
SET name = ?, key = ?, description = ?, active = ?, time_project_id = ?, is_personal = ?, owner_id = ?, icon = ?, color = ?, avatar_url = ?, default_view = ?, internal_comments_enabled = ?, updated_at = ?
297-
WHERE id = ?
298-
`, workspace.Name, workspace.Key, workspace.Description, workspace.Active,
299-
workspace.TimeProjectID, workspace.IsPersonal, workspace.OwnerID,
300-
workspace.Icon, workspace.Color, workspace.AvatarURL, workspace.DefaultView,
301-
workspace.InternalCommentsEnabled,
302-
now, workspace.ID)
303-
304-
return err
305-
}
306-
307291
// AssignTimeProjectIfUnset attaches an imported time project without replacing
308292
// an existing workspace default.
309293
func (r *WorkspaceRepository) AssignTimeProjectIfUnset(workspaceID, timeProjectID int) error {

internal/restapi/v1/handlers/workspaces.go

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -69,18 +69,19 @@ type WorkspaceUpdateRequest struct {
6969
Color *string `json:"color,omitempty"`
7070
}
7171

72-
func toWorkspaceResponse(ws *services.WorkspaceListResult) WorkspaceResponse {
72+
func toWorkspaceResponse(ws *models.Workspace) WorkspaceResponse {
7373
return WorkspaceResponse{
74-
ID: ws.ID,
75-
Name: ws.Name,
76-
Key: ws.Key,
77-
Description: ws.Description,
78-
Active: ws.Active,
79-
IsPersonal: ws.IsPersonal,
80-
Icon: ws.Icon,
81-
Color: ws.Color,
82-
CreatedAt: ws.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
83-
UpdatedAt: ws.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
74+
ID: ws.ID,
75+
Name: ws.Name,
76+
Key: ws.Key,
77+
Description: ws.Description,
78+
Active: ws.Active,
79+
IsPersonal: ws.IsPersonal,
80+
InternalCommentsEnabled: ws.InternalCommentsEnabled,
81+
Icon: ws.Icon,
82+
Color: ws.Color,
83+
CreatedAt: ws.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
84+
UpdatedAt: ws.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
8485
}
8586
}
8687

0 commit comments

Comments
 (0)