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>
601 lines
20 KiB
Go
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)
|
|
}
|
|
}
|