Spørgsmålet var, om modtageren ikke burde godkende først. Svaret er nej — men det, spørgsmålet fik øje på, var rigtigt, og værre end det lød. Tre ting gjaldt samtidig: man blev tilføjet uden at blive spurgt, man fik ikke besked, og man kunne ikke gå ud igen. En delt note gav en klokkebesked; et delt projekt gav ingenting — det stod bare i sidebjælken næste gang, man kiggede, uden at noget sagde hvornår eller fra hvem. Og handleRemoveMember lå bag ejer-kravet, så kun ejeren kunne fjerne nogen, også en selv. Hvert enkelt af de tre er til at leve med. Alle tre sammen gør en deling til noget, der sker for en. Ikke et godkendelsestrin: en invitation, der venter på et ja, betyder, at den, der delte, tror det er gjort, mens arbejdet ligger stille — og noter deles allerede uden. Det, der manglede, var besked og en udgang. `project.shared` er ny. Klokkens follow() kunne allerede føre et project_id hen til projektet, så beskeden bærer sit eget mål og krævede ingen særregel. Ruten til at fjerne et medlem er flyttet ud af ejer-gruppen, og handleren skiller nu de to ting ad: ejeren, der fjerner nogen, og den, der selv går. At pege på en andens medlemskab uden at eje projektet svarer 404 og ikke 403 — "det må du ikke" bekræfter, at medlemskabet findes. Ejeren kan stadig ikke gå fra sit eget projekt; det ville være en overdragelse, og den findes ikke endnu. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
768 lines
26 KiB
Go
768 lines
26 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"`
|
|
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"`
|
|
}
|
|
|
|
// projectInviteJSON er en invitation, der venter på en konto. Der er intet navn og
|
|
// ingen farve at vise: adressen er alt, hvad der findes om personen, indtil de
|
|
// dukker op.
|
|
type projectInviteJSON struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
Role string `json:"role"`
|
|
}
|
|
|
|
// handleListMembers is everything the share panel needs, in one answer: who is on
|
|
// the project, who can be added, and who has been invited and not arrived.
|
|
//
|
|
// Kandidaterne kommer med her frem for fra et endepunkt for sig, så panelet åbner
|
|
// færdigt i ét kald — samme form som notens delingspanel. Og de er nødvendige:
|
|
// indtil nu kunne et projekt kun deles ved at skrive en e-mailadresse, hvilket
|
|
// betyder, at man skal kende adressen på en kollega, man kender navnet på.
|
|
//
|
|
// De inviterede står ved siden af medlemmerne, fordi de ellers hverken er i den
|
|
// ene liste eller den anden — de har ingen konto at være medlem med, og de er
|
|
// derfor også fraværende fra kandidaterne. Uden dem ser en invitation sendt i går
|
|
// ud som om den aldrig blev sendt, og så bliver den sendt igen.
|
|
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
|
|
projectID := chi.URLParam(r, "projectID")
|
|
|
|
members, err := s.db.ListMembers(r.Context(), projectID)
|
|
if err != nil {
|
|
s.internal(w, r, "list members", err)
|
|
return
|
|
}
|
|
out := make([]memberJSON, 0, len(members))
|
|
already := map[string]bool{}
|
|
for _, m := range members {
|
|
out = append(out, memberJSON{m.UserID, m.Email, m.Name, m.AvatarColor, string(m.Role)})
|
|
already[m.UserID] = true
|
|
}
|
|
|
|
project, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID)
|
|
if err != nil {
|
|
s.storeError(w, r, "get project", err)
|
|
return
|
|
}
|
|
already[project.OwnerID] = true
|
|
|
|
people, err := s.db.UsersForSharing(r.Context(), userFrom(r.Context()).ID)
|
|
if err != nil {
|
|
s.internal(w, r, "share candidates", err)
|
|
return
|
|
}
|
|
candidates := make([]personJSON, 0, len(people))
|
|
for _, p := range people {
|
|
if already[p.ID] {
|
|
continue
|
|
}
|
|
candidates = append(candidates, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
|
|
}
|
|
|
|
invites, err := s.db.ListProjectInvites(r.Context(), projectID)
|
|
if err != nil {
|
|
s.internal(w, r, "list project invites", err)
|
|
return
|
|
}
|
|
pending := make([]projectInviteJSON, 0, len(invites))
|
|
for _, i := range invites {
|
|
pending = append(pending, projectInviteJSON{ID: i.ID, Email: i.Email, Role: string(i.Role)})
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"members": out,
|
|
"candidates": candidates,
|
|
"pending": pending,
|
|
})
|
|
}
|
|
|
|
// handleDeleteProjectInvite withdraws an invitation that has not been taken up.
|
|
//
|
|
// Uden den er den ventende liste kun til at se på, og en invitation sendt til en
|
|
// forkert adresse kan ikke tages tilbage — den bliver bare liggende, til den
|
|
// udløber, med et link, der virker hele tiden.
|
|
func (s *Server) handleDeleteProjectInvite(w http.ResponseWriter, r *http.Request) {
|
|
projectID := chi.URLParam(r, "projectID")
|
|
err := s.db.DeleteProjectInvite(r.Context(), projectID, chi.URLParam(r, "inviteID"))
|
|
if err != nil {
|
|
s.storeError(w, r, "delete project invite", err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
type inviteRequest struct {
|
|
// UserID er den, man har peget på i listen. Email er den, man kender adressen
|
|
// på — og den er også den, der virker, når der ingen konto er endnu.
|
|
UserID string `json:"user_id"`
|
|
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.
|
|
//
|
|
// Der er to måder at sige, hvem det er — samme to som en note deles med. `user_id`
|
|
// er den, man har peget på i listen, og den er grunden til at det her blev skrevet
|
|
// om: indtil nu kunne et projekt kun deles ved at stave en e-mailadresse, altså
|
|
// ved at kende adressen på en kollega, man kender navnet på. `email` er stadig
|
|
// vejen til dem, der ikke er her endnu, og den bliver til et link, der opretter
|
|
// kontoen og medlemskabet på én gang.
|
|
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 req.UserID == "" && email == "" {
|
|
writeFieldErrors(w, map[string]string{"user_id": "required"})
|
|
return
|
|
}
|
|
if req.UserID == "" && !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
|
|
}
|
|
|
|
// En adresse, der viser sig at høre til en konto, ER den konto: at sende dem
|
|
// gennem en oprettelsesside, de ikke kan komme igennem, ville være en
|
|
// blindgyde. Derfor slås adressen op først, og de to veje mødes her.
|
|
if req.UserID == "" {
|
|
if existing, err := s.db.UserByEmail(r.Context(), email); err == nil {
|
|
req.UserID = existing.ID
|
|
}
|
|
}
|
|
|
|
if req.UserID != "" {
|
|
if req.UserID == project.OwnerID {
|
|
writeError(w, http.StatusConflict, CodeConflict, "that person already owns this project")
|
|
return
|
|
}
|
|
// Slået op frem for taget på ordet: et id, der ikke findes, ville ellers
|
|
// blive til en medlemsrække, der peger på ingen — og panelet ville vise en
|
|
// tom plads, ingen kan fjerne.
|
|
person, err := s.db.PersonByID(r.Context(), req.UserID)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "no such person")
|
|
return
|
|
}
|
|
if err := s.db.AddMember(r.Context(), projectID, person.ID, role); err != nil {
|
|
s.internal(w, r, "add member", err)
|
|
return
|
|
}
|
|
s.activity(r, projectID, "", "member.added", map[string]any{"name": person.Name, "role": string(role)})
|
|
// Og de får det at vide. Før stod projektet bare i sidebjælken næste gang,
|
|
// de kiggede — uden at noget sagde hvornår eller fra hvem.
|
|
s.notifyProjectShared(r, project, person.ID)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"added": true,
|
|
// Personen sendes med retur, fordi fladen kan have delt med en adresse
|
|
// og derfor ikke selv ved, hvem den ramte.
|
|
"user": personJSON{ID: person.ID, Name: person.Name, AvatarColor: person.AvatarColor},
|
|
"role": string(role),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Et andet link til den samme indbakke hjælper ingen: det første virker
|
|
// stadig, og nu er der to at holde styr på og to at trække tilbage.
|
|
if pending, err := s.db.PendingProjectInvite(r.Context(), projectID, email); err == nil && pending {
|
|
writeError(w, http.StatusConflict, CodeConflict, "that address has already been invited")
|
|
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)
|
|
}
|
|
|
|
// handleRemoveMember takes somebody off a project — the owner removing anybody, or
|
|
// anybody removing themselves.
|
|
//
|
|
// Den anden halvdel er ny, og den er grunden til at ruten er flyttet ud af
|
|
// ejer-gruppen. Man blev lukket ind i et projekt uden at blive spurgt, og kunne så
|
|
// ikke komme ud igen: kun ejeren kunne fjerne en, også en selv. To af de tre ting
|
|
// kan man leve med; alle tre er det, der gør en deling til noget, der sker for en.
|
|
//
|
|
// Ejeren kan stadig ikke gå fra sit eget projekt. Det er ikke en udgang, det er en
|
|
// overdragelse, og den findes ikke endnu.
|
|
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
|
projectID := chi.URLParam(r, "projectID")
|
|
userID := chi.URLParam(r, "userID")
|
|
me := userFrom(r.Context())
|
|
|
|
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
|
if err != nil {
|
|
s.storeError(w, r, "project owner", err)
|
|
return
|
|
}
|
|
|
|
// 404 og ikke 403: at sige "det må du ikke" til en, der peger på en anden
|
|
// persons medlemskab, bekræfter at medlemskabet findes.
|
|
if me.ID != owner && userID != me.ID {
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "no such member")
|
|
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)
|
|
}
|
|
}
|