package httpapi import ( "errors" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "github.com/kristianwind/verdande/internal/store" ) type projectJSON struct { ID string `json:"id"` Name string `json:"name"` Color string `json:"color"` Icon string `json:"icon,omitempty"` ViewMode string `json:"view_mode"` OwnerID string `json:"owner_id"` IsInbox bool `json:"is_inbox"` Archived bool `json:"archived"` SortOrder float64 `json:"sort_order"` Role string `json:"role"` MemberCount int `json:"member_count"` OpenCount int `json:"open_count"` Shared bool `json:"shared"` // GroupID is the sidebar group the asking user has filed it under, empty for // none. Always empty on a project shared with them: grouping is a column on // the project row, so it is the owner's filing and nobody else's. GroupID string `json:"group_id,omitempty"` } func toProjectJSON(p store.Project) projectJSON { return projectJSON{ ID: p.ID, Name: p.Name, Color: p.Color, Icon: p.Icon, ViewMode: p.ViewMode, OwnerID: p.OwnerID, IsInbox: p.IsInbox, Archived: p.Archived, SortOrder: p.SortOrder, Role: string(p.Role), MemberCount: p.MemberCount, OpenCount: p.OpenCount, Shared: p.MemberCount > 1, GroupID: p.GroupID, } } func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) { user := userFrom(r.Context()) includeArchived := r.URL.Query().Get("archived") == "true" projects, err := s.db.ListProjects(r.Context(), user.ID, includeArchived) if err != nil { s.internal(w, r, "list projects", err) return } out := make([]projectJSON, 0, len(projects)) for _, p := range projects { out = append(out, toProjectJSON(p)) } writeJSON(w, http.StatusOK, map[string]any{"projects": out}) } func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) { user := userFrom(r.Context()) p, err := s.db.GetProject(r.Context(), chi.URLParam(r, "projectID"), user.ID) if err != nil { s.storeError(w, r, "get project", err) return } writeJSON(w, http.StatusOK, toProjectJSON(*p)) } type projectRequest struct { Name *string `json:"name"` Color *string `json:"color"` Icon *string `json:"icon"` ViewMode *string `json:"view_mode"` Archived *bool `json:"archived"` // GroupID files the project under a sidebar group; "" takes it out of one. // The empty string rather than a JSON null, because null and absent are the // same nil pointer once decoded — and telling "put it nowhere" apart from // "I did not mention it" is the whole point of a PATCH. GroupID *string `json:"group_id"` } type reorderProjectsRequest struct { // IDs in the order they should appear, top to bottom. IDs []string `json:"ids"` } // handleReorderProjects sets the sidebar order. // // The whole list rather than one move: a person has a handful of projects, and // sending the order you want is both simpler to reason about and impossible to // get into the half-ordered state a sequence of individual moves can produce if // one of them fails. // // Projects the caller does not own are skipped — sort_order is a column on the // project, so a shared one sits where its owner put it. func (s *Server) handleReorderProjects(w http.ResponseWriter, r *http.Request) { var req reorderProjectsRequest if err := decodeJSON(w, r, &req); err != nil { return } // A ceiling rather than a guess at what is reasonable: this is a bound on // the transaction, not an opinion about how many projects somebody may have. if len(req.IDs) > 500 { writeFieldErrors(w, map[string]string{"ids": "at most 500 at a time"}) return } if err := s.db.ReorderProjects(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil { s.internal(w, r, "reorder projects", err) return } w.WriteHeader(http.StatusNoContent) } func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) { var req projectRequest if err := decodeJSON(w, r, &req); err != nil { return } user := userFrom(r.Context()) name := "" if req.Name != nil { name = strings.TrimSpace(*req.Name) } if fields := validateProject(name, req.ViewMode, req.Color); len(fields) > 0 { writeFieldErrors(w, fields) return } p := &store.Project{Name: name, OwnerID: user.ID} if req.Color != nil { p.Color = *req.Color } if req.Icon != nil { p.Icon = *req.Icon } if req.ViewMode != nil { p.ViewMode = *req.ViewMode } if err := s.db.CreateProject(r.Context(), p); err != nil { s.internal(w, r, "create project", err) return } // Filed under a group in the same call. `group_id` was declared on this // request type, documented on it, applied by the update handler — and silently // dropped here, so creating a project into a group answered 201 and put it // nowhere. A field that is accepted and thrown away is worse than one that is // refused. if req.GroupID != nil && *req.GroupID != "" { if err := s.db.SetProjectGroup(r.Context(), p.ID, *req.GroupID, user.ID); err != nil { s.storeError(w, r, "set project group", err) return } p.GroupID = *req.GroupID } s.activity(r, p.ID, "", "project.created", map[string]any{"name": p.Name}) s.publishToOwner(r, "project.created", toProjectJSON(*p)) writeJSON(w, http.StatusCreated, toProjectJSON(*p)) } func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) { var req projectRequest if err := decodeJSON(w, r, &req); err != nil { return } projectID := chi.URLParam(r, "projectID") var name string if req.Name != nil { name = strings.TrimSpace(*req.Name) req.Name = &name } // The name is only checked when it was sent — but the colour is checked // whenever it was, which is why this is two conditions rather than one. fields := validateProject(name, req.ViewMode, req.Color) if req.Name == nil { delete(fields, "name") } if len(fields) > 0 { writeFieldErrors(w, fields) return } err := s.db.UpdateProject(r.Context(), projectID, store.ProjectUpdate{ Name: req.Name, Color: req.Color, Icon: req.Icon, ViewMode: req.ViewMode, Archived: req.Archived, }) if err != nil { s.storeError(w, r, "update project", err) return } // Grouping is written separately because it is scoped differently: the fields // above belong to the project, and this one belongs to whoever is filing it. // The route already requires ownership, so the check inside is a second lock // on the same door rather than a different one. if req.GroupID != nil { if err := s.db.SetProjectGroup(r.Context(), projectID, *req.GroupID, userFrom(r.Context()).ID); err != nil { s.storeError(w, r, "set project group", err) return } } s.activity(r, projectID, "", "project.updated", nil) p, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID) if err != nil { s.storeError(w, r, "get project", err) return } s.publishToOwner(r, "project.updated", toProjectJSON(*p)) writeJSON(w, http.StatusOK, toProjectJSON(*p)) } func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "projectID") if err := s.db.DeleteProject(r.Context(), projectID); err != nil { if strings.Contains(err.Error(), "Inbox cannot be deleted") { writeError(w, http.StatusConflict, CodeInboxProtected, "the Inbox cannot be deleted") return } s.storeError(w, r, "delete project", err) return } s.activity(r, projectID, "", "project.deleted", nil) s.publishToOwner(r, "project.deleted", map[string]string{"id": projectID}) w.WriteHeader(http.StatusNoContent) } type trashedProjectJSON struct { projectJSON DeletedAt string `json:"deleted_at"` // PurgeAfter is when it stops being recoverable. Sent rather than left for // the frontend to work out, because the retention window is the server's and // a copy of it in the interface is a copy that will one day disagree. PurgeAfter string `json:"purge_after"` TaskCount int `json:"task_count"` } // handleListTrashedProjects is what makes restoring reachable. The restore // endpoint has always existed; without this, using it meant knowing the id of // something that had already vanished from the interface. func (s *Server) handleListTrashedProjects(w http.ResponseWriter, r *http.Request) { trashed, err := s.db.ListTrashedProjects(r.Context(), userFrom(r.Context()).ID) if err != nil { s.internal(w, r, "list trashed projects", err) return } out := make([]trashedProjectJSON, 0, len(trashed)) for _, t := range trashed { out = append(out, trashedProjectJSON{ projectJSON: toProjectJSON(t.Project), DeletedAt: t.DeletedAt.Format(time.RFC3339), PurgeAfter: t.DeletedAt.Add(s.cfg.TrashRetention).Format(time.RFC3339), TaskCount: t.TaskCount, }) } writeJSON(w, http.StatusOK, map[string]any{"projects": out}) } func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "projectID") // Permission cannot be checked through ProjectRole here: a trashed project is // deliberately invisible to it. Ownership is what governs restoring. owner, err := s.db.ProjectOwner(r.Context(), projectID) if err != nil { s.storeError(w, r, "project owner", err) return } if owner != userFrom(r.Context()).ID { writeError(w, http.StatusNotFound, CodeNotFound, "not found") return } if err := s.db.RestoreProject(r.Context(), projectID); err != nil { s.storeError(w, r, "restore project", err) return } w.WriteHeader(http.StatusNoContent) } // colorFieldError names the closed set rather than listing it. The list is ten // long and lives in three places already; a caller that got it wrong needs to be // pointed at the documentation, not handed a copy that will one day be stale. const colorFieldError = "must be one of the colours in the palette — see the Color schema" func validateProject(name string, viewMode, color *string) map[string]string { fields := map[string]string{} switch { case name == "": fields["name"] = "required" case len([]rune(name)) > 200: fields["name"] = "must be 200 characters or fewer" } if viewMode != nil { switch *viewMode { case "list", "board", "calendar": default: fields["view_mode"] = "must be list, board or calendar" } } // Refused rather than stored and quietly painted as the default, which is what // an unknown name would do — and which looks like a colour that did not save. if color != nil && !store.ValidColor(*color) { fields["color"] = colorFieldError } return fields } // --- sections ----------------------------------------------------------------- type sectionJSON struct { ID string `json:"id"` ProjectID string `json:"project_id"` Name string `json:"name"` SortOrder float64 `json:"sort_order"` } func (s *Server) handleListSections(w http.ResponseWriter, r *http.Request) { sections, err := s.db.ListSections(r.Context(), chi.URLParam(r, "projectID")) if err != nil { s.internal(w, r, "list sections", err) return } out := make([]sectionJSON, 0, len(sections)) for _, sec := range sections { out = append(out, sectionJSON{sec.ID, sec.ProjectID, sec.Name, sec.SortOrder}) } writeJSON(w, http.StatusOK, map[string]any{"sections": out}) } type sectionRequest struct { Name *string `json:"name"` SortOrder *float64 `json:"sort_order"` } func (s *Server) handleCreateSection(w http.ResponseWriter, r *http.Request) { var req sectionRequest if err := decodeJSON(w, r, &req); err != nil { return } if req.Name == nil || strings.TrimSpace(*req.Name) == "" { writeFieldErrors(w, map[string]string{"name": "required"}) return } sec := &store.Section{ProjectID: chi.URLParam(r, "projectID"), Name: strings.TrimSpace(*req.Name)} if req.SortOrder != nil { sec.SortOrder = *req.SortOrder } if err := s.db.CreateSection(r.Context(), sec); err != nil { s.internal(w, r, "create section", err) return } s.activity(r, sec.ProjectID, "", "section.created", map[string]any{"name": sec.Name}) writeJSON(w, http.StatusCreated, sectionJSON{sec.ID, sec.ProjectID, sec.Name, sec.SortOrder}) } // handleReorderSections sets the order of the sections inside one project. // // Under /projects/{projectID}/ rather than beside the other section routes, which // hang off a section id: the thing being changed here is the project's list, not // any one section in it. That also puts it behind the same project permission // check as everything else in this subtree, instead of resolving a section to its // project one id at a time and hoping they all named the same one. func (s *Server) handleReorderSections(w http.ResponseWriter, r *http.Request) { var req reorderProjectsRequest if err := decodeJSON(w, r, &req); err != nil { return } // The same ceiling as the projects list, and a bound on the transaction rather // than an opinion about how many sections a project may have. if len(req.IDs) > 500 { writeFieldErrors(w, map[string]string{"ids": "at most 500 at a time"}) return } projectID := chi.URLParam(r, "projectID") if err := s.db.ReorderSections(r.Context(), projectID, req.IDs); err != nil { s.internal(w, r, "reorder sections", err) return } s.activity(r, projectID, "", "section.updated", nil) w.WriteHeader(http.StatusNoContent) } // sectionAccess resolves a section to its project and checks the caller may edit // it. Sections have no permissions of their own — they belong to a project, and // that is where access is decided. func (s *Server) sectionAccess(w http.ResponseWriter, r *http.Request, min store.Role) (string, string, bool) { sectionID := chi.URLParam(r, "sectionID") projectID, err := s.db.SectionProject(r.Context(), sectionID) if err != nil { s.storeError(w, r, "section project", err) return "", "", false } if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, userFrom(r.Context()).ID, min); err != nil { writeError(w, http.StatusNotFound, CodeNotFound, "not found") return "", "", false } return sectionID, projectID, true } func (s *Server) handleUpdateSection(w http.ResponseWriter, r *http.Request) { var req sectionRequest if err := decodeJSON(w, r, &req); err != nil { return } sectionID, projectID, ok := s.sectionAccess(w, r, store.RoleEditor) if !ok { return } if req.Name != nil { trimmed := strings.TrimSpace(*req.Name) if trimmed == "" { writeFieldErrors(w, map[string]string{"name": "required"}) return } req.Name = &trimmed } if err := s.db.UpdateSection(r.Context(), sectionID, store.SectionUpdate{ Name: req.Name, SortOrder: req.SortOrder, }); err != nil { s.storeError(w, r, "update section", err) return } s.activity(r, projectID, "", "section.updated", nil) w.WriteHeader(http.StatusNoContent) } func (s *Server) handleDeleteSection(w http.ResponseWriter, r *http.Request) { sectionID, projectID, ok := s.sectionAccess(w, r, store.RoleEditor) if !ok { return } if err := s.db.DeleteSection(r.Context(), sectionID); err != nil { s.storeError(w, r, "delete section", err) return } s.activity(r, projectID, "", "section.deleted", nil) w.WriteHeader(http.StatusNoContent) } // --- members ------------------------------------------------------------------ type memberJSON struct { UserID string `json:"user_id"` Email string `json:"email"` Name string `json:"name"` AvatarColor string `json:"avatar_color"` Role string `json:"role"` } func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) { members, err := s.db.ListMembers(r.Context(), chi.URLParam(r, "projectID")) if err != nil { s.internal(w, r, "list members", err) return } out := make([]memberJSON, 0, len(members)) for _, m := range members { out = append(out, memberJSON{m.UserID, m.Email, m.Name, m.AvatarColor, string(m.Role)}) } writeJSON(w, http.StatusOK, map[string]any{"members": out}) } type inviteRequest struct { Email string `json:"email"` Role string `json:"role"` } type inviteResponse struct { // Link is returned so the UI can offer to copy it. On an instance with no // mail server that is the only way the invite reaches anybody, and even with // one it saves waiting on delivery. Link string `json:"link"` Emailed bool `json:"emailed"` } // handleInvite shares a project with somebody. An existing user is added directly; // anybody else gets a link that creates their account and their membership at once. func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) { var req inviteRequest if err := decodeJSON(w, r, &req); err != nil { return } projectID := chi.URLParam(r, "projectID") inviter := userFrom(r.Context()) role := store.Role(req.Role) if role == "" { role = store.RoleEditor } if !role.Valid() || role == store.RoleOwner { writeFieldErrors(w, map[string]string{"role": "must be editor or viewer"}) return } email := store.NormalizeEmail(req.Email) if email == "" || !strings.Contains(email, "@") { writeFieldErrors(w, map[string]string{"email": "must be an email address"}) return } project, err := s.db.GetProject(r.Context(), projectID, inviter.ID) if err != nil { s.storeError(w, r, "get project", err) return } // Somebody who already has an account is added straight away: sending them // through a signup form they cannot complete would be a dead end. if existing, err := s.db.UserByEmail(r.Context(), email); err == nil { if existing.ID == project.OwnerID { writeError(w, http.StatusConflict, CodeConflict, "that person already owns this project") return } if err := s.db.AddMember(r.Context(), projectID, existing.ID, role); err != nil { s.internal(w, r, "add member", err) return } s.activity(r, projectID, "", "member.added", map[string]any{"email": email, "role": string(role)}) writeJSON(w, http.StatusOK, map[string]any{"added": true}) return } token, _, err := s.db.CreateInvite(r.Context(), email, projectID, role, inviter.ID, s.cfg.InviteTTL) if err != nil { s.internal(w, r, "create invite", err) return } link := s.cfg.BaseURL + "/invite?token=" + token emailed := false if s.mail.Configured() { if err := s.mail.SendInvite(r.Context(), email, inviter.Name, project.Name, link, s.cfg.InviteTTL); err != nil { s.log.Error("send invite", "err", err, "to", email) } else { emailed = true } } s.activity(r, projectID, "", "member.invited", map[string]any{"email": email, "role": string(role)}) writeJSON(w, http.StatusCreated, inviteResponse{Link: link, Emailed: emailed}) } type memberRoleRequest struct { Role string `json:"role"` } // handleSetMemberRole changes an existing member between editor and viewer. // // Until now the only way to correct an invite sent with the wrong role was to // remove the person and invite them again — which also unassigns everything they // were responsible for, because removing a member clears their tasks' assignee. // Fixing a dropdown should not cost somebody their work. func (s *Server) handleSetMemberRole(w http.ResponseWriter, r *http.Request) { var req memberRoleRequest if err := decodeJSON(w, r, &req); err != nil { return } projectID := chi.URLParam(r, "projectID") userID := chi.URLParam(r, "userID") role := store.Role(req.Role) if !role.Valid() || role == store.RoleOwner { writeFieldErrors(w, map[string]string{"role": "must be editor or viewer"}) return } // The owner has no project_members row at all — ownership lives on the project // — so this would report "not a member", which is true and completely // unhelpful. Say the real reason instead. owner, err := s.db.ProjectOwner(r.Context(), projectID) if err != nil { s.storeError(w, r, "project owner", err) return } if userID == owner { writeError(w, http.StatusConflict, CodeConflict, "the owner's standing cannot be changed; ownership is transferred, not granted") return } if err := s.db.SetMemberRole(r.Context(), projectID, userID, role); err != nil { s.storeError(w, r, "set member role", err) return } s.activity(r, projectID, "", "member.role_changed", map[string]any{"user_id": userID, "role": string(role)}) w.WriteHeader(http.StatusNoContent) } func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "projectID") userID := chi.URLParam(r, "userID") owner, err := s.db.ProjectOwner(r.Context(), projectID) if err != nil { s.storeError(w, r, "project owner", err) return } if userID == owner { writeError(w, http.StatusConflict, CodeConflict, "the owner cannot be removed from their own project") return } if err := s.db.RemoveMember(r.Context(), projectID, userID); err != nil { s.storeError(w, r, "remove member", err) return } s.activity(r, projectID, "", "member.removed", map[string]any{"user_id": userID}) w.WriteHeader(http.StatusNoContent) } // storeError maps a store error onto a status. // // Everything a caller is not allowed to see is reported as 404 rather than 403: a // 403 confirms the thing exists, which is exactly what somebody probing ids wants // to learn. func (s *Server) storeError(w http.ResponseWriter, r *http.Request, what string, err error) { switch { case errors.Is(err, store.ErrNotFound), errors.Is(err, store.ErrNoAccess): writeError(w, http.StatusNotFound, CodeNotFound, "not found") case errors.Is(err, store.ErrEmailInUse): writeError(w, http.StatusConflict, CodeConflict, "that email address already has an account") default: s.internal(w, r, what, err) } }