feat(projekter): del et projekt med en, der allerede er her
Et projekt kunne kun deles ved at stave en e-mailadresse. Det er den forkerte
vej at spørge om en kollega, man kender navnet på og sidder i samme rum som —
og beslutningen om, hvorvidt en instans' konti må vises for hinanden, var
allerede taget: `UsersForSharing` har gjort det for noter hele tiden, med
begrundelsen skrevet ved siden af. Der er ingen åben oprettelse, så konti er en
lukket, inviteret mængde, og det er en adressebog frem for en udlevering.
Projekterne bruger nu den samme.
`GET /members` svarer med tre lister frem for én, så panelet åbner færdigt i ét
kald — samme form som notens delingspanel. `POST /invites` tager `user_id` ved
siden af `email`, og de to veje mødes ét sted: en adresse, der viser sig at høre
til en konto, ER den konto, og skal ikke gennem en oprettelsesside, den ikke kan
komme igennem.
De ventende invitationer kom med, fordi den nye liste skabte behovet for dem:
den, der er inviteret og ikke kommet, har ingen konto og står derfor hverken
blandt medlemmerne eller blandt dem, man kan vælge. Før betød det, at man ikke
kunne se sin egen invitation; nu ville det betyde, at man leder efter en person,
der mangler i listen, og inviterer igen. Det andet link virker lige så godt som
det første. De kan trækkes tilbage, for ellers er den ventende liste kun til at
se på.
Tre ting, der kostede en kørsel hver:
`OpenAPI is valid` fangede den nye DELETE-rute, før jeg selv gjorde.
To felter hed "Rolle" på den samme side. Det er ikke kun en prøve, der brokker
sig — en skærmlæser siger "Rolle" to gange uden at kunne skelne. De har nu hver
sin tilstand og hvert sit navn, og den gamle prøves `getByLabel('Rolle')` er
blevet `exact`, fordi den matchede som understreng.
Og e2e-prøven lånte først sin anden person fra prøven ovenfor. Det så jeg kun,
fordi jeg kørte den alene: basen tømmes for hver kørsel, så en prøve, der kun
består, når hele filen kører i rækkefølge, består ikke af sine egne grunde. Den
laver nu selv sin — og viser dermed skiftet: den samme person er først en
ventende adresse og bagefter et navn, der kan vælges.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a39f655565
commit
9b21b3eac5
@@ -452,22 +452,100 @@ type memberJSON struct {
|
||||
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) {
|
||||
members, err := s.db.ListMembers(r.Context(), chi.URLParam(r, "projectID"))
|
||||
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
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"members": out})
|
||||
|
||||
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 {
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
// 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 {
|
||||
@@ -478,8 +556,14 @@ type inviteResponse struct {
|
||||
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.
|
||||
// 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 {
|
||||
@@ -496,8 +580,13 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
writeFieldErrors(w, map[string]string{"role": "must be editor or viewer"})
|
||||
return
|
||||
}
|
||||
|
||||
email := store.NormalizeEmail(req.Email)
|
||||
if email == "" || !strings.Contains(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
|
||||
}
|
||||
@@ -508,19 +597,47 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
// 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
|
||||
}
|
||||
if err := s.db.AddMember(r.Context(), projectID, existing.ID, role); err != nil {
|
||||
// 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{"email": email, "role": string(role)})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"added": true})
|
||||
s.activity(r, projectID, "", "member.added", map[string]any{"name": person.Name, "role": string(role)})
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -486,3 +486,144 @@ func TestASectionHoldsMoreThanOneTask(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Et projekt kunne kun deles ved at stave en e-mailadresse. Det er den forkerte
|
||||
// vej at spørge om en kollega, man kender navnet på og sidder i samme rum som — og
|
||||
// på en instans, hvor konti kun bliver til ved invitation, er der ikke noget at
|
||||
// beskytte ved at lade være med at vise dem.
|
||||
func TestAProjectCanBeSharedWithSomebodyWhoIsAlreadyHere(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
ts.newUser(t, "anden@example.dk", "Anden")
|
||||
|
||||
anden, err := ts.db.UserByEmail(t.Context(), "anden@example.dk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
|
||||
projectID := project["id"].(string)
|
||||
|
||||
// Først står de i listen over dem, der kan tilføjes.
|
||||
_, before := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||
if !hasPerson(before["candidates"], anden.ID) {
|
||||
t.Fatalf("Anden er ikke blandt kandidaterne: %v", before["candidates"])
|
||||
}
|
||||
|
||||
// Delt ved at pege, ikke ved at stave.
|
||||
resp, added := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||
map[string]any{"user_id": anden.ID, "role": "editor"})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status %d, body %v", resp.StatusCode, added)
|
||||
}
|
||||
// Personen kommer med retur, så fladen kan tegne rækken uden at spørge igen.
|
||||
if user, _ := added["user"].(map[string]any); user["name"] != "Anden" {
|
||||
t.Errorf("user = %v, want Anden", added["user"])
|
||||
}
|
||||
|
||||
// Og bagefter er de et medlem og ikke længere en kandidat: en liste, der
|
||||
// tilbyder at tilføje en, der allerede er der, er en liste, man gør det i.
|
||||
_, after := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||
if hasPerson(after["candidates"], anden.ID) {
|
||||
t.Errorf("Anden står stadig som kandidat efter at være tilføjet")
|
||||
}
|
||||
found := false
|
||||
for _, raw := range after["members"].([]any) {
|
||||
if m := raw.(map[string]any); m["user_id"] == anden.ID {
|
||||
found = true
|
||||
if m["role"] != "editor" {
|
||||
t.Errorf("role = %v, want editor", m["role"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Anden blev ikke medlem: %v", after["members"])
|
||||
}
|
||||
}
|
||||
|
||||
// Ejeren skal ikke kunne tilbydes som en, projektet kan deles med — de har det.
|
||||
func TestTheOwnerIsNotOfferedAsSomebodyToShareWith(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
me, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/projects/"+project["id"].(string)+"/members", nil)
|
||||
if hasPerson(body["candidates"], me.ID) {
|
||||
t.Errorf("ejeren står som kandidat til sit eget projekt")
|
||||
}
|
||||
}
|
||||
|
||||
// En invitation, der venter, står ved siden af medlemmerne — og kan tages tilbage.
|
||||
//
|
||||
// Uden den er den usynlig: personen har ingen konto, så de er hverken medlem eller
|
||||
// kandidat, og så bliver invitationen sendt igen. Det andet link virker lige så
|
||||
// godt som det første, og så er der to at trække tilbage i stedet for et.
|
||||
func TestAPendingInviteIsVisibleAndCanBeWithdrawn(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
|
||||
projectID := project["id"].(string)
|
||||
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||
map[string]any{"email": "fremmed@example.dk", "role": "viewer"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("invitation: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||
pending, _ := body["pending"].([]any)
|
||||
if len(pending) != 1 {
|
||||
t.Fatalf("%d ventende invitationer, want 1: %v", len(pending), body["pending"])
|
||||
}
|
||||
first := pending[0].(map[string]any)
|
||||
if first["email"] != "fremmed@example.dk" {
|
||||
t.Errorf("email = %v", first["email"])
|
||||
}
|
||||
|
||||
// Et andet link til den samme indbakke hjælper ingen.
|
||||
if resp, _ := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
|
||||
map[string]any{"email": "fremmed@example.dk", "role": "viewer"}); resp.StatusCode != http.StatusConflict {
|
||||
t.Errorf("en gentaget invitation gav %d, want 409", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, _ = ts.do(t, "DELETE",
|
||||
"/api/v1/projects/"+projectID+"/invites/"+first["id"].(string), nil)
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("tilbagetrækning: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, after := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/members", nil)
|
||||
if p, _ := after["pending"].([]any); len(p) != 0 {
|
||||
t.Errorf("invitationen står der stadig: %v", after["pending"])
|
||||
}
|
||||
}
|
||||
|
||||
// Et id, der ikke findes, må ikke blive til en medlemsrække, der peger på ingen.
|
||||
func TestSharingWithSomebodyWhoDoesNotExistIsRefused(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/projects/"+project["id"].(string)+"/invites",
|
||||
map[string]any{"user_id": "0198f000-0000-7000-8000-00000000dead", "role": "editor"})
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// hasPerson siger, om en liste af personer indeholder et bestemt id.
|
||||
func hasPerson(raw any, id string) bool {
|
||||
list, _ := raw.([]any)
|
||||
for _, item := range list {
|
||||
if p, ok := item.(map[string]any); ok && p["id"] == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -506,6 +506,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Patch("/", s.handleUpdateProject)
|
||||
r.Delete("/", s.handleDeleteProject)
|
||||
r.Post("/invites", s.handleInvite)
|
||||
r.Delete("/invites/{inviteID}", s.handleDeleteProjectInvite)
|
||||
r.Patch("/members/{userID}", s.handleSetMemberRole)
|
||||
r.Delete("/members/{userID}", s.handleRemoveMember)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user