Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions api/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,19 +69,20 @@ 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,
Minio: mc,
CORSAllowOrigin: cfg.CORSAllowOrigin,
})

Expand Down
9 changes: 9 additions & 0 deletions api/internal/handler/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
106 changes: 106 additions & 0 deletions api/internal/handler/favorite.go
Original file line number Diff line number Diff line change
@@ -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 {
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 {
Comment thread
martian56 marked this conversation as resolved.
Outdated
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"})
}
89 changes: 89 additions & 0 deletions api/internal/handler/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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})
}
45 changes: 31 additions & 14 deletions api/internal/handler/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ 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/service"
"github.com/gin-gonic/gin"
Expand Down Expand Up @@ -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()})
Expand All @@ -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 == "" {
Expand All @@ -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"})
Expand Down
Loading