Files
verdande/internal/httpapi/projects_handlers.go
T
Kristian WindandClaude Opus 5 01d498be20 fix(projects): group_id was accepted on create and thrown away
The field was declared on the request type, commented on it, and applied by the
update handler — and the create handler never read it. Creating a project into a
group answered 201 and put it nowhere. A field that is accepted and discarded is
worse than one that is refused: nothing reports it.

Found by looking at a screenshot. Both groups stood empty while the projects sat
loose above them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:05:36 +02:00

601 lines
20 KiB
Go

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"`
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,
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})
}
// 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)
}
}