diff --git a/api/cmd/api/main.go b/api/cmd/api/main.go index df1c4d5a..c5c926c9 100644 --- a/api/cmd/api/main.go +++ b/api/cmd/api/main.go @@ -45,7 +45,7 @@ func main() { sqlDB, _ := db.DB() defer sqlDB.Close() - // Redis + // Redis var rdb *redis.Client if client, err := redis.New(cfg, log); err != nil { log.Warn("redis", "error", err) @@ -54,7 +54,7 @@ func main() { defer rdb.Close() } - // RabbitMQ + // RabbitMQ var queuePublisher *queue.Publisher var rmq *rabbitmq.Client if client, err := rabbitmq.New(cfg, log); err != nil { @@ -69,20 +69,21 @@ func main() { } } - // MinIO - mc, err := minio.New(cfg, log) - if err != nil { + // MinIO (optional: file uploads for covers, avatars, logos) + var mc *minio.Client + if client, err := minio.New(cfg, log); err != nil { log.Warn("minio", "error", err) } else { - _ = mc + mc = client } r := router.New(router.Config{ - Log: log, - DB: db, - Redis: rdb, - Queue: queuePublisher, - CORSAllowOrigin: cfg.CORSAllowOrigin, + Log: log, + DB: db, + Redis: rdb, + Queue: queuePublisher, + Minio: mc, + CORSAllowOrigin: cfg.CORSAllowOrigin, }) // Start task consumer when RabbitMQ is available diff --git a/api/internal/auth/service.go b/api/internal/auth/service.go index c3c081b5..390a66ff 100644 --- a/api/internal/auth/service.go +++ b/api/internal/auth/service.go @@ -32,8 +32,8 @@ func NewService(userStore *store.UserStore, sessionStore *store.SessionStore) *S } type SignUpRequest struct { - Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required,min=8"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=8"` FirstName string `json:"first_name"` LastName string `json:"last_name"` } @@ -62,13 +62,13 @@ func (s *Service) SignUp(ctx context.Context, req SignUpRequest) (sessionKey str return "", nil, err } u := &model.User{ - Username: username, - Email: &email, - Password: string(hash), - FirstName: req.FirstName, - LastName: req.LastName, + Username: username, + Email: &email, + Password: string(hash), + FirstName: req.FirstName, + LastName: req.LastName, DisplayName: strings.TrimSpace(req.FirstName + " " + req.LastName), - IsActive: true, + IsActive: true, } if err := s.userStore.Create(ctx, u); err != nil { return "", nil, err diff --git a/api/internal/config/config.go b/api/internal/config/config.go index eba6a26c..fdbaf9f2 100644 --- a/api/internal/config/config.go +++ b/api/internal/config/config.go @@ -65,18 +65,18 @@ func Load() (*Config, error) { } cfg := &Config{ - Env: getEnv("ENV", "development"), - ServerPort: getEnv("SERVER_PORT", "8080"), - DBHost: getEnv("DB_HOST", "localhost"), - DBPort: getEnv("DB_PORT", "5432"), - DBUser: getEnv("DB_USER", "postgres"), - DBPassword: getEnv("DB_PASSWORD", "postgres"), - DBName: getEnv("DB_NAME", "devlane"), - DBSSLMode: getEnv("DB_SSLMODE", "disable"), - RedisAddr: getEnv("REDIS_ADDR", "localhost:6379"), - RedisPassword: getEnv("REDIS_PASSWORD", ""), - RedisDB: redisDB, - RabbitMQURL: getEnv("RABBITMQ_URL", "amqp://guest:guest@localhost:5672/"), + Env: getEnv("ENV", "development"), + ServerPort: getEnv("SERVER_PORT", "8080"), + DBHost: getEnv("DB_HOST", "localhost"), + DBPort: getEnv("DB_PORT", "5432"), + DBUser: getEnv("DB_USER", "postgres"), + DBPassword: getEnv("DB_PASSWORD", "postgres"), + DBName: getEnv("DB_NAME", "devlane"), + DBSSLMode: getEnv("DB_SSLMODE", "disable"), + RedisAddr: getEnv("REDIS_ADDR", "localhost:6379"), + RedisPassword: getEnv("REDIS_PASSWORD", ""), + RedisDB: redisDB, + RabbitMQURL: getEnv("RABBITMQ_URL", "amqp://guest:guest@localhost:5672/"), MinIOEndpoint: getEnv("MINIO_ENDPOINT", "localhost:9000"), MinIOAccessKeyID: getEnv("MINIO_ACCESS_KEY_ID", "minioadmin"), MinIOSecretAccessKey: getEnv("MINIO_SECRET_ACCESS_KEY", "minioadmin"), diff --git a/api/internal/handler/auth.go b/api/internal/handler/auth.go index 277898db..08a50639 100644 --- a/api/internal/handler/auth.go +++ b/api/internal/handler/auth.go @@ -17,12 +17,12 @@ import ( ) type AuthHandler struct { - Auth *auth.Service - Settings *store.InstanceSettingStore - Winv *store.WorkspaceInviteStore + Auth *auth.Service + Settings *store.InstanceSettingStore + Winv *store.WorkspaceInviteStore Ws *store.WorkspaceStore - NotifPrefs *store.UserNotificationPreferenceStore - ApiTokens *store.ApiTokenStore + NotifPrefs *store.UserNotificationPreferenceStore + ApiTokens *store.ApiTokenStore } type SignInRequest struct { @@ -31,10 +31,10 @@ type SignInRequest struct { } type SignUpRequest struct { - Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required,min=8"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=8"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` InviteToken string `json:"invite_token"` } @@ -181,6 +181,8 @@ type UpdateMeRequest struct { LastName *string `json:"last_name"` DisplayName *string `json:"display_name"` UserTimezone *string `json:"user_timezone"` + Avatar *string `json:"avatar"` + CoverImage *string `json:"cover_image"` } // UpdateMe updates the authenticated user's profile (email is not updatable). @@ -208,6 +210,12 @@ func (h *AuthHandler) UpdateMe(c *gin.Context) { if req.UserTimezone != nil { user.UserTimezone = *req.UserTimezone } + if req.Avatar != nil { + user.Avatar = *req.Avatar + } + if req.CoverImage != nil { + user.CoverImage = *req.CoverImage + } if err := h.Auth.UpdateProfile(c.Request.Context(), user); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Update failed"}) return @@ -255,11 +263,11 @@ func (h *AuthHandler) GetNotificationPreferences(c *gin.Context) { } if h.NotifPrefs == nil { c.JSON(http.StatusOK, gin.H{ - "property_change": true, - "state_change": true, - "comment": true, - "mention": true, - "issue_completed": true, + "property_change": true, + "state_change": true, + "comment": true, + "mention": true, + "issue_completed": true, }) return } @@ -270,20 +278,20 @@ func (h *AuthHandler) GetNotificationPreferences(c *gin.Context) { } if p == nil { c.JSON(http.StatusOK, gin.H{ - "property_change": true, - "state_change": true, - "comment": true, - "mention": true, - "issue_completed": true, + "property_change": true, + "state_change": true, + "comment": true, + "mention": true, + "issue_completed": true, }) return } c.JSON(http.StatusOK, gin.H{ - "property_change": p.PropertyChange, - "state_change": p.StateChange, - "comment": p.Comment, - "mention": p.Mention, - "issue_completed": p.IssueCompleted, + "property_change": p.PropertyChange, + "state_change": p.StateChange, + "comment": p.Comment, + "mention": p.Mention, + "issue_completed": p.IssueCompleted, }) } @@ -522,6 +530,7 @@ func userResponse(u *model.User) gin.H { "last_name": u.LastName, "display_name": u.DisplayName, "avatar": u.Avatar, + "cover_image": u.CoverImage, "is_active": u.IsActive, "is_onboarded": u.IsOnboarded, "date_joined": u.DateJoined, @@ -530,4 +539,3 @@ func userResponse(u *model.User) gin.H { "user_timezone": u.UserTimezone, } } - diff --git a/api/internal/handler/favorite.go b/api/internal/handler/favorite.go new file mode 100644 index 00000000..3164b3fb --- /dev/null +++ b/api/internal/handler/favorite.go @@ -0,0 +1,106 @@ +package handler + +import ( + "net/http" + + "github.com/Devlaner/devlane/api/internal/middleware" + "github.com/Devlaner/devlane/api/internal/service" + "github.com/Devlaner/devlane/api/internal/store" + "github.com/gin-gonic/gin" +) + +// FavoriteHandler serves user favorite project endpoints. +type FavoriteHandler struct { + Project *service.ProjectService + Favorites *store.UserFavoriteStore +} + +// ListFavoriteProjects returns the list of favorited project IDs for the current user. +// GET /api/users/me/favorite-projects/ +func (h *FavoriteHandler) ListFavoriteProjects(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + if h.Favorites == nil { + c.JSON(http.StatusOK, gin.H{"project_ids": []string{}}) + return + } + ids, err := h.Favorites.ListProjectIDsByUser(c.Request.Context(), user.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load favorites"}) + return + } + strIds := make([]string, 0, len(ids)) + for _, id := range ids { + strIds = append(strIds, id.String()) + } + c.JSON(http.StatusOK, gin.H{"project_ids": strIds}) +} + +// AddFavoriteProject adds a project to the current user's favorites. +// POST /api/workspaces/:slug/projects/:projectId/favorite +func (h *FavoriteHandler) AddFavoriteProject(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + slug := c.Param("slug") + projectID, ok := projectID(c) + if !ok { + return + } + if h.Favorites == nil || h.Project == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Favorites not available"}) + return + } + project, err := h.Project.GetByID(c.Request.Context(), slug, projectID, user.ID) + if err != nil { + if err == service.ErrProjectNotFound || err == service.ErrProjectForbidden { + c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add favorite"}) + return + } + if err := h.Favorites.AddProject(c.Request.Context(), user.ID, project.WorkspaceID, projectID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add favorite"}) + return + } + c.JSON(http.StatusOK, gin.H{"project_id": projectID.String()}) +} + +// RemoveFavoriteProject removes a project from the current user's favorites. +// DELETE /api/workspaces/:slug/projects/:projectId/favorite +func (h *FavoriteHandler) RemoveFavoriteProject(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + projectID, ok := projectID(c) + if !ok { + return + } + if h.Favorites == nil || h.Project == nil { + c.JSON(http.StatusOK, gin.H{"message": "ok"}) + return + } + // Verify user has access by resolving workspace + project + slug := c.Param("slug") + if _, err := h.Project.GetByID(c.Request.Context(), slug, projectID, user.ID); err != nil { + if err == service.ErrProjectNotFound || err == service.ErrProjectForbidden { + c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove favorite"}) + return + } + if err := h.Favorites.RemoveProject(c.Request.Context(), user.ID, projectID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove favorite"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "ok"}) +} diff --git a/api/internal/handler/instance.go b/api/internal/handler/instance.go index 9bf373e9..1cbce22b 100644 --- a/api/internal/handler/instance.go +++ b/api/internal/handler/instance.go @@ -3,7 +3,10 @@ package handler import ( "crypto/rand" "encoding/hex" + "encoding/json" + "io" "net/http" + "net/url" "strings" "github.com/Devlaner/devlane/api/internal/auth" @@ -99,10 +102,10 @@ func (h *InstanceHandler) InstanceSetup(c *gin.Context) { instanceName = user.DisplayName } _ = h.Settings.Upsert(c.Request.Context(), "general", model.JSONMap{ - "instance_id": instanceID, - "admin_email": req.Email, - "instance_name": instanceName, - "only_admin_can_create_workspace": false, + "instance_id": instanceID, + "admin_email": req.Email, + "instance_name": instanceName, + "only_admin_can_create_workspace": false, }) } @@ -292,3 +295,89 @@ func (h *InstanceSettingsHandler) UpdateSetting(c *gin.Context) { responseValue := decryptSectionSecrets(key, value) c.JSON(http.StatusOK, gin.H{"key": key, "value": responseValue}) } + +// unsplashPhoto is a single photo from Unsplash API response. +type unsplashPhoto struct { + ID string `json:"id"` + URLs struct { + Full string `json:"full"` + Regular string `json:"regular"` + Thumb string `json:"thumb"` + } `json:"urls"` +} + +// unsplashSearchResponse is the Unsplash search API response. +type unsplashSearchResponse struct { + Results []unsplashPhoto `json:"results"` +} + +// UnsplashSearchResult is a simplified photo returned by our proxy. +type UnsplashSearchResult struct { + ID string `json:"id"` + URL string `json:"url"` + Thumb string `json:"thumb"` +} + +// UnsplashSearch proxies search to Unsplash API using instance image settings key (auth required). +// GET /api/instance/unsplash/search?q=... +func (h *InstanceSettingsHandler) UnsplashSearch(c *gin.Context) { + if h.Settings == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Settings not available"}) + return + } + row, err := h.Settings.Get(c.Request.Context(), "image") + if err != nil || row == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Unsplash is not configured"}) + return + } + decrypted := decryptSectionSecrets("image", row.Value) + keyVal, _ := decrypted["unsplash_access_key"].(string) + keyVal = strings.TrimSpace(keyVal) + if keyVal == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Unsplash is not configured"}) + return + } + + q := strings.TrimSpace(c.Query("q")) + if q == "" { + c.JSON(http.StatusOK, gin.H{"results": []UnsplashSearchResult{}}) + return + } + + apiURL := "https://api.unsplash.com/search/photos?query=" + url.QueryEscape(q) + "&per_page=20" + req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, apiURL, nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search"}) + return + } + req.Header.Set("Authorization", "Client-ID "+keyVal) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search"}) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, resp.Body) + c.JSON(http.StatusBadGateway, gin.H{"error": "Unsplash search failed"}) + return + } + + var payload unsplashSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid response"}) + return + } + + results := make([]UnsplashSearchResult, 0, len(payload.Results)) + for _, p := range payload.Results { + u := p.URLs.Regular + if u == "" { + u = p.URLs.Full + } + results = append(results, UnsplashSearchResult{ID: p.ID, URL: u, Thumb: p.URLs.Thumb}) + } + c.JSON(http.StatusOK, gin.H{"results": results}) +} diff --git a/api/internal/handler/issue_view.go b/api/internal/handler/issue_view.go index 2b73fb1d..3957a711 100644 --- a/api/internal/handler/issue_view.go +++ b/api/internal/handler/issue_view.go @@ -3,8 +3,8 @@ package handler import ( "net/http" - "github.com/Devlaner/devlane/api/internal/model" "github.com/Devlaner/devlane/api/internal/middleware" + "github.com/Devlaner/devlane/api/internal/model" "github.com/Devlaner/devlane/api/internal/service" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -53,9 +53,9 @@ func (h *IssueViewHandler) Create(c *gin.Context) { } slug := c.Param("slug") var body struct { - Name string `json:"name" binding:"required"` - Description string `json:"description"` - ProjectID *uuid.UUID `json:"project_id"` + Name string `json:"name" binding:"required"` + Description string `json:"description"` + ProjectID *uuid.UUID `json:"project_id"` Query model.JSONMap `json:"query"` Filters model.JSONMap `json:"filters"` DisplayFilters model.JSONMap `json:"display_filters"` @@ -118,8 +118,8 @@ func (h *IssueViewHandler) Update(c *gin.Context) { return } var body struct { - Name string `json:"name"` - Description string `json:"description"` + Name string `json:"name"` + Description string `json:"description"` Query model.JSONMap `json:"query"` Filters model.JSONMap `json:"filters"` DisplayFilters model.JSONMap `json:"display_filters"` diff --git a/api/internal/handler/page.go b/api/internal/handler/page.go index 3e1621ca..5dab97dd 100644 --- a/api/internal/handler/page.go +++ b/api/internal/handler/page.go @@ -55,7 +55,7 @@ func (h *PageHandler) Create(c *gin.Context) { Name string `json:"name" binding:"required"` DescriptionHTML string `json:"description_html"` ProjectID *uuid.UUID `json:"project_id"` - Access int16 `json:"access"` // 0 public, 1 private + Access int16 `json:"access"` // 0 public, 1 private } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) diff --git a/api/internal/handler/project.go b/api/internal/handler/project.go index 83f6a36d..a4c620bf 100644 --- a/api/internal/handler/project.go +++ b/api/internal/handler/project.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/Devlaner/devlane/api/internal/middleware" + "github.com/Devlaner/devlane/api/internal/model" "github.com/Devlaner/devlane/api/internal/service" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -83,8 +84,8 @@ func (h *ProjectHandler) Create(c *gin.Context) { } slug := c.Param("slug") var body struct { - Name string `json:"name" binding:"required"` - Identifier string `json:"identifier"` + Name string `json:"name" binding:"required"` + Identifier string `json:"identifier"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) @@ -116,19 +117,22 @@ func (h *ProjectHandler) Update(c *gin.Context) { return } var body struct { - Name string `json:"name"` - Identifier string `json:"identifier"` - Description *string `json:"description"` - Timezone *string `json:"timezone"` - ProjectLeadID *string `json:"project_lead_id"` - DefaultAssigneeID *string `json:"default_assignee_id"` - GuestViewAllFeatures *bool `json:"guest_view_all_features"` - ModuleView *bool `json:"module_view"` - CycleView *bool `json:"cycle_view"` - IssueViewsView *bool `json:"issue_views_view"` - PageView *bool `json:"page_view"` - IntakeView *bool `json:"intake_view"` - IsTimeTrackingEnabled *bool `json:"is_time_tracking_enabled"` + Name string `json:"name"` + Identifier string `json:"identifier"` + Description *string `json:"description"` + Timezone *string `json:"timezone"` + CoverImage *string `json:"cover_image"` + Emoji *string `json:"emoji"` + IconProp map[string]interface{} `json:"icon_prop"` + ProjectLeadID *string `json:"project_lead_id"` + DefaultAssigneeID *string `json:"default_assignee_id"` + GuestViewAllFeatures *bool `json:"guest_view_all_features"` + ModuleView *bool `json:"module_view"` + CycleView *bool `json:"cycle_view"` + IssueViewsView *bool `json:"issue_views_view"` + PageView *bool `json:"page_view"` + IntakeView *bool `json:"intake_view"` + IsTimeTrackingEnabled *bool `json:"is_time_tracking_enabled"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "detail": err.Error()}) @@ -147,6 +151,19 @@ func (h *ProjectHandler) Update(c *gin.Context) { if body.Timezone != nil { timezone = body.Timezone } + var coverImage *string + if body.CoverImage != nil { + coverImage = body.CoverImage + } + var iconProp *model.JSONMap + if body.Emoji != nil && *body.Emoji != "" { + // When setting emoji, clear icon_prop + empty := model.JSONMap{} + iconProp = &empty + } else if len(body.IconProp) > 0 { + ip := model.JSONMap(body.IconProp) + iconProp = &ip + } var projectLeadIDPtr *uuid.UUID if body.ProjectLeadID != nil { if *body.ProjectLeadID == "" { @@ -173,7 +190,7 @@ func (h *ProjectHandler) Update(c *gin.Context) { defaultAssigneeIDPtr = &id } } - p, err := h.Project.Update(c.Request.Context(), slug, projectID, user.ID, name, identifier, description, timezone, body.ProjectLeadID != nil, projectLeadIDPtr, body.DefaultAssigneeID != nil, defaultAssigneeIDPtr, body.GuestViewAllFeatures, body.ModuleView, body.CycleView, body.IssueViewsView, body.PageView, body.IntakeView, body.IsTimeTrackingEnabled) + p, err := h.Project.Update(c.Request.Context(), slug, projectID, user.ID, name, identifier, description, timezone, coverImage, body.Emoji, iconProp, body.ProjectLeadID != nil, projectLeadIDPtr, body.DefaultAssigneeID != nil, defaultAssigneeIDPtr, body.GuestViewAllFeatures, body.ModuleView, body.CycleView, body.IssueViewsView, body.PageView, body.IntakeView, body.IsTimeTrackingEnabled) if err != nil { if err == service.ErrProjectNotFound || err == service.ErrProjectForbidden { c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) diff --git a/api/internal/handler/recent_visit.go b/api/internal/handler/recent_visit.go index c25b8853..39c8f378 100644 --- a/api/internal/handler/recent_visit.go +++ b/api/internal/handler/recent_visit.go @@ -52,9 +52,9 @@ func (h *RecentVisitHandler) Record(c *gin.Context) { } slug := c.Param("slug") var body struct { - EntityName string `json:"entity_name" binding:"required"` // "issue", "project", "page" - EntityIdentifier *uuid.UUID `json:"entity_identifier"` - ProjectID *uuid.UUID `json:"project_id"` + EntityName string `json:"entity_name" binding:"required"` // "issue", "project", "page" + EntityIdentifier *uuid.UUID `json:"entity_identifier"` + ProjectID *uuid.UUID `json:"project_id"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) diff --git a/api/internal/handler/upload.go b/api/internal/handler/upload.go new file mode 100644 index 00000000..62ffbc2f --- /dev/null +++ b/api/internal/handler/upload.go @@ -0,0 +1,121 @@ +package handler + +import ( + "bytes" + "fmt" + "io" + "net/http" + "path/filepath" + "strings" + "time" + + "github.com/Devlaner/devlane/api/internal/middleware" + "github.com/Devlaner/devlane/api/internal/minio" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// UploadHandler handles file uploads to MinIO. +type UploadHandler struct { + Minio *minio.Client +} + +var allowedImageTypes = map[string]bool{ + "image/jpeg": true, + "image/jpg": true, + "image/png": true, + "image/webp": true, +} + +// Upload accepts a multipart file and uploads it to MinIO. +// POST /api/upload +// Form: file (required). Returns { "url": "/api/files/uploads/..." }. +func (h *UploadHandler) Upload(c *gin.Context) { + if h.Minio == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "File upload is not configured"}) + return + } + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + + file, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "No file provided", "detail": err.Error()}) + return + } + + f, err := file.Open() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read file"}) + return + } + defer f.Close() + + // Detect MIME from file bytes instead of trusting client Content-Type + buf := make([]byte, 512) + n, _ := io.ReadFull(f, buf) + buf = buf[:n] + contentType := http.DetectContentType(buf) + // Go's DetectContentType may not recognize WebP; check magic bytes (RIFF....WEBP) + if contentType == "application/octet-stream" && n >= 12 && string(buf[0:4]) == "RIFF" && string(buf[8:12]) == "WEBP" { + contentType = "image/webp" + } + if !allowedImageTypes[contentType] { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file type. Supported: .jpeg, .jpg, .png, .webp"}) + return + } + + // Recombine read bytes with remainder for upload + body := io.MultiReader(bytes.NewReader(buf), f) + + ext := strings.ToLower(filepath.Ext(file.Filename)) + if ext == "" { + ext = ".jpg" + } + if ext != ".jpeg" && ext != ".jpg" && ext != ".png" && ext != ".webp" { + ext = ".jpg" + } + + now := time.Now().UTC() + objectName := fmt.Sprintf("uploads/%d/%02d/%s%s", now.Year(), now.Month(), uuid.New().String(), ext) + + if err := h.Minio.PutObject(c.Request.Context(), objectName, body, file.Size, contentType); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to upload file"}) + return + } + + c.JSON(http.StatusOK, gin.H{"url": "/api/files/" + objectName}) +} + +// ServeFile streams a file from MinIO by path. +// GET /api/files/*path +func (h *UploadHandler) ServeFile(c *gin.Context) { + if h.Minio == nil { + c.Status(http.StatusServiceUnavailable) + return + } + path := strings.TrimPrefix(c.Param("path"), "/") + if path == "" || strings.Contains(path, "..") || !strings.HasPrefix(path, "uploads/") { + c.Status(http.StatusBadRequest) + return + } + + obj, err := h.Minio.GetObject(c.Request.Context(), path) + if err != nil { + c.Status(http.StatusNotFound) + return + } + defer obj.Close() + + info, err := obj.Stat() + if err != nil { + c.Status(http.StatusNotFound) + return + } + + c.Header("Content-Type", info.ContentType) + c.DataFromReader(http.StatusOK, info.Size, info.ContentType, obj, nil) +} diff --git a/api/internal/handler/workspace.go b/api/internal/handler/workspace.go index 0abfc0e5..9c6489d8 100644 --- a/api/internal/handler/workspace.go +++ b/api/internal/handler/workspace.go @@ -14,7 +14,7 @@ import ( // WorkspaceHandler serves workspace and member/invite endpoints. type WorkspaceHandler struct { Workspace *service.WorkspaceService - Settings *store.InstanceSettingStore + Settings *store.InstanceSettingStore } // List returns the current user's workspaces. @@ -113,8 +113,9 @@ func (h *WorkspaceHandler) Update(c *gin.Context) { } slug := c.Param("slug") var body struct { - Name string `json:"name"` - Slug string `json:"slug"` + Name string `json:"name"` + Slug string `json:"slug"` + Logo *string `json:"logo"` } _ = c.ShouldBindJSON(&body) var name, newSlug *string @@ -124,7 +125,7 @@ func (h *WorkspaceHandler) Update(c *gin.Context) { if body.Slug != "" { newSlug = &body.Slug } - w, err := h.Workspace.Update(c.Request.Context(), slug, user.ID, name, newSlug) + w, err := h.Workspace.Update(c.Request.Context(), slug, user.ID, name, newSlug, body.Logo) if err != nil { if err == service.ErrWorkspaceNotFound || err == service.ErrWorkspaceForbidden { c.JSON(http.StatusNotFound, gin.H{"error": "Workspace not found"}) diff --git a/api/internal/handler/workspace_link.go b/api/internal/handler/workspace_link.go index b3b9fa83..5500c29a 100644 --- a/api/internal/handler/workspace_link.go +++ b/api/internal/handler/workspace_link.go @@ -45,9 +45,9 @@ func (h *WorkspaceLinkHandler) Create(c *gin.Context) { } slug := c.Param("slug") var body struct { - Title string `json:"title"` - URL string `json:"url" binding:"required"` - ProjectID *uuid.UUID `json:"project_id"` + Title string `json:"title"` + URL string `json:"url" binding:"required"` + ProjectID *uuid.UUID `json:"project_id"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) diff --git a/api/internal/minio/minio.go b/api/internal/minio/minio.go index 5b6b0542..5c2f7ac8 100644 --- a/api/internal/minio/minio.go +++ b/api/internal/minio/minio.go @@ -3,6 +3,7 @@ package minio import ( "context" "fmt" + "io" "log/slog" "github.com/Devlaner/devlane/api/internal/config" @@ -53,3 +54,14 @@ func New(cfg *config.Config, log *slog.Logger) (*Client, error) { func (c *Client) Bucket() string { return c.bucket } + +// PutObject uploads data to the default bucket. +func (c *Client) PutObject(ctx context.Context, objectName string, reader io.Reader, size int64, contentType string) error { + _, err := c.Client.PutObject(ctx, c.bucket, objectName, reader, size, minio.PutObjectOptions{ContentType: contentType}) + return err +} + +// GetObject returns a reader for the object from the default bucket. +func (c *Client) GetObject(ctx context.Context, objectName string) (*minio.Object, error) { + return c.Client.GetObject(ctx, c.bucket, objectName, minio.GetObjectOptions{}) +} diff --git a/api/internal/model/api_token.go b/api/internal/model/api_token.go index c0842885..4b837aa5 100644 --- a/api/internal/model/api_token.go +++ b/api/internal/model/api_token.go @@ -9,19 +9,19 @@ import ( // ApiToken matches api_tokens. type ApiToken struct { - ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` - Label string `gorm:"type:varchar(255);not null" json:"label"` - Description string `gorm:"type:text" json:"description,omitempty"` - Token string `gorm:"type:varchar(255);not null;uniqueIndex" json:"token"` - UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"` - WorkspaceID *uuid.UUID `gorm:"type:uuid" json:"workspace_id,omitempty"` - IsActive bool `gorm:"column:is_active;default:true" json:"is_active"` - LastUsed *time.Time `gorm:"type:timestamptz" json:"last_used,omitempty"` - ExpiredAt *time.Time `gorm:"type:timestamptz" json:"expired_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` - UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + Label string `gorm:"type:varchar(255);not null" json:"label"` + Description string `gorm:"type:text" json:"description,omitempty"` + Token string `gorm:"type:varchar(255);not null;uniqueIndex" json:"token"` + UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"` + WorkspaceID *uuid.UUID `gorm:"type:uuid" json:"workspace_id,omitempty"` + IsActive bool `gorm:"column:is_active;default:true" json:"is_active"` + LastUsed *time.Time `gorm:"type:timestamptz" json:"last_used,omitempty"` + ExpiredAt *time.Time `gorm:"type:timestamptz" json:"expired_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` + UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` } func (ApiToken) TableName() string { return "api_tokens" } diff --git a/api/internal/model/cycle.go b/api/internal/model/cycle.go index 0feccfcd..6290b059 100644 --- a/api/internal/model/cycle.go +++ b/api/internal/model/cycle.go @@ -9,25 +9,25 @@ import ( // Cycle matches cycles. type Cycle struct { - ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` - Name string `gorm:"type:varchar(255);not null" json:"name"` - Description string `gorm:"type:text" json:"description,omitempty"` - StartDate *time.Time `gorm:"type:timestamptz" json:"start_date,omitempty"` - EndDate *time.Time `gorm:"type:timestamptz" json:"end_date,omitempty"` - Status string `gorm:"type:varchar(255);default:draft" json:"status"` - ProjectID uuid.UUID `gorm:"type:uuid;not null" json:"project_id"` - WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` - IssueCount int `gorm:"-" json:"issue_count,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` - OwnedByID uuid.UUID `gorm:"type:uuid;not null" json:"owned_by_id"` - UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` - SortOrder float64 `gorm:"column:sort_order;default:65535" json:"sort_order"` - ArchivedAt *time.Time `gorm:"type:timestamptz" json:"archived_at,omitempty"` - Timezone string `gorm:"default:UTC" json:"timezone"` - Version int `gorm:"default:1" json:"version"` + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + Name string `gorm:"type:varchar(255);not null" json:"name"` + Description string `gorm:"type:text" json:"description,omitempty"` + StartDate *time.Time `gorm:"type:timestamptz" json:"start_date,omitempty"` + EndDate *time.Time `gorm:"type:timestamptz" json:"end_date,omitempty"` + Status string `gorm:"type:varchar(255);default:draft" json:"status"` + ProjectID uuid.UUID `gorm:"type:uuid;not null" json:"project_id"` + WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` + IssueCount int `gorm:"-" json:"issue_count,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` + OwnedByID uuid.UUID `gorm:"type:uuid;not null" json:"owned_by_id"` + UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` + SortOrder float64 `gorm:"column:sort_order;default:65535" json:"sort_order"` + ArchivedAt *time.Time `gorm:"type:timestamptz" json:"archived_at,omitempty"` + Timezone string `gorm:"default:UTC" json:"timezone"` + Version int `gorm:"default:1" json:"version"` } func (Cycle) TableName() string { return "cycles" } @@ -42,15 +42,15 @@ func (c *Cycle) BeforeCreate(tx *gorm.DB) error { // CycleIssue matches cycle_issues (M2M). type CycleIssue struct { ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` - CycleID uuid.UUID `gorm:"type:uuid;not null" json:"cycle_id"` - IssueID uuid.UUID `gorm:"type:uuid;not null" json:"issue_id"` - ProjectID uuid.UUID `gorm:"type:uuid;not null" json:"project_id"` - WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` - UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` + CycleID uuid.UUID `gorm:"type:uuid;not null" json:"cycle_id"` + IssueID uuid.UUID `gorm:"type:uuid;not null" json:"issue_id"` + ProjectID uuid.UUID `gorm:"type:uuid;not null" json:"project_id"` + WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` + UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` } func (CycleIssue) TableName() string { return "cycle_issues" } diff --git a/api/internal/model/issue.go b/api/internal/model/issue.go index 7e3afec7..b7aa2622 100644 --- a/api/internal/model/issue.go +++ b/api/internal/model/issue.go @@ -9,30 +9,30 @@ import ( // Issue matches migration table "issues". type Issue struct { - ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` - Name string `gorm:"type:varchar(255);not null" json:"name"` - Description JSONMap `gorm:"type:jsonb;serializer:json" json:"description,omitempty"` - DescriptionHTML string `gorm:"column:description_html;type:text" json:"description_html,omitempty"` - Priority string `gorm:"type:varchar(30)" json:"priority,omitempty"` - StartDate *time.Time `gorm:"type:date" json:"start_date,omitempty"` - TargetDate *time.Time `gorm:"type:date" json:"target_date,omitempty"` - SequenceID int `gorm:"column:sequence_id;default:1" json:"sequence_id"` - ProjectID uuid.UUID `gorm:"type:uuid;not null" json:"project_id"` - WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` - StateID *uuid.UUID `gorm:"type:uuid" json:"state_id,omitempty"` - ParentID *uuid.UUID `gorm:"type:uuid" json:"parent_id,omitempty"` - AssigneeIDs []uuid.UUID `gorm:"-" json:"assignee_ids,omitempty"` - LabelIDs []uuid.UUID `gorm:"-" json:"label_ids,omitempty"` - CycleIDs []uuid.UUID `gorm:"-" json:"cycle_ids,omitempty"` - ModuleIDs []uuid.UUID `gorm:"-" json:"module_ids,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` - UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` - SortOrder float64 `gorm:"column:sort_order;default:65535" json:"sort_order"` - ArchivedAt *time.Time `gorm:"type:timestamptz" json:"archived_at,omitempty"` - IsDraft bool `gorm:"column:is_draft;default:false" json:"is_draft"` + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + Name string `gorm:"type:varchar(255);not null" json:"name"` + Description JSONMap `gorm:"type:jsonb;serializer:json" json:"description,omitempty"` + DescriptionHTML string `gorm:"column:description_html;type:text" json:"description_html,omitempty"` + Priority string `gorm:"type:varchar(30)" json:"priority,omitempty"` + StartDate *time.Time `gorm:"type:date" json:"start_date,omitempty"` + TargetDate *time.Time `gorm:"type:date" json:"target_date,omitempty"` + SequenceID int `gorm:"column:sequence_id;default:1" json:"sequence_id"` + ProjectID uuid.UUID `gorm:"type:uuid;not null" json:"project_id"` + WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` + StateID *uuid.UUID `gorm:"type:uuid" json:"state_id,omitempty"` + ParentID *uuid.UUID `gorm:"type:uuid" json:"parent_id,omitempty"` + AssigneeIDs []uuid.UUID `gorm:"-" json:"assignee_ids,omitempty"` + LabelIDs []uuid.UUID `gorm:"-" json:"label_ids,omitempty"` + CycleIDs []uuid.UUID `gorm:"-" json:"cycle_ids,omitempty"` + ModuleIDs []uuid.UUID `gorm:"-" json:"module_ids,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` + UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` + SortOrder float64 `gorm:"column:sort_order;default:65535" json:"sort_order"` + ArchivedAt *time.Time `gorm:"type:timestamptz" json:"archived_at,omitempty"` + IsDraft bool `gorm:"column:is_draft;default:false" json:"is_draft"` } func (Issue) TableName() string { return "issues" } @@ -83,4 +83,3 @@ func (l *IssueLabel) BeforeCreate(tx *gorm.DB) error { } return nil } - diff --git a/api/internal/model/issue_view.go b/api/internal/model/issue_view.go index 3b7adbc4..05c9a05d 100644 --- a/api/internal/model/issue_view.go +++ b/api/internal/model/issue_view.go @@ -16,12 +16,12 @@ type IssueView struct { Filters JSONMap `gorm:"type:jsonb;default:{};serializer:json" json:"filters,omitempty"` DisplayFilters JSONMap `gorm:"type:jsonb;default:{};serializer:json" json:"display_filters,omitempty"` DisplayProperties JSONMap `gorm:"type:jsonb;default:{};serializer:json" json:"display_properties,omitempty"` - RichFilters JSONMap `gorm:"type:jsonb;default:{};serializer:json" json:"rich_filters,omitempty"` + RichFilters JSONMap `gorm:"type:jsonb;default:{};serializer:json" json:"rich_filters,omitempty"` Access int16 `gorm:"default:1" json:"access"` // 0 private, 1 public SortOrder float64 `gorm:"column:sort_order;default:65535" json:"sort_order"` LogoProps JSONMap `gorm:"type:jsonb;default:{};serializer:json" json:"logo_props,omitempty"` OwnedByID uuid.UUID `gorm:"type:uuid;not null" json:"owned_by_id"` - IsLocked bool `gorm:"column:is_locked;default:false" json:"is_locked"` + IsLocked bool `gorm:"column:is_locked;default:false" json:"is_locked"` WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` ProjectID *uuid.UUID `gorm:"type:uuid" json:"project_id,omitempty"` CreatedAt time.Time `json:"created_at"` diff --git a/api/internal/model/module.go b/api/internal/model/module.go index 3d530f65..4dbed79e 100644 --- a/api/internal/model/module.go +++ b/api/internal/model/module.go @@ -9,22 +9,22 @@ import ( // Module matches modules. type Module struct { - ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` - Name string `gorm:"type:varchar(255);not null" json:"name"` - Description string `gorm:"type:text" json:"description,omitempty"` - StartDate *time.Time `gorm:"type:date" json:"start_date,omitempty"` - TargetDate *time.Time `gorm:"type:date" json:"target_date,omitempty"` - Status string `gorm:"type:varchar(50);default:backlog" json:"status"` - ProjectID uuid.UUID `gorm:"type:uuid;not null" json:"project_id"` - WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` - IssueCount int `gorm:"-" json:"issue_count,omitempty"` - LeadID *uuid.UUID `gorm:"type:uuid" json:"lead_id,omitempty"` - SortOrder float64 `gorm:"column:sort_order;default:65535" json:"sort_order"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` - UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + Name string `gorm:"type:varchar(255);not null" json:"name"` + Description string `gorm:"type:text" json:"description,omitempty"` + StartDate *time.Time `gorm:"type:date" json:"start_date,omitempty"` + TargetDate *time.Time `gorm:"type:date" json:"target_date,omitempty"` + Status string `gorm:"type:varchar(50);default:backlog" json:"status"` + ProjectID uuid.UUID `gorm:"type:uuid;not null" json:"project_id"` + WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` + IssueCount int `gorm:"-" json:"issue_count,omitempty"` + LeadID *uuid.UUID `gorm:"type:uuid" json:"lead_id,omitempty"` + SortOrder float64 `gorm:"column:sort_order;default:65535" json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` + UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` } func (Module) TableName() string { return "modules" } @@ -38,13 +38,13 @@ func (m *Module) BeforeCreate(tx *gorm.DB) error { // ModuleIssue matches module_issues (M2M). type ModuleIssue struct { - ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` - ModuleID uuid.UUID `gorm:"type:uuid;not null" json:"module_id"` - IssueID uuid.UUID `gorm:"type:uuid;not null" json:"issue_id"` - ProjectID uuid.UUID `gorm:"type:uuid;not null" json:"project_id"` - WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + ModuleID uuid.UUID `gorm:"type:uuid;not null" json:"module_id"` + IssueID uuid.UUID `gorm:"type:uuid;not null" json:"issue_id"` + ProjectID uuid.UUID `gorm:"type:uuid;not null" json:"project_id"` + WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` } diff --git a/api/internal/model/page.go b/api/internal/model/page.go index 8ccd8e69..d8285542 100644 --- a/api/internal/model/page.go +++ b/api/internal/model/page.go @@ -9,21 +9,21 @@ import ( // Page matches pages. type Page struct { - ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` - Name string `gorm:"type:text" json:"name"` - DescriptionHTML string `gorm:"column:description_html;type:text;default:

" json:"description_html,omitempty"` - OwnedByID uuid.UUID `gorm:"type:uuid;not null" json:"owned_by_id"` - WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` - Access int16 `gorm:"default:0" json:"access"` - ParentID *uuid.UUID `gorm:"type:uuid" json:"parent_id,omitempty"` - ArchivedAt *time.Time `gorm:"type:timestamptz" json:"archived_at,omitempty"` - IsLocked bool `gorm:"column:is_locked;default:false" json:"is_locked"` - SortOrder float64 `gorm:"column:sort_order;default:65535" json:"sort_order"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` - UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + Name string `gorm:"type:text" json:"name"` + DescriptionHTML string `gorm:"column:description_html;type:text;default:

" json:"description_html,omitempty"` + OwnedByID uuid.UUID `gorm:"type:uuid;not null" json:"owned_by_id"` + WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` + Access int16 `gorm:"default:0" json:"access"` + ParentID *uuid.UUID `gorm:"type:uuid" json:"parent_id,omitempty"` + ArchivedAt *time.Time `gorm:"type:timestamptz" json:"archived_at,omitempty"` + IsLocked bool `gorm:"column:is_locked;default:false" json:"is_locked"` + SortOrder float64 `gorm:"column:sort_order;default:65535" json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` + UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` } func (Page) TableName() string { return "pages" } diff --git a/api/internal/model/profile.go b/api/internal/model/profile.go index 285ec29f..f4f3fe70 100644 --- a/api/internal/model/profile.go +++ b/api/internal/model/profile.go @@ -8,13 +8,13 @@ import ( // Profile matches profiles. type Profile struct { - ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` - UserID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex" json:"user_id"` - Role string `gorm:"type:varchar(300)" json:"role,omitempty"` + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + UserID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex" json:"user_id"` + Role string `gorm:"type:varchar(300)" json:"role,omitempty"` LastWorkspaceID *uuid.UUID `gorm:"type:uuid" json:"last_workspace_id,omitempty"` - Language string `gorm:"type:varchar(255);default:en" json:"language"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Language string `gorm:"type:varchar(255);default:en" json:"language"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (Profile) TableName() string { return "profiles" } diff --git a/api/internal/model/project.go b/api/internal/model/project.go index cebaa86e..3c6fe192 100644 --- a/api/internal/model/project.go +++ b/api/internal/model/project.go @@ -27,31 +27,32 @@ func (m *JSONMap) Scan(v interface{}) error { // Project matches migration table "projects". type Project struct { - ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` - WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` - Name string `gorm:"type:varchar(255);not null" json:"name"` - Description string `gorm:"type:text" json:"description,omitempty"` - Identifier string `gorm:"type:varchar(12)" json:"identifier,omitempty"` - Slug string `gorm:"type:varchar(100)" json:"slug,omitempty"` - Network int16 `gorm:"default:2" json:"network"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` - DefaultAssigneeID *uuid.UUID `gorm:"type:uuid" json:"default_assignee_id,omitempty"` - ProjectLeadID *uuid.UUID `gorm:"type:uuid" json:"project_lead_id,omitempty"` - UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` - DefaultStateID *uuid.UUID `gorm:"type:uuid" json:"default_state_id,omitempty"` - Emoji string `gorm:"type:varchar(10)" json:"emoji,omitempty"` - IconProp JSONMap `gorm:"column:icon_prop;type:jsonb;serializer:json" json:"icon_prop,omitempty"` - ModuleView bool `gorm:"default:true" json:"module_view"` - CycleView bool `gorm:"default:true" json:"cycle_view"` - IssueViewsView bool `gorm:"column:issue_views_view;default:true" json:"issue_views_view"` - PageView bool `gorm:"default:true" json:"page_view"` - IntakeView bool `gorm:"default:true" json:"intake_view"` - IsTimeTrackingEnabled bool `gorm:"column:is_time_tracking_enabled;default:false" json:"is_time_tracking_enabled"` - GuestViewAllFeatures bool `gorm:"column:guest_view_all_features;default:false" json:"guest_view_all_features"` - Timezone string `gorm:"default:UTC" json:"timezone"` + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` + Name string `gorm:"type:varchar(255);not null" json:"name"` + Description string `gorm:"type:text" json:"description,omitempty"` + Identifier string `gorm:"type:varchar(12)" json:"identifier,omitempty"` + Slug string `gorm:"type:varchar(100)" json:"slug,omitempty"` + Network int16 `gorm:"default:2" json:"network"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` + DefaultAssigneeID *uuid.UUID `gorm:"type:uuid" json:"default_assignee_id,omitempty"` + ProjectLeadID *uuid.UUID `gorm:"type:uuid" json:"project_lead_id,omitempty"` + UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` + DefaultStateID *uuid.UUID `gorm:"type:uuid" json:"default_state_id,omitempty"` + Emoji string `gorm:"type:varchar(10)" json:"emoji,omitempty"` + IconProp JSONMap `gorm:"column:icon_prop;type:jsonb;serializer:json" json:"icon_prop,omitempty"` + ModuleView bool `gorm:"default:true" json:"module_view"` + CycleView bool `gorm:"default:true" json:"cycle_view"` + IssueViewsView bool `gorm:"column:issue_views_view;default:true" json:"issue_views_view"` + PageView bool `gorm:"default:true" json:"page_view"` + IntakeView bool `gorm:"default:true" json:"intake_view"` + IsTimeTrackingEnabled bool `gorm:"column:is_time_tracking_enabled;default:false" json:"is_time_tracking_enabled"` + GuestViewAllFeatures bool `gorm:"column:guest_view_all_features;default:false" json:"guest_view_all_features"` + CoverImage string `gorm:"column:cover_image;type:text" json:"cover_image,omitempty"` + Timezone string `gorm:"default:UTC" json:"timezone"` } func (Project) TableName() string { return "projects" } diff --git a/api/internal/model/user.go b/api/internal/model/user.go index f0791687..ef25a062 100644 --- a/api/internal/model/user.go +++ b/api/internal/model/user.go @@ -17,6 +17,7 @@ type User struct { LastName string `gorm:"column:last_name;type:varchar(255);default:''" json:"last_name"` DisplayName string `gorm:"column:display_name;type:varchar(255)" json:"display_name"` Avatar string `gorm:"type:text" json:"avatar,omitempty"` + CoverImage string `gorm:"column:cover_image;type:text" json:"cover_image,omitempty"` DateJoined time.Time `gorm:"column:date_joined;not null" json:"date_joined"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/api/internal/model/user_favorite.go b/api/internal/model/user_favorite.go index d8abe82f..47a2867c 100644 --- a/api/internal/model/user_favorite.go +++ b/api/internal/model/user_favorite.go @@ -9,18 +9,18 @@ import ( // UserFavorite matches user_favorites. type UserFavorite struct { - ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` - Name string `gorm:"type:varchar(255);not null" json:"name"` - Type string `gorm:"type:varchar(50);not null" json:"type"` - EntityType string `gorm:"type:varchar(50);not null" json:"entity_type"` - EntityIdentifier uuid.UUID `gorm:"type:uuid;not null" json:"entity_identifier"` - WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` - ProjectID *uuid.UUID `gorm:"type:uuid" json:"project_id,omitempty"` - UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` - UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + Name string `gorm:"type:varchar(255);not null" json:"name"` + Type string `gorm:"type:varchar(50);not null" json:"type"` + EntityType string `gorm:"type:varchar(50);not null;uniqueIndex:idx_user_fav_entity" json:"entity_type"` + EntityIdentifier uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_user_fav_entity" json:"entity_identifier"` + WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` + ProjectID *uuid.UUID `gorm:"type:uuid" json:"project_id,omitempty"` + UserID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_user_fav_entity" json:"user_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` + UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` } func (UserFavorite) TableName() string { return "user_favorites" } diff --git a/api/internal/model/webhook.go b/api/internal/model/webhook.go index 74deb591..061ede43 100644 --- a/api/internal/model/webhook.go +++ b/api/internal/model/webhook.go @@ -9,16 +9,16 @@ import ( // Webhook matches webhooks. type Webhook struct { - ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` - URL string `gorm:"type:text;not null" json:"url"` - SecretKey string `gorm:"type:varchar(255)" json:"secret_key,omitempty"` - IsActive bool `gorm:"column:is_active;default:true" json:"is_active"` - WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` - ProjectID *uuid.UUID `gorm:"type:uuid" json:"project_id,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` - UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + URL string `gorm:"type:text;not null" json:"url"` + SecretKey string `gorm:"type:varchar(255)" json:"secret_key,omitempty"` + IsActive bool `gorm:"column:is_active;default:true" json:"is_active"` + WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` + ProjectID *uuid.UUID `gorm:"type:uuid" json:"project_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` + UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` } func (Webhook) TableName() string { return "webhooks" } diff --git a/api/internal/model/workspace.go b/api/internal/model/workspace.go index f0af2086..3654afd7 100644 --- a/api/internal/model/workspace.go +++ b/api/internal/model/workspace.go @@ -9,19 +9,19 @@ import ( // Workspace matches migration table "workspaces". type Workspace struct { - ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` - Name string `gorm:"type:varchar(255);not null" json:"name"` - Logo string `gorm:"type:text" json:"logo,omitempty"` - Slug string `gorm:"type:varchar(100);uniqueIndex;not null" json:"slug"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` - OwnerID uuid.UUID `gorm:"type:uuid;not null" json:"owner_id"` - UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` - OrganizationSize string `gorm:"column:organization_size;type:varchar(50)" json:"organization_size,omitempty"` - Timezone string `gorm:"default:UTC" json:"timezone"` - BackgroundColor string `gorm:"column:background_color;type:varchar(255)" json:"background_color,omitempty"` + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + Name string `gorm:"type:varchar(255);not null" json:"name"` + Logo string `gorm:"type:text" json:"logo,omitempty"` + Slug string `gorm:"type:varchar(100);uniqueIndex;not null" json:"slug"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` + OwnerID uuid.UUID `gorm:"type:uuid;not null" json:"owner_id"` + UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` + OrganizationSize string `gorm:"column:organization_size;type:varchar(50)" json:"organization_size,omitempty"` + Timezone string `gorm:"default:UTC" json:"timezone"` + BackgroundColor string `gorm:"column:background_color;type:varchar(255)" json:"background_color,omitempty"` } func (Workspace) TableName() string { return "workspaces" } @@ -35,16 +35,16 @@ func (w *Workspace) BeforeCreate(tx *gorm.DB) error { // WorkspaceMember matches migration table "workspace_members". type WorkspaceMember struct { - ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` - WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` - MemberID uuid.UUID `gorm:"type:uuid;not null" json:"member_id"` - Role int16 `gorm:"not null;default:10" json:"role"` - MemberDisplayName string `gorm:"column:member_display_name;->" json:"member_display_name,omitempty"` - MemberEmail *string `gorm:"column:member_email;->" json:"member_email,omitempty"` - MemberAvatar string `gorm:"column:member_avatar;->" json:"member_avatar,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` + MemberID uuid.UUID `gorm:"type:uuid;not null" json:"member_id"` + Role int16 `gorm:"not null;default:10" json:"role"` + MemberDisplayName string `gorm:"column:member_display_name;->" json:"member_display_name,omitempty"` + MemberEmail *string `gorm:"column:member_email;->" json:"member_email,omitempty"` + MemberAvatar string `gorm:"column:member_avatar;->" json:"member_avatar,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } func (WorkspaceMember) TableName() string { return "workspace_members" } diff --git a/api/internal/queue/consumer.go b/api/internal/queue/consumer.go index 4710a97d..8cfc7c2c 100644 --- a/api/internal/queue/consumer.go +++ b/api/internal/queue/consumer.go @@ -13,8 +13,8 @@ type TaskHandler func(ctx context.Context, queue string, body []byte) error // Consumer consumes from RabbitMQ queues and dispatches to handlers. type Consumer struct { - ch *amqp.Channel - log *slog.Logger + ch *amqp.Channel + log *slog.Logger handlers map[string]TaskHandler } @@ -99,8 +99,8 @@ func HandleSendEmail(sender func(ctx context.Context, to, subject, body string) func HandleWebhook(deliverer func(ctx context.Context, url, secret, event string, payload map[string]interface{}) error) TaskHandler { return func(ctx context.Context, queue string, body []byte) error { var msg struct { - Type string `json:"type"` - Payload WebhookPayload `json:"payload"` + Type string `json:"type"` + Payload WebhookPayload `json:"payload"` } if err := json.Unmarshal(body, &msg); err != nil { return err diff --git a/api/internal/queue/queue.go b/api/internal/queue/queue.go index 0afa5f03..3acbe0bb 100644 --- a/api/internal/queue/queue.go +++ b/api/internal/queue/queue.go @@ -19,7 +19,7 @@ const ( // Task types for routing or payload identification. const ( - TaskSendEmail = "send_email" + TaskSendEmail = "send_email" TaskWebhookDeliver = "webhook_deliver" ) @@ -42,8 +42,8 @@ type WebhookPayload struct { // Publisher publishes tasks to RabbitMQ. type Publisher struct { - ch *amqp.Channel - log *slog.Logger + ch *amqp.Channel + log *slog.Logger queues map[string]bool } diff --git a/api/internal/router/router.go b/api/internal/router/router.go index c089f974..e4f389ff 100644 --- a/api/internal/router/router.go +++ b/api/internal/router/router.go @@ -6,6 +6,7 @@ import ( "github.com/Devlaner/devlane/api/internal/auth" "github.com/Devlaner/devlane/api/internal/handler" "github.com/Devlaner/devlane/api/internal/middleware" + "github.com/Devlaner/devlane/api/internal/minio" "github.com/Devlaner/devlane/api/internal/queue" "github.com/Devlaner/devlane/api/internal/redis" "github.com/Devlaner/devlane/api/internal/service" @@ -16,11 +17,12 @@ import ( // Config holds dependencies for the router. type Config struct { - Log *slog.Logger - DB *gorm.DB - Redis *redis.Client // optional: cache, locks, magic-link - Queue *queue.Publisher // optional: enqueue emails, webhooks - CORSAllowOrigin string // optional: e.g. "http://localhost:5173" for UI dev + Log *slog.Logger + DB *gorm.DB + Redis *redis.Client // optional: cache, locks, magic-link + Queue *queue.Publisher // optional: enqueue emails, webhooks + Minio *minio.Client // optional: file uploads (cover images, avatars, logos) + CORSAllowOrigin string // optional: e.g. "http://localhost:5173" for UI dev } // New builds and returns the Gin engine with /api/ and /auth/ routes. @@ -64,6 +66,7 @@ func New(cfg Config) *gin.Engine { userRecentVisitStore := store.NewUserRecentVisitStore(cfg.DB) userNotifPrefStore := store.NewUserNotificationPreferenceStore(cfg.DB) apiTokenStore := store.NewApiTokenStore(cfg.DB) + userFavoriteStore := store.NewUserFavoriteStore(cfg.DB) // Auth authSvc := auth.NewService(userStore, sessionStore) @@ -94,6 +97,7 @@ func New(cfg Config) *gin.Engine { // Handlers workspaceHandler := &handler.WorkspaceHandler{Workspace: workspaceSvc, Settings: instanceSettingStore} projectHandler := &handler.ProjectHandler{Project: projectSvc} + favoriteHandler := &handler.FavoriteHandler{Project: projectSvc, Favorites: userFavoriteStore} stateHandler := &handler.StateHandler{State: stateSvc} labelHandler := &handler.LabelHandler{Label: labelSvc} issueHandler := &handler.IssueHandler{Issue: issueSvc} @@ -121,8 +125,14 @@ func New(cfg Config) *gin.Engine { api.GET("/users/me/tokens/", authHandler.ListTokens) api.POST("/users/me/tokens/", authHandler.CreateToken) api.DELETE("/users/me/tokens/:id/", authHandler.RevokeToken) + api.GET("/users/me/favorite-projects/", favoriteHandler.ListFavoriteProjects) api.GET("/instance/settings/", instanceSettingsHandler.GetSettings) api.PATCH("/instance/settings/:key", instanceSettingsHandler.UpdateSetting) + api.GET("/instance/unsplash/search", instanceSettingsHandler.UnsplashSearch) + + uploadHandler := &handler.UploadHandler{Minio: cfg.Minio} + api.POST("/upload", uploadHandler.Upload) + api.GET("/files/*path", uploadHandler.ServeFile) api.GET("/users/me/workspaces/", workspaceHandler.List) api.GET("/users/me/workspaces/invitations/", workspaceHandler.ListUserInvitations) api.GET("/workspace-slug-check/", workspaceHandler.SlugCheck) @@ -149,6 +159,8 @@ func New(cfg Config) *gin.Engine { api.GET("/workspaces/:slug/projects/:projectId/", projectHandler.Get) api.PATCH("/workspaces/:slug/projects/:projectId/", projectHandler.Update) api.DELETE("/workspaces/:slug/projects/:projectId/", projectHandler.Delete) + api.POST("/workspaces/:slug/projects/:projectId/favorite", favoriteHandler.AddFavoriteProject) + api.DELETE("/workspaces/:slug/projects/:projectId/favorite", favoriteHandler.RemoveFavoriteProject) api.GET("/workspaces/:slug/projects/:projectId/members/", projectHandler.ListMembers) api.POST("/workspaces/:slug/projects/:projectId/members/leave/", projectHandler.Leave) api.GET("/workspaces/:slug/projects/:projectId/members/:pk/", projectHandler.GetMember) diff --git a/api/internal/service/issue.go b/api/internal/service/issue.go index 3df8e61d..ff3eefc0 100644 --- a/api/internal/service/issue.go +++ b/api/internal/service/issue.go @@ -12,7 +12,7 @@ import ( ) var ( - ErrIssueNotFound = errors.New("issue not found") + ErrIssueNotFound = errors.New("issue not found") ) // IssueService handles issue business logic. diff --git a/api/internal/service/issue_view.go b/api/internal/service/issue_view.go index e68bb782..68e9b476 100644 --- a/api/internal/service/issue_view.go +++ b/api/internal/service/issue_view.go @@ -93,7 +93,7 @@ func (s *IssueViewService) Create(ctx context.Context, workspaceSlug string, pro Filters: filters, DisplayFilters: displayFilters, DisplayProperties: displayProperties, - RichFilters: model.JSONMap{}, + RichFilters: model.JSONMap{}, Access: 1, OwnedByID: userID, WorkspaceID: workspaceID, diff --git a/api/internal/service/project.go b/api/internal/service/project.go index 0503300d..bfbfe500 100644 --- a/api/internal/service/project.go +++ b/api/internal/service/project.go @@ -78,7 +78,7 @@ func (s *ProjectService) Create(ctx context.Context, workspaceSlug, name, identi return p, nil } -func (s *ProjectService) Update(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID, name, identifier, description, timezone *string, projectLeadIDSet bool, projectLeadID *uuid.UUID, defaultAssigneeIDSet bool, defaultAssigneeID *uuid.UUID, guestViewAllFeatures *bool, moduleView, cycleView, issueViewsView, pageView, intakeView, isTimeTrackingEnabled *bool) (*model.Project, error) { +func (s *ProjectService) Update(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID, name, identifier, description, timezone, coverImage *string, emoji *string, iconProp *model.JSONMap, projectLeadIDSet bool, projectLeadID *uuid.UUID, defaultAssigneeIDSet bool, defaultAssigneeID *uuid.UUID, guestViewAllFeatures *bool, moduleView, cycleView, issueViewsView, pageView, intakeView, isTimeTrackingEnabled *bool) (*model.Project, error) { p, err := s.GetByID(ctx, workspaceSlug, projectID, userID) if err != nil { return nil, err @@ -95,6 +95,18 @@ func (s *ProjectService) Update(ctx context.Context, workspaceSlug string, proje if timezone != nil { p.Timezone = *timezone } + if coverImage != nil { + p.CoverImage = *coverImage + } + if emoji != nil { + p.Emoji = *emoji + } + if iconProp != nil { + p.IconProp = *iconProp + if len(*iconProp) > 0 { + p.Emoji = "" // clear emoji when setting icon + } + } if projectLeadIDSet { p.ProjectLeadID = projectLeadID } diff --git a/api/internal/service/workspace.go b/api/internal/service/workspace.go index c071827c..bf587854 100644 --- a/api/internal/service/workspace.go +++ b/api/internal/service/workspace.go @@ -14,12 +14,12 @@ import ( ) var ( - ErrWorkspaceNotFound = errors.New("workspace not found") - ErrWorkspaceForbidden = errors.New("not a member of this workspace") - ErrSlugInvalid = errors.New("invalid slug") - ErrSlugTaken = errors.New("slug already in use") - ErrInviteNotFound = errors.New("invite not found") - ErrMemberNotFound = errors.New("member not found") + ErrWorkspaceNotFound = errors.New("workspace not found") + ErrWorkspaceForbidden = errors.New("not a member of this workspace") + ErrSlugInvalid = errors.New("invalid slug") + ErrSlugTaken = errors.New("slug already in use") + ErrInviteNotFound = errors.New("invite not found") + ErrMemberNotFound = errors.New("member not found") ) var ( @@ -70,9 +70,9 @@ func (s *WorkspaceService) Create(ctx context.Context, name, slug string, ownerI return nil, ErrSlugTaken } w := &model.Workspace{ - Name: name, - Slug: slug, - OwnerID: ownerID, + Name: name, + Slug: slug, + OwnerID: ownerID, CreatedByID: &ownerID, } if err := s.ws.Create(ctx, w); err != nil { @@ -83,7 +83,7 @@ func (s *WorkspaceService) Create(ctx context.Context, name, slug string, ownerI return w, nil } -func (s *WorkspaceService) Update(ctx context.Context, slug string, userID uuid.UUID, name, newSlug *string) (*model.Workspace, error) { +func (s *WorkspaceService) Update(ctx context.Context, slug string, userID uuid.UUID, name, newSlug, logo *string) (*model.Workspace, error) { w, err := s.GetBySlug(ctx, slug, userID) if err != nil { return nil, err @@ -91,6 +91,9 @@ func (s *WorkspaceService) Update(ctx context.Context, slug string, userID uuid. if name != nil { w.Name = *name } + if logo != nil { + w.Logo = *logo + } if newSlug != nil { slugVal := strings.TrimSpace(strings.ToLower(*newSlug)) if !slugRegex.MatchString(slugVal) { diff --git a/api/internal/store/session.go b/api/internal/store/session.go index 0ace0e37..33b0673a 100644 --- a/api/internal/store/session.go +++ b/api/internal/store/session.go @@ -27,8 +27,8 @@ func (s *SessionStore) Create(ctx context.Context, sessionKey string, userID uui expire := time.Now().UTC().AddDate(0, 0, sessionExpireDays) rec := &model.Session{ SessionKey: sessionKey, - SessionData: string(data), - ExpireDate: expire, + SessionData: string(data), + ExpireDate: expire, } return s.db.WithContext(ctx).Create(rec).Error } diff --git a/api/internal/store/user_favorite.go b/api/internal/store/user_favorite.go new file mode 100644 index 00000000..538f7b70 --- /dev/null +++ b/api/internal/store/user_favorite.go @@ -0,0 +1,53 @@ +package store + +import ( + "context" + + "github.com/Devlaner/devlane/api/internal/model" + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const FavoriteEntityTypeProject = "project" + +// UserFavoriteStore handles user_favorites persistence. +type UserFavoriteStore struct{ db *gorm.DB } + +func NewUserFavoriteStore(db *gorm.DB) *UserFavoriteStore { + return &UserFavoriteStore{db: db} +} + +// ListProjectIDsByUser returns project IDs the user has favorited. +func (s *UserFavoriteStore) ListProjectIDsByUser(ctx context.Context, userID uuid.UUID) ([]uuid.UUID, error) { + var ids []uuid.UUID + err := s.db.WithContext(ctx).Model(&model.UserFavorite{}). + Where("user_id = ? AND entity_type = ?", userID, FavoriteEntityTypeProject). + Pluck("entity_identifier", &ids).Error + return ids, err +} + +// AddProject adds a project to the user's favorites. workspaceID is stored for the favorite record. +// Idempotent: uses ON CONFLICT DO NOTHING so duplicate inserts are ignored. +func (s *UserFavoriteStore) AddProject(ctx context.Context, userID, workspaceID, projectID uuid.UUID) error { + fav := &model.UserFavorite{ + Name: "project", + Type: "project", + EntityType: FavoriteEntityTypeProject, + EntityIdentifier: projectID, + WorkspaceID: workspaceID, + ProjectID: &projectID, + UserID: userID, + } + return s.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "entity_type"}, {Name: "entity_identifier"}}, + DoNothing: true, + }).Create(fav).Error +} + +// RemoveProject removes a project from the user's favorites. +func (s *UserFavoriteStore) RemoveProject(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) error { + return s.db.WithContext(ctx). + Where("user_id = ? AND entity_type = ? AND entity_identifier = ?", userID, FavoriteEntityTypeProject, projectID). + Delete(&model.UserFavorite{}).Error +} diff --git a/api/internal/store/user_recent_visit.go b/api/internal/store/user_recent_visit.go index f5d7f321..c9cdf4e7 100644 --- a/api/internal/store/user_recent_visit.go +++ b/api/internal/store/user_recent_visit.go @@ -52,12 +52,12 @@ func (s *UserRecentVisitStore) Upsert(ctx context.Context, workspaceID, userID u } if err == gorm.ErrRecordNotFound { v := &model.UserRecentVisit{ - WorkspaceID: workspaceID, - UserID: userID, - EntityName: entityName, - EntityIdentifier: entityIdentifier, - ProjectID: projectID, - LastVisitedAt: now, + WorkspaceID: workspaceID, + UserID: userID, + EntityName: entityName, + EntityIdentifier: entityIdentifier, + ProjectID: projectID, + LastVisitedAt: now, } return s.db.WithContext(ctx).Create(v).Error } diff --git a/ui/README.md b/ui/README.md index f2948e47..8ba6cb99 100644 --- a/ui/README.md +++ b/ui/README.md @@ -17,9 +17,9 @@ If you are developing a production application, we recommend updating the config ```js export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(["dist"]), { - files: ['**/*.{ts,tsx}'], + files: ["**/*.{ts,tsx}"], extends: [ // Other configs... @@ -34,45 +34,44 @@ export default defineConfig([ ], languageOptions: { parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], + project: ["./tsconfig.node.json", "./tsconfig.app.json"], tsconfigRootDir: import.meta.dirname, }, // other options... }, }, -]) +]); ``` You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: ```js // eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' +import reactX from "eslint-plugin-react-x"; +import reactDom from "eslint-plugin-react-dom"; export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(["dist"]), { - files: ['**/*.{ts,tsx}'], + files: ["**/*.{ts,tsx}"], extends: [ // Other configs... // Enable lint rules for React - reactX.configs['recommended-typescript'], + reactX.configs["recommended-typescript"], // Enable lint rules for React DOM reactDom.configs.recommended, ], languageOptions: { parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], + project: ["./tsconfig.node.json", "./tsconfig.app.json"], tsconfigRootDir: import.meta.dirname, }, // other options... }, }, -]) +]); ``` - --- ## Ufazien Deployment @@ -82,6 +81,7 @@ This project is configured for deployment to Ufazien Hosting. ### Build and Deploy 1. Build your project (this will create a `dist` or `build` folder): + ```bash npm run build # or @@ -91,6 +91,7 @@ pnpm build ``` 2. Deploy to Ufazien: + ```bash ufazien deploy ``` diff --git a/ui/eslint.config.js b/ui/eslint.config.js index 5e6b472f..75d3c46f 100644 --- a/ui/eslint.config.js +++ b/ui/eslint.config.js @@ -1,14 +1,14 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' -import { defineConfig, globalIgnores } from 'eslint/config' +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; +import { defineConfig, globalIgnores } from "eslint/config"; export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(["dist"]), { - files: ['**/*.{ts,tsx}'], + files: ["**/*.{ts,tsx}"], extends: [ js.configs.recommended, tseslint.configs.recommended, @@ -20,4 +20,4 @@ export default defineConfig([ globals: globals.browser, }, }, -]) +]); diff --git a/ui/index.html b/ui/index.html index 4441604e..2ec4dd9c 100644 --- a/ui/index.html +++ b/ui/index.html @@ -7,7 +7,10 @@ Devlane - +
diff --git a/ui/package-lock.json b/ui/package-lock.json index 0963fcde..23d10e39 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "Devlane UI", - "version": "0.2.1", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "Devlane UI", - "version": "0.2.1", + "version": "0.3.0", "dependencies": { "@headlessui/react": "^2.2.9", "@tailwindcss/vite": "^4.1.18", @@ -35,6 +35,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "prettier": "^3.8.1", "typescript": "~5.9.3", "typescript-eslint": "^8.48.0", "vite": "^7.3.1" @@ -4776,6 +4777,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/prosemirror-changeset": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz", diff --git a/ui/package.json b/ui/package.json index 5141f08e..7a02aa89 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,12 +1,15 @@ { "name": "Devlane UI", "private": true, - "version": "0.2.1", + "version": "0.3.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", + "lint:fix": "eslint --fix .", + "format": "prettier --write .", + "format:check": "prettier --check .", "preview": "vite preview" }, "dependencies": { @@ -37,6 +40,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "prettier": "^3.8.1", "typescript": "~5.9.3", "typescript-eslint": "^8.48.0", "vite": "^7.3.1" diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 064dea54..089c692c 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,13 +1,16 @@ -import { RouterProvider } from 'react-router-dom'; -import { AuthProvider } from './contexts/AuthContext'; -import { ThemeProvider } from './contexts/ThemeContext'; -import { router } from './routes'; +import { RouterProvider } from "react-router-dom"; +import { AuthProvider } from "./contexts/AuthContext"; +import { FavoritesProvider } from "./contexts/FavoritesContext"; +import { ThemeProvider } from "./contexts/ThemeContext"; +import { router } from "./routes"; export default function App() { return ( - + + + ); diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 805d5581..e0a64946 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -1,5 +1,5 @@ -import axios, { type AxiosError } from 'axios'; -import { config } from '../config/env'; +import axios, { type AxiosError } from "axios"; +import { config } from "../config/env"; /** * Shared Axios instance for all API requests. @@ -11,7 +11,7 @@ export const apiClient = axios.create({ baseURL: config.apiBaseUrl, withCredentials: true, headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, }); @@ -35,7 +35,7 @@ export function getApiErrorMessage(err: unknown): string { if (ax.response?.status) return `Request failed (${ax.response.status}).`; } if (err instanceof Error) return err.message; - return 'An unexpected error occurred.'; + return "An unexpected error occurred."; } apiClient.interceptors.response.use( @@ -43,5 +43,5 @@ apiClient.interceptors.response.use( (error: AxiosError) => { const message = getApiErrorMessage(error); return Promise.reject(new Error(message)); - } + }, ); diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index 85a8a41b..c7ef7d2a 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -1,3 +1,3 @@ -export { apiClient, getApiErrorMessage } from './client'; -export type { ApiErrorResponse } from './client'; -export type { CreateWorkspaceRequest, WorkspaceApiResponse } from './types'; +export { apiClient, getApiErrorMessage } from "./client"; +export type { ApiErrorResponse } from "./client"; +export type { CreateWorkspaceRequest, WorkspaceApiResponse } from "./types"; diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 2b96523c..9eb77ad2 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -15,6 +15,7 @@ export interface WorkspaceApiResponse { name: string; slug: string; owner_id: string; + logo?: string; created_at?: string; updated_at?: string; } @@ -50,6 +51,12 @@ export interface CreateProjectRequest { identifier?: string; } +/** Project icon_prop from API (name + optional color) */ +export interface ProjectIconProp { + name?: string; + color?: string; +} + /** Project as returned by the API (list + get) */ export interface ProjectApiResponse { id: string; @@ -59,6 +66,9 @@ export interface ProjectApiResponse { identifier?: string; slug?: string; timezone?: string; + cover_image?: string; + emoji?: string; + icon_prop?: ProjectIconProp | null; project_lead_id?: string | null; default_assignee_id?: string | null; guest_view_all_features?: boolean; @@ -188,6 +198,7 @@ export interface UserApiResponse { last_name: string; display_name: string; avatar?: string; + cover_image?: string; is_active: boolean; is_onboarded: boolean; date_joined: string; @@ -202,6 +213,8 @@ export interface UpdateMeRequest { last_name?: string; display_name?: string; user_timezone?: string; + avatar?: string; + cover_image?: string; } /** POST /api/users/me/change-password/ */ @@ -368,8 +381,11 @@ export interface IssueViewApiResponse { export interface PageApiResponse { id: string; name: string; + /** Display title (may equal name); use for list display. */ + title?: string; description_html?: string; owned_by_id: string; + updated_by_id?: string | null; workspace_id: string; access: number; parent_id?: string | null; diff --git a/ui/src/components/CoverImageModal.tsx b/ui/src/components/CoverImageModal.tsx new file mode 100644 index 00000000..84e25d3b --- /dev/null +++ b/ui/src/components/CoverImageModal.tsx @@ -0,0 +1,292 @@ +import { useCallback, useEffect, useState } from "react"; +import { Button, Modal } from "./ui"; +import { + instanceSettingsService, + type UnsplashSearchResult, +} from "../services/instanceService"; +import { uploadImage } from "../services/uploadService"; + +const TAB_UNSPLASH = "unsplash"; +const TAB_UPLOAD = "upload"; +type Tab = typeof TAB_UNSPLASH | typeof TAB_UPLOAD; + +export interface CoverImageModalProps { + open: boolean; + onClose: () => void; + onSelect: (url: string) => void; + title?: string; +} + +export function CoverImageModal({ + open, + onClose, + onSelect, + title = "Select cover image", +}: CoverImageModalProps) { + const [tab, setTab] = useState(TAB_UNSPLASH); + const [unsplashQuery, setUnsplashQuery] = useState(""); + const [unsplashResults, setUnsplashResults] = useState< + UnsplashSearchResult[] + >([]); + const [unsplashLoading, setUnsplashLoading] = useState(false); + const [unsplashError, setUnsplashError] = useState(null); + const [selectedUrl, setSelectedUrl] = useState(null); + const [uploadFile, setUploadFile] = useState(null); + const [uploadPreview, setUploadPreview] = useState(null); + const [uploadLoading, setUploadLoading] = useState(false); + const [uploadError, setUploadError] = useState(null); + + useEffect(() => { + if (!open) { + setTab(TAB_UNSPLASH); + setUnsplashQuery(""); + setUnsplashResults([]); + setUnsplashError(null); + setSelectedUrl(null); + setUploadFile(null); + setUploadPreview(null); + setUploadError(null); + } + }, [open]); + + const handleSearch = useCallback(async () => { + if (!unsplashQuery.trim()) return; + setUnsplashError(null); + setUnsplashLoading(true); + try { + const { results } = await instanceSettingsService.unsplashSearch( + unsplashQuery.trim(), + ); + setUnsplashResults(results); + } catch (e) { + setUnsplashError(e instanceof Error ? e.message : "Search failed"); + setUnsplashResults([]); + } finally { + setUnsplashLoading(false); + } + }, [unsplashQuery]); + + const handleUnsplashSelect = useCallback(() => { + if (selectedUrl) { + onSelect(selectedUrl); + onClose(); + } + }, [selectedUrl, onSelect, onClose]); + + const handleFileChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const allowed = ["image/jpeg", "image/jpg", "image/png", "image/webp"]; + if (!allowed.includes(file.type)) { + setUploadError( + "Invalid file type. Supported: .jpeg, .jpg, .png, .webp", + ); + return; + } + setUploadError(null); + setUploadFile(file); + const reader = new FileReader(); + reader.onload = () => setUploadPreview(reader.result as string); + reader.readAsDataURL(file); + }, + [], + ); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + const file = e.dataTransfer.files?.[0]; + if (!file) return; + const allowed = ["image/jpeg", "image/jpg", "image/png", "image/webp"]; + if (!allowed.includes(file.type)) { + setUploadError("Invalid file type. Supported: .jpeg, .jpg, .png, .webp"); + return; + } + setUploadError(null); + setUploadFile(file); + const reader = new FileReader(); + reader.onload = () => setUploadPreview(reader.result as string); + reader.readAsDataURL(file); + }, []); + + const handleDragOver = useCallback( + (e: React.DragEvent) => e.preventDefault(), + [], + ); + + const handleUploadSave = useCallback(async () => { + if (!uploadFile) return; + setUploadError(null); + setUploadLoading(true); + try { + const { url } = await uploadImage(uploadFile); + onSelect(url); + onClose(); + } catch (e) { + setUploadError(e instanceof Error ? e.message : "Upload failed"); + } finally { + setUploadLoading(false); + } + }, [uploadFile, onSelect, onClose]); + + const handleRemoveUpload = useCallback(() => { + setUploadFile(null); + setUploadPreview(null); + setUploadError(null); + }, []); + + const footer = ( + <> + + {tab === TAB_UNSPLASH && ( + + )} + {tab === TAB_UPLOAD && ( + + )} + + ); + + return ( + +
+ + +
+ + {tab === TAB_UNSPLASH && ( +
+
+ setUnsplashQuery(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + placeholder="Search for images" + className="min-w-0 flex-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-surface-1)] px-3 py-2 text-sm text-[var(--txt-primary)] placeholder:text-[var(--txt-placeholder)] focus:outline-none focus:border-[var(--border-strong)]" + /> + +
+ {unsplashError && ( +

+ {unsplashError} +

+ )} +
+ {unsplashResults.map((r) => ( + + ))} +
+
+ )} + + {tab === TAB_UPLOAD && ( +
+ {!uploadPreview ? ( +
+

+ Drag & drop image here +

+ +
+ ) : ( +
+ Preview +
+ +
+
+ )} +

+ File formats supported: .jpeg, .jpg, .png, .webp +

+ {uploadError && ( +

+ {uploadError} +

+ )} +
+ )} +
+ ); +} diff --git a/ui/src/components/CreateProjectModal.tsx b/ui/src/components/CreateProjectModal.tsx index e5460aa4..6d2728ef 100644 --- a/ui/src/components/CreateProjectModal.tsx +++ b/ui/src/components/CreateProjectModal.tsx @@ -1,8 +1,8 @@ -import { useEffect, useState } from 'react'; -import { createPortal } from 'react-dom'; -import { Button, Input } from './ui'; -import { projectService } from '../services/projectService'; -import type { ProjectApiResponse } from '../api/types'; +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { Button, Input } from "./ui"; +import { projectService } from "../services/projectService"; +import type { ProjectApiResponse } from "../api/types"; export interface CreateProjectModalProps { open: boolean; @@ -12,15 +12,25 @@ export interface CreateProjectModalProps { } const COVER_GRADIENTS = [ - 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a78bfa 100%)', - 'linear-gradient(135deg, #0ea5e9 0%, #38bdf8 50%, #7dd3fc 100%)', - 'linear-gradient(135deg, #10b981 0%, #34d399 50%, #6ee7b7 100%)', - 'linear-gradient(135deg, #f59e0b 0%, #fbbf24 50%, #fcd34d 100%)', - 'linear-gradient(135deg, #ec4899 0%, #f472b6 50%, #f9a8d4 100%)', + "linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a78bfa 100%)", + "linear-gradient(135deg, #0ea5e9 0%, #38bdf8 50%, #7dd3fc 100%)", + "linear-gradient(135deg, #10b981 0%, #34d399 50%, #6ee7b7 100%)", + "linear-gradient(135deg, #f59e0b 0%, #fbbf24 50%, #fcd34d 100%)", + "linear-gradient(135deg, #ec4899 0%, #f472b6 50%, #f9a8d4 100%)", ]; const IconGlobe = () => ( - + @@ -28,7 +38,17 @@ const IconGlobe = () => ( ); const IconUsers = () => ( - + @@ -37,7 +57,17 @@ const IconUsers = () => ( ); const IconInfo = () => ( - + @@ -45,7 +75,17 @@ const IconInfo = () => ( ); const IconX = () => ( - + @@ -57,26 +97,26 @@ export function CreateProjectModal({ workspaceSlug, onSuccess, }: CreateProjectModalProps) { - const [name, setName] = useState(''); - const [identifier, setIdentifier] = useState(''); - const [description, setDescription] = useState(''); - const [error, setError] = useState(''); + const [name, setName] = useState(""); + const [identifier, setIdentifier] = useState(""); + const [description, setDescription] = useState(""); + const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); const [coverIndex, setCoverIndex] = useState(0); const handleClose = () => { - setName(''); - setIdentifier(''); - setDescription(''); - setError(''); + setName(""); + setIdentifier(""); + setDescription(""); + setError(""); onClose(); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - setError(''); + setError(""); if (!name.trim()) { - setError('Project name is required.'); + setError("Project name is required."); return; } setSubmitting(true); @@ -88,7 +128,9 @@ export function CreateProjectModal({ onSuccess?.(project); handleClose(); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to create project.'); + setError( + err instanceof Error ? err.message : "Failed to create project.", + ); } finally { setSubmitting(false); } @@ -97,19 +139,21 @@ export function CreateProjectModal({ useEffect(() => { if (!open) return; const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); + if (e.key === "Escape") onClose(); }; - document.addEventListener('keydown', handleEscape); - document.body.style.overflow = 'hidden'; + document.addEventListener("keydown", handleEscape); + document.body.style.overflow = "hidden"; return () => { - document.removeEventListener('keydown', handleEscape); - document.body.style.overflow = ''; + document.removeEventListener("keydown", handleEscape); + document.body.style.overflow = ""; }; }, [open, onClose]); if (!open) return null; - const coverStyle = { background: COVER_GRADIENTS[coverIndex % COVER_GRADIENTS.length] }; + const coverStyle = { + background: COVER_GRADIENTS[coverIndex % COVER_GRADIENTS.length], + }; return createPortal(
-

Create project

+

+ Create project +

setIdentifier(e.target.value.toUpperCase().replace(/[^A-Z0-9-]/g, ''))} + onChange={(e) => + setIdentifier( + e.target.value.toUpperCase().replace(/[^A-Z0-9-]/g, ""), + ) + } placeholder="e.g. PROJ" disabled={submitting} className="w-full pr-9" @@ -213,21 +263,28 @@ export function CreateProjectModal({
{error && ( -

{error}

+

+ {error} +

)} {/* Actions */}
-
, - document.body + document.body, ); } diff --git a/ui/src/components/CreateWorkItemModal.tsx b/ui/src/components/CreateWorkItemModal.tsx index 9563a48e..a5680541 100644 --- a/ui/src/components/CreateWorkItemModal.tsx +++ b/ui/src/components/CreateWorkItemModal.tsx @@ -1,30 +1,56 @@ -import { useEffect, useState } from 'react'; -import { createPortal } from 'react-dom'; -import { Button, Input } from './ui'; -import { Dropdown, DatePickerTrigger, SelectParentModal } from './work-item'; -import { stateService } from '../services/stateService'; -import { labelService } from '../services/labelService'; -import { issueService } from '../services/issueService'; -import { cycleService } from '../services/cycleService'; -import { moduleService } from '../services/moduleService'; -import { workspaceService } from '../services/workspaceService'; -import type { StateApiResponse, LabelApiResponse, IssueApiResponse, ProjectApiResponse } from '../api/types'; -import type { Priority } from '../types'; +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { Button, Input } from "./ui"; +import { Dropdown, DatePickerTrigger, SelectParentModal } from "./work-item"; +import { stateService } from "../services/stateService"; +import { labelService } from "../services/labelService"; +import { issueService } from "../services/issueService"; +import { cycleService } from "../services/cycleService"; +import { moduleService } from "../services/moduleService"; +import { workspaceService } from "../services/workspaceService"; +import type { + StateApiResponse, + LabelApiResponse, + IssueApiResponse, + ProjectApiResponse, +} from "../api/types"; +import type { Priority } from "../types"; const IconCog = () => ( - + ); const IconCircleSlash = () => ( - + ); const IconUsers = () => ( - + @@ -32,12 +58,26 @@ const IconUsers = () => ( ); const IconTag = () => ( - + ); const IconCalendar = () => ( - + @@ -45,13 +85,27 @@ const IconCalendar = () => ( ); const IconCycle = () => ( - + ); const IconGrid = () => ( - + @@ -59,20 +113,41 @@ const IconGrid = () => ( ); const IconLink2 = () => ( - + ); const IconTruck = () => ( - + ); const IconBuilding = () => ( - + @@ -87,7 +162,7 @@ const IconBuilding = () => ( ); -const PRIORITIES: Priority[] = ['urgent', 'high', 'medium', 'low', 'none']; +const PRIORITIES: Priority[] = ["urgent", "high", "medium", "low", "none"]; export interface CreateWorkItemModalProps { open: boolean; @@ -122,51 +197,58 @@ export function CreateWorkItemModal({ createError, onSave, }: CreateWorkItemModalProps) { - const [title, setTitle] = useState(''); - const [description, setDescription] = useState(''); - const [projectId, setProjectId] = useState(defaultProjectId ?? projects[0]?.id ?? ''); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [projectId, setProjectId] = useState( + defaultProjectId ?? projects[0]?.id ?? "", + ); const [createMore, setCreateMore] = useState(false); const [submitting, setSubmitting] = useState(false); const [openDropdown, setOpenDropdown] = useState(null); - const [stateId, setStateId] = useState(''); - const [priority, setPriority] = useState('none'); + const [stateId, setStateId] = useState(""); + const [priority, setPriority] = useState("none"); const [assigneeIds, setAssigneeIds] = useState([]); const [labelIds, setLabelIds] = useState([]); - const [startDate, setStartDate] = useState(''); - const [dueDate, setDueDate] = useState(''); + const [startDate, setStartDate] = useState(""); + const [dueDate, setDueDate] = useState(""); const [cycleId, setCycleId] = useState(null); const [moduleId, setModuleId] = useState(null); const [parentId, setParentId] = useState(null); const [parentModalOpen, setParentModalOpen] = useState(false); - const [projectSearch, setProjectSearch] = useState(''); - const [stateSearch, setStateSearch] = useState(''); - const [assigneeSearch, setAssigneeSearch] = useState(''); - const [labelSearch, setLabelSearch] = useState(''); - const [cycleSearch, setCycleSearch] = useState(''); - const [moduleSearch, setModuleSearch] = useState(''); + const [projectSearch, setProjectSearch] = useState(""); + const [stateSearch, setStateSearch] = useState(""); + const [assigneeSearch, setAssigneeSearch] = useState(""); + const [labelSearch, setLabelSearch] = useState(""); + const [cycleSearch, setCycleSearch] = useState(""); + const [moduleSearch, setModuleSearch] = useState(""); useEffect(() => { if (!openDropdown) { - setProjectSearch(''); - setStateSearch(''); - setAssigneeSearch(''); - setLabelSearch(''); - setCycleSearch(''); - setModuleSearch(''); + setProjectSearch(""); + setStateSearch(""); + setAssigneeSearch(""); + setLabelSearch(""); + setCycleSearch(""); + setModuleSearch(""); } }, [openDropdown]); - const selectedProject = projects.find((p) => p.id === projectId) ?? projects[0]; - const pid = selectedProject?.id ?? ''; + const selectedProject = + projects.find((p) => p.id === projectId) ?? projects[0]; + const pid = selectedProject?.id ?? ""; const [states, setStates] = useState([]); const [labels, setLabels] = useState([]); const [issues, setIssues] = useState([]); const [cycles, setCycles] = useState>([]); - const [modules, setModules] = useState>([]); - const [members, setMembers] = useState>([]); + const [modules, setModules] = useState>( + [], + ); + const [members, setMembers] = useState>( + [], + ); useEffect(() => { if (!workspaceSlug || !pid) { @@ -203,7 +285,9 @@ export function CreateWorkItemModal({ setModules([]); } }); - return () => { cancelled = true; }; + return () => { + cancelled = true; + }; }, [workspaceSlug, pid]); useEffect(() => { @@ -221,9 +305,9 @@ export function CreateWorkItemModal({ id: m.member_id, name: (m.member_display_name && m.member_display_name.trim()) || - (m.member_email && m.member_email.split('@')[0]) || - 'Member', - })) + (m.member_email && m.member_email.split("@")[0]) || + "Member", + })), ); }) .catch(() => { @@ -234,36 +318,58 @@ export function CreateWorkItemModal({ }; }, [workspaceSlug]); - const stateName = stateId ? states.find((s) => s.id === stateId)?.name : ''; + const stateName = stateId ? states.find((s) => s.id === stateId)?.name : ""; const assigneeNames = assigneeIds .map((id) => members.find((m) => m.id === id)?.name ?? id.slice(0, 8)) .filter(Boolean) - .join(', ') || ''; - const labelNames = labelIds.map((id) => labels.find((l) => l.id === id)?.name).filter(Boolean).join(', ') || ''; - const cycleName = cycleId ? cycles.find((c) => c.id === cycleId)?.name : ''; - const moduleName = moduleId ? modules.find((m) => m.id === moduleId)?.name : ''; - const parentTitle = parentId ? issues.find((i) => i.id === parentId)?.name : ''; + .join(", ") || ""; + const labelNames = + labelIds + .map((id) => labels.find((l) => l.id === id)?.name) + .filter(Boolean) + .join(", ") || ""; + const cycleName = cycleId ? cycles.find((c) => c.id === cycleId)?.name : ""; + const moduleName = moduleId + ? modules.find((m) => m.id === moduleId)?.name + : ""; + const parentTitle = parentId + ? issues.find((i) => i.id === parentId)?.name + : ""; const q = (s: string) => s.toLowerCase().trim(); - const filteredProjects = projects.filter((p) => q(p.name).includes(q(projectSearch))); - const filteredStates = states.filter((s) => q(s.name).includes(q(stateSearch))); - const filteredUsers = members.filter((u) => q(u.name).includes(q(assigneeSearch)) || q(u.id).includes(q(assigneeSearch))); - const filteredLabels = labels.filter((l) => q(l.name).includes(q(labelSearch))); - const filteredCycles = cycles.filter((c) => q(c.name).includes(q(cycleSearch))); - const filteredModules = modules.filter((m) => q(m.name).includes(q(moduleSearch))); + const filteredProjects = projects.filter((p) => + q(p.name).includes(q(projectSearch)), + ); + const filteredStates = states.filter((s) => + q(s.name).includes(q(stateSearch)), + ); + const filteredUsers = members.filter( + (u) => + q(u.name).includes(q(assigneeSearch)) || + q(u.id).includes(q(assigneeSearch)), + ); + const filteredLabels = labels.filter((l) => + q(l.name).includes(q(labelSearch)), + ); + const filteredCycles = cycles.filter((c) => + q(c.name).includes(q(cycleSearch)), + ); + const filteredModules = modules.filter((m) => + q(m.name).includes(q(moduleSearch)), + ); useEffect(() => { if (open) { - setProjectId(defaultProjectId ?? projects[0]?.id ?? ''); - setTitle(''); - setDescription(''); - setStateId(''); - setPriority('none'); + setProjectId(defaultProjectId ?? projects[0]?.id ?? ""); + setTitle(""); + setDescription(""); + setStateId(""); + setPriority("none"); setAssigneeIds([]); setLabelIds([]); - setStartDate(''); - setDueDate(''); + setStartDate(""); + setDueDate(""); setCycleId(null); setModuleId(null); setParentId(null); @@ -275,17 +381,17 @@ export function CreateWorkItemModal({ useEffect(() => { if (!open) return; const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape') { + if (e.key === "Escape") { if (parentModalOpen) setParentModalOpen(false); else if (openDropdown) setOpenDropdown(null); else onClose(); } }; - document.addEventListener('keydown', handleEscape); - document.body.style.overflow = 'hidden'; + document.addEventListener("keydown", handleEscape); + document.body.style.overflow = "hidden"; return () => { - document.removeEventListener('keydown', handleEscape); - document.body.style.overflow = ''; + document.removeEventListener("keydown", handleEscape); + document.body.style.overflow = ""; }; }, [open, onClose, openDropdown, parentModalOpen]); @@ -299,7 +405,7 @@ export function CreateWorkItemModal({ description, projectId, stateId: stateId || undefined, - priority: priority !== 'none' ? priority : undefined, + priority: priority !== "none" ? priority : undefined, assigneeIds: assigneeIds.length ? assigneeIds : undefined, assigneeId: assigneeIds[0] ?? undefined, labelIds: labelIds.length ? labelIds : undefined, @@ -311,14 +417,14 @@ export function CreateWorkItemModal({ }); if (!createMore) onClose(); else { - setTitle(''); - setDescription(''); - setStateId(''); - setPriority('none'); + setTitle(""); + setDescription(""); + setStateId(""); + setPriority("none"); setAssigneeIds([]); setLabelIds([]); - setStartDate(''); - setDueDate(''); + setStartDate(""); + setDueDate(""); setCycleId(null); setModuleId(null); setParentId(null); @@ -329,14 +435,14 @@ export function CreateWorkItemModal({ } else { if (!createMore) onClose(); else { - setTitle(''); - setDescription(''); - setStateId(''); - setPriority('none'); + setTitle(""); + setDescription(""); + setStateId(""); + setPriority("none"); setAssigneeIds([]); setLabelIds([]); - setStartDate(''); - setDueDate(''); + setStartDate(""); + setDueDate(""); setCycleId(null); setModuleId(null); setParentId(null); @@ -345,13 +451,15 @@ export function CreateWorkItemModal({ }; const handleDiscard = () => { - setTitle(''); - setDescription(''); + setTitle(""); + setDescription(""); onClose(); }; const toggleLabel = (id: string) => { - setLabelIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); + setLabelIds((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], + ); }; if (!open) return null; @@ -364,335 +472,381 @@ export function CreateWorkItemModal({ aria-modal="true" aria-labelledby="create-work-item-title" > -
-
e.stopPropagation()} - > -
-

- Create new work item -

-
- - ) : ( - - ) - } - displayValue={selectedProject?.name ?? ''} - panelClassName="flex min-w-[160px] max-h-52 flex-col rounded border border-[var(--border-subtle)] bg-[var(--bg-surface-1)] shadow-[var(--shadow-raised)]" +
+
e.stopPropagation()} + > +
+

-
- setProjectSearch(e.target.value)} - className="w-full rounded border border-[var(--border-subtle)] bg-[var(--bg-surface-1)] px-2 py-1 text-xs placeholder:text-[var(--txt-placeholder)] focus:outline-none focus:border-[var(--border-strong)]" - /> -
-
- {filteredProjects.map((p) => ( + Create new work item +

+
+ + ) : ( + + ) + } + displayValue={selectedProject?.name ?? ""} + panelClassName="flex min-w-[160px] max-h-52 flex-col rounded border border-[var(--border-subtle)] bg-[var(--bg-surface-1)] shadow-[var(--shadow-raised)]" + > +
+ setProjectSearch(e.target.value)} + className="w-full rounded border border-[var(--border-subtle)] bg-[var(--bg-surface-1)] px-2 py-1 text-xs placeholder:text-[var(--txt-placeholder)] focus:outline-none focus:border-[var(--border-strong)]" + /> +
+
+ {filteredProjects.map((p) => ( + + ))} +
+
+
+
+ +
+ setTitle(e.target.value)} + className="mb-3 border-[var(--border-subtle)]" + /> +