@@ -18,6 +18,7 @@ import (
1818type 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
4344type 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
5960func 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+
7084func (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+
479495func (h * WorkspaceHandler ) Delete (w http.ResponseWriter , r * http.Request ) {
480496 id , ok := h .requireWorkspaceAdminAccess (w , r )
481497 if ! ok {
0 commit comments