feat(noter): del en note med en, der ikke har en konto endnu

En note kunne deles med dem, der allerede var på instansen — og kun med
dem. Skulle en anden læse med, skulle man først invitere dem ind, vente
på at de oprettede sig, finde noten frem og så dele den. Fire skridt og
en ventetid for det, der er én sætning: lad Sofie se den her.

Nu er der ét felt. Man skriver adressen, og serveren ved, om der er en
konto bag: er der det, bliver det en deling med det samme, og ellers en
invitation, der bliver til delingen i samme øjeblik, kontoen oprettes.
Hvem der har en konto, er ikke noget, man skal vide for at dele en note.

Rækken er den, der allerede fandtes. En invitation bar i forvejen en
e-mail, en rolle og et forseglet token; det eneste, der manglede, var at
kunne sige, hvad den var en invitation *til*. note_id ved siden af
project_id, med fremmednøglen — dén, der rydder invitationen væk, når
noten slettes, inden nogen når at tage imod en invitation til noget, der
ikke er der længere.

Det er ikke ny magt til nogen: enhver, der ejer et projekt, kunne i
forvejen invitere en fremmed ind på instansen pr. e-mail. Kun ejeren kan
invitere til en note, som kun ejeren kan dele den.

De inviterede står i panelet ved siden af dem, der er kommet, og kan
trækkes tilbage. Uden det er en invitation sendt i går usynlig, og så
sender man den igen — endnu et link til den samme indbakke, og kun det
ene af dem til at gøre ugyldigt.

Og delingen siger selv til: den, der får en note delt, får besked om, at
den er der. Klokken sorterer nu på id efter tid, fordi tiden er sekunder
— en note, der bliver delt og rettet i samme håndbevægelse, stod ellers
i den rækkefølge, basen nu engang fandt dem i.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kristianwind
2026-09-08 11:58:47 +02:00
co-authored by Claude Opus 5
parent ab77024ec9
commit 8ee9fbedfb
15 changed files with 858 additions and 54 deletions
+186
View File
@@ -0,0 +1,186 @@
package httpapi
import (
"net/http"
"strings"
"testing"
)
// Hele vejen: ejeren deler en note med en adresse, der ikke har nogen konto, og
// den, der tager imod invitationen, har noten fra det øjeblik, kontoen findes.
func TestANoteCanBeSharedWithSomebodyWhoHasNoAccount(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
noteID := owner.createNote(t, "# Aftale om levering\nfredag kl. 9")
resp, body := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"email": "sofie@example.dk", "role": "editor"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("invite by email: status %d, body %v", resp.StatusCode, body)
}
invited, _ := body["invited"].(map[string]any)
if invited == nil || invited["email"] != "sofie@example.dk" {
t.Fatalf("the response does not say who was invited: %v", body)
}
if invited["role"] != "editor" {
t.Errorf("role = %v, want the one the owner chose", invited["role"])
}
// Uden en postserver er linket det eneste, der findes, og det skal stå i
// svaret frem for at blive lovet i en mail, der ikke kom af sted.
if body["emailed"] != false {
t.Errorf("emailed = %v, want false with no mail server configured", body["emailed"])
}
link, _ := body["link"].(string)
if !strings.Contains(link, "token=") {
t.Fatalf("link = %q, want one carrying a token", link)
}
// Den venter i panelet under noten, så ejeren kan se, at den er sendt.
_, panel := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
pending, _ := panel["invites"].([]any)
if len(pending) != 1 {
t.Fatalf("want one pending invite under the note, got %v", pending)
}
// Og linket laver kontoen og delingen i ét.
token := link[strings.Index(link, "token=")+len("token="):]
fresh := newSignedOut(t, owner)
resp, _ = fresh.do(t, "POST", "/api/v1/auth/signup", map[string]any{
"token": token, "name": "Sofie", "password": "et langt kodeord",
})
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
t.Fatalf("signup with the note invite: status %d", resp.StatusCode)
}
// Noten er hendes at læse — og som medredaktør også at rette.
resp, note := fresh.do(t, "GET", "/api/v1/notes/"+noteID, nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("the invited person cannot read the note: status %d", resp.StatusCode)
}
if note["title"] != "Aftale om levering" {
t.Errorf("the invited person got something else: %v", note)
}
resp, _ = fresh.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]string{"body": "# Aftale om levering\nfredag kl. 10"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("an invited editor cannot write: status %d", resp.StatusCode)
}
// Og invitationen er brugt op: den står ikke længere og venter.
_, after := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
if left, _ := after["invites"].([]any); len(left) != 0 {
t.Errorf("the invite is still pending after it was accepted: %v", left)
}
shares, _ := after["shares"].([]any)
if len(shares) != 1 {
t.Fatalf("want the accepted invite to be a share, got %v", shares)
}
}
// En adresse, der viser sig at høre til en konto, er den konto — ikke en
// invitation til at oprette en, som personen aldrig kan komme igennem.
func TestSharingByEmailFindsAnExistingAccount(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
noteID := owner.createNote(t, "# Prisliste\n2026")
resp, body := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"email": "Sofie@Example.dk"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("share with an existing address: status %d, body %v", resp.StatusCode, body)
}
person, _ := body["user"].(map[string]any)
if person == nil || person["id"] != sofie {
t.Fatalf("the response does not say who it reached: %v", body)
}
if _, invited := body["invited"]; invited {
t.Error("an address with an account was invited rather than shared with")
}
role, _, err := owner.db.NoteShareRole(t.Context(), noteID, sofie)
if err != nil || role != "viewer" {
t.Fatalf("share role = %v (%v), want viewer by default", role, err)
}
}
// To links til den samme indbakke er ikke en invitation mere: det er to, der gør
// det samme, og kun det ene kan trækkes tilbage ad gangen.
func TestASecondInviteToTheSameNoteIsRefused(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
noteID := owner.createNote(t, "# Bankoplysninger\nkonto")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"email": "ny@example.dk"}); resp.StatusCode != http.StatusCreated {
t.Fatalf("first invite: status %d", resp.StatusCode)
}
resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"email": "ny@example.dk"})
if resp.StatusCode != http.StatusConflict {
t.Fatalf("second invite: status %d, want 409", resp.StatusCode)
}
// Trukket tilbage kan den derimod, og så kan der sendes en ny.
_, panel := owner.do(t, "GET", "/api/v1/notes/"+noteID+"/shares", nil)
pending, _ := panel["invites"].([]any)
id := pending[0].(map[string]any)["id"].(string)
if resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+noteID+"/invites/"+id, nil); resp.StatusCode != http.StatusNoContent {
t.Fatalf("withdraw: status %d", resp.StatusCode)
}
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"email": "ny@example.dk"}); resp.StatusCode != http.StatusCreated {
t.Fatalf("invite again after withdrawing: status %d", resp.StatusCode)
}
}
// Kun ejeren. En medlæser, der kunne invitere, ville kunne give noten videre til
// nogen, ejeren aldrig har hørt om — og til en konto, der ikke fandtes før.
func TestOnlyTheOwnerCanInviteToANote(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
noteID := owner.createNote(t, "# Ferieplan\nuge 29")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
t.Fatalf("share: status %d", resp.StatusCode)
}
resp, _ := other.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"email": "fremmed@example.dk"})
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("an editor could invite somebody: status %d", resp.StatusCode)
}
if n, err := owner.db.ListNoteInvites(t.Context(), noteID); err != nil || len(n) != 0 {
t.Fatalf("invites = %v (%v), want none", n, err)
}
}
// En invitation til én note kan ikke bruges til at rydde en anden notes
// invitationer af vejen: note-id'et er en del af WHERE, ikke et tjek udenom.
func TestANoteInviteCannotBeWithdrawnThroughAnotherNote(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
mine := owner.createNote(t, "# Min note\n1")
other := owner.createNote(t, "# Min anden note\n2")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+mine+"/shares",
map[string]string{"email": "ny@example.dk"}); resp.StatusCode != http.StatusCreated {
t.Fatal("invite failed")
}
invites, err := owner.db.ListNoteInvites(t.Context(), mine)
if err != nil || len(invites) != 1 {
t.Fatalf("invites = %v (%v)", invites, err)
}
resp, _ := owner.do(t, "DELETE", "/api/v1/notes/"+other+"/invites/"+invites[0].ID, nil)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status %d, want 404 for another note's invite", resp.StatusCode)
}
if left, _ := owner.db.ListNoteInvites(t.Context(), mine); len(left) != 1 {
t.Error("the invite was withdrawn through a note it does not belong to")
}
}
+145 -7
View File
@@ -2,6 +2,7 @@ package httpapi
import (
"net/http"
"strings"
"github.com/go-chi/chi/v5"
@@ -128,9 +129,23 @@ func (s *Server) handleListNoteShares(w http.ResponseWriter, r *http.Request) {
cand = append(cand, personJSON{ID: p.ID, Name: p.Name, AvatarColor: p.AvatarColor})
}
// De inviterede står ved siden af dem, der er kommet. Uden dem er en
// invitation sendt i går usynlig, og så sender man den igen — endnu et link til
// den samme indbakke, og ingen af dem til at trække tilbage.
invites, err := s.db.ListNoteInvites(r.Context(), n.ID)
if err != nil {
s.internal(w, r, "list note invites", err)
return
}
pending := make([]noteInviteJSON, 0, len(invites))
for _, i := range invites {
pending = append(pending, noteInviteJSON{ID: i.ID, Email: i.Email, Role: string(i.Role)})
}
writeJSON(w, http.StatusOK, map[string]any{
"shares": out,
"candidates": cand,
"invites": pending,
"follows": followsOf(following, n.ID),
})
}
@@ -167,10 +182,33 @@ type noteShareJSON struct {
type shareNoteRequest struct {
UserID string `json:"user_id"`
Role string `json:"role"`
// Email deles der med, når personen ikke er at finde i listen — enten fordi
// man kender adressen bedre end navnet, eller fordi der ikke er nogen konto at
// finde endnu.
Email string `json:"email"`
Role string `json:"role"`
}
// noteInviteJSON er en deling, der venter på en konto. Der er ikke noget navn og
// ingen farve at vise: adressen er alt, hvad der findes om personen indtil de
// dukker op.
type noteInviteJSON struct {
ID string `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
}
// handleShareNote grants a person a role on a note, or changes the role they hold.
//
// Der er to måder at sige, hvem det er. `user_id` er den, man har peget på i
// listen. `email` er den, man kender adressen på — og det er den, der også virker,
// når personen ikke har nogen konto: så bliver delingen til en invitation, der
// venter, og bliver til en rigtig deling i det øjeblik, kontoen bliver oprettet.
//
// Det er ikke en ny magt til nogen. Enhver, der ejer et projekt, kunne i forvejen
// invitere en fremmed ind på instansen pr. e-mail; det her er den samme handling
// på en note, der ellers krævede tre skridt og en ventetid: invitér til instansen,
// vent på at de opretter sig, find noten frem, del den.
func (s *Server) handleShareNote(w http.ResponseWriter, r *http.Request) {
var req shareNoteRequest
if err := decodeJSON(w, r, &req); err != nil {
@@ -194,34 +232,134 @@ func (s *Server) handleShareNote(w http.ResponseWriter, r *http.Request) {
writeFieldErrors(w, map[string]string{"role": "must be viewer or editor"})
return
}
if req.UserID == "" {
me := userFrom(r.Context())
email := store.NormalizeEmail(req.Email)
if req.UserID == "" && email == "" {
writeFieldErrors(w, map[string]string{"user_id": "required"})
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
// — samme regel som en projektinvitation følger.
if req.UserID == "" {
if !strings.Contains(email, "@") {
writeFieldErrors(w, map[string]string{"email": "must be an email address"})
return
}
if email == store.NormalizeEmail(me.Email) {
writeFieldErrors(w, map[string]string{"email": "you already have this note"})
return
}
existing, err := s.db.UserByEmail(r.Context(), email)
if err != nil {
s.inviteToNote(w, r, n, email, role)
return
}
req.UserID = existing.ID
}
// The recipient must be a real account and not the sharer themselves; ShareNote
// refuses the owner, and a made-up id should not reach it.
if req.UserID == userFrom(r.Context()).ID {
if req.UserID == me.ID {
writeFieldErrors(w, map[string]string{"user_id": "you already have this note"})
return
}
if _, err := s.db.PersonByID(r.Context(), req.UserID); err != nil {
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.ShareNote(r.Context(), n.ID, req.UserID, role, userFrom(r.Context()).ID); err != nil {
if err := s.db.ShareNote(r.Context(), n.ID, req.UserID, role, me.ID); err != nil {
s.storeError(w, r, "share note", err)
return
}
s.notifyNoteShared(r, n, req.UserID)
// Og det, noten peger på, følger med. Svaret siger hvad — en deling, der stille
// tog tre noter mere med, ville være rigtig og alligevel ikke til at overskue.
followed, err := s.db.SyncLinkedShares(r.Context(), userFrom(r.Context()).ID)
followed, err := s.db.SyncLinkedShares(r.Context(), me.ID)
if err != nil {
s.internal(w, r, "sync linked shares", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"follows": followsOf(followed, n.ID)})
writeJSON(w, http.StatusOK, map[string]any{
"follows": followsOf(followed, n.ID),
// 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),
})
}
// inviteToNote sends a link that creates an account and the share at once.
//
// Linket vises i svaret, når der ikke er nogen post at sende det med. En
// invitation, der forsvandt, fordi instansen ikke har en SMTP-server, ville se ud
// som om den var sendt — og først blive opdaget som ikke-sendt af den, der ventede
// på den.
func (s *Server) inviteToNote(w http.ResponseWriter, r *http.Request, n *store.Note, email string, role store.Role) {
me := userFrom(r.Context())
// En anden invitation til den samme note i den samme indbakke er ikke en
// invitation mere: det er to links, der gør det samme, og kun det ene kan
// trækkes tilbage ad gangen.
pending, err := s.db.PendingNoteInvite(r.Context(), n.ID, email)
if err != nil {
s.internal(w, r, "pending note invite", err)
return
}
if pending {
writeError(w, http.StatusConflict, CodeConflict, "that address has already been invited to this note")
return
}
token, inv, err := s.db.CreateNoteInvite(r.Context(), email, n.ID, role, me.ID, s.cfg.InviteTTL)
if err != nil {
s.internal(w, r, "create note invite", err)
return
}
link := s.cfg.BaseURL + "/invite?token=" + token
emailed := false
if s.mail.Configured() {
if err := s.mail.SendNoteInvite(r.Context(), email, me.Name, n.Title, link, s.cfg.InviteTTL); err != nil {
s.log.Error("send note invite", "err", err, "to", email)
} else {
emailed = true
}
}
writeJSON(w, http.StatusCreated, map[string]any{
"invited": noteInviteJSON{ID: inv.ID, Email: inv.Email, Role: string(inv.Role)},
"link": link,
"emailed": emailed,
})
}
// handleDeleteNoteInvite withdraws an invitation before it is used.
//
// Det er den eneste vej tilbage. Kun aftrykket af tokenet er gemt, så et link, der
// er sendt til den forkerte adresse, kan ikke findes frem og gøres ugyldigt — det
// kan kun rækken, det hører til.
func (s *Server) handleDeleteNoteInvite(w http.ResponseWriter, r *http.Request) {
n, err := s.db.Note(r.Context(), chi.URLParam(r, "noteID"))
if err != nil {
s.internal(w, r, "get note", err)
return
}
if n == nil || !s.ownsNote(r, n) {
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
return
}
if err := s.db.DeleteNoteInvite(r.Context(), n.ID, chi.URLParam(r, "inviteID")); err != nil {
s.storeError(w, r, "delete note invite", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleUnshareNote takes a person's access away again.
@@ -120,6 +120,26 @@ func (s *Server) notifyAssigned(r *http.Request, t *store.Task) {
})
}
// notifyNoteShared tells somebody that a note is now theirs to read.
//
// Én besked, når delingen sker, og ikke noget bagefter: den, der får en note delt,
// skal vide, at den er der. At den senere bliver rettet, er en anden besked — den
// skriver notifyNoteChanged — og de to må ikke sige det samme om det samme.
//
// Ikke sammenlagt med noget. En deling sker én gang pr. person pr. note, så der er
// ikke en gentagelse at folde sammen — og bliver rollen ændret bagefter, er det
// ikke en ny note at læse.
func (s *Server) notifyNoteShared(r *http.Request, n *store.Note, userID string) {
if n == nil || userID == "" {
return
}
actor := userFrom(r.Context())
s.notify(r, &store.Notification{
UserID: userID, ActorID: actor.ID, ProjectID: n.ProjectID, NoteID: n.ID,
Kind: "note.shared", Title: actor.Name + " delte en note med dig", Body: n.Title,
})
}
// notifyNoteChanged tells everybody who can see a note that somebody else wrote in
// it — folded into one line per note while it is unread.
//
+17 -8
View File
@@ -109,14 +109,22 @@ func TestEditingASharedNoteNotifiesTheOthers(t *testing.T) {
t.Fatal("del noten")
}
// Selve delingen er den første besked: Sofie skal vide, at noten er der, før
// nogen retter i den. Den tælles med i alt, hvad der følger.
if list, unread := notifications(t, other); len(list) != 1 || unread != 1 ||
firstOf(t, list)["kind"] != "note.shared" {
t.Fatalf("delingen gav %d beskeder (%v ulæste): %v", len(list), unread, list)
}
// Ejeren skriver. Sofie skal høre det.
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale om levering\nanden linje"}); resp.StatusCode != http.StatusOK {
t.Fatal("ret noten")
}
list, unread := notifications(t, other)
if unread != 1 || len(list) != 1 {
t.Fatalf("Sofie fik %d beskeder (%v ulæste), want 1", len(list), unread)
if unread != 2 || len(list) != 2 {
t.Fatalf("Sofie fik %d beskeder (%v ulæste), want 2 — delingen og rettelsen",
len(list), unread)
}
n := firstOf(t, list)
if n["kind"] != "note.changed" || n["note_id"] != noteID {
@@ -132,8 +140,9 @@ func TestEditingASharedNoteNotifiesTheOthers(t *testing.T) {
t.Fatal("ret noten igen")
}
}
if list, unread := notifications(t, other); len(list) != 1 || unread != 1 {
t.Errorf("tre gemninger blev til %d beskeder, want 1", len(list))
if list, unread := notifications(t, other); len(list) != 2 || unread != 2 {
t.Errorf("tre gemninger blev til %d beskeder, want 2 — delingen og én rettelse",
len(list))
}
// Læst, og så en rettelse mere: dét er en ny ting at få at vide.
@@ -144,8 +153,8 @@ func TestEditingASharedNoteNotifiesTheOthers(t *testing.T) {
map[string]any{"body": "# Aftale om levering\nefter læsningen"}); resp.StatusCode != http.StatusOK {
t.Fatal("ret noten efter læsningen")
}
if list, unread := notifications(t, other); len(list) != 2 || unread != 1 {
t.Errorf("efter læsningen gav en rettelse %d beskeder (%v ulæste), want 2 og 1", len(list), unread)
if list, unread := notifications(t, other); len(list) != 3 || unread != 1 {
t.Errorf("efter læsningen gav en rettelse %d beskeder (%v ulæste), want 3 og 1", len(list), unread)
}
// Ejeren har ikke fået besked om sine egne rettelser undervejs.
@@ -171,8 +180,8 @@ func TestEditingASharedNoteNotifiesTheOthers(t *testing.T) {
map[string]any{"pinned": true}); resp.StatusCode != http.StatusOK {
t.Fatal("stjernemarkér")
}
if list, _ := notifications(t, other); len(list) != 2 {
t.Errorf("en stjernemarkering gav besked: %d beskeder, want 2", len(list))
if list, _ := notifications(t, other); len(list) != 3 {
t.Errorf("en stjernemarkering gav besked: %d beskeder, want 3", len(list))
}
}
+3
View File
@@ -353,6 +353,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/{noteID}/shares", s.handleListNoteShares)
r.Post("/{noteID}/shares", s.handleShareNote)
r.Delete("/{noteID}/shares/{userID}", s.handleUnshareNote)
// En invitation til en note er en deling, der venter på en konto.
// Den kan tages tilbage indtil den bruges; se handleShareNote.
r.Delete("/{noteID}/invites/{inviteID}", s.handleDeleteNoteInvite)
r.Get("/{noteID}", s.handleGetNote)
r.Patch("/{noteID}", s.handleUpdateNote)
r.Delete("/{noteID}", s.handleDeleteNote)
+20 -1
View File
@@ -38,11 +38,30 @@ func New(cfg config.SMTP, baseURL string, log *slog.Logger) *Sender {
func (s *Sender) Configured() bool { return s.cfg.Configured() }
func (s *Sender) SendInvite(ctx context.Context, to, inviterName, projectName, link string, ttl time.Duration) error {
subject := fmt.Sprintf("%s har delt noget med dig i verdande", inviterName)
what := "verdande"
if projectName != "" {
what = "projektet “" + projectName + "”"
}
return s.sendInvite(ctx, to, inviterName, what, link, ttl)
}
// SendNoteInvite is the same letter about a single note.
//
// The title is in the mail because it is what makes the invitation mean anything —
// "Kristian har inviteret dig til noten “Aftale om levering”" is a sentence
// somebody can decide about, where "til en note" is one they have to click a link
// to understand. It travels no further than the address the owner typed, which is
// the address they chose to share the note with.
func (s *Sender) SendNoteInvite(ctx context.Context, to, inviterName, noteTitle, link string, ttl time.Duration) error {
what := "en note"
if noteTitle != "" {
what = "noten “" + noteTitle + "”"
}
return s.sendInvite(ctx, to, inviterName, what, link, ttl)
}
func (s *Sender) sendInvite(ctx context.Context, to, inviterName, what, link string, ttl time.Duration) error {
subject := fmt.Sprintf("%s har delt noget med dig i verdande", inviterName)
body := fmt.Sprintf(`Hej
%s har inviteret dig til %s.
+161 -36
View File
@@ -16,6 +16,7 @@ type Invite struct {
ID string
Email string
ProjectID string // empty for an invite to the instance rather than to a project
NoteID string // set when the invite is to a single note
Role Role
CreatedBy string
CreatedAt time.Time
@@ -68,36 +69,46 @@ func (db *DB) CreateFirstAdmin(ctx context.Context, u *User, inboxName string) e
})
}
// CreateInvite issues an invite and returns the token for the emailed link.
// projectID may be empty, which invites somebody to the instance rather than to a
// particular project.
// CreateInvite issues an invite to a project and returns the token for the emailed
// link. projectID may be empty, which invites somebody to the instance rather than
// to a particular project.
func (db *DB) CreateInvite(ctx context.Context, email, projectID string, role Role, createdBy string, ttl time.Duration) (string, *Invite, error) {
if !role.Valid() {
return "", nil, fmt.Errorf("store: %q is not a role", role)
return db.createInvite(ctx, &Invite{Email: email, ProjectID: projectID, Role: role, CreatedBy: createdBy}, ttl)
}
// CreateNoteInvite issues an invite to a single note.
//
// Same row, same token, same link as a project invite — the difference is only
// what the person gets when they arrive, and that is settled in AcceptInvite. A
// second table for "invited to a note" would have meant a second signup path to
// keep in step with this one, and the two would drift the first time either was
// touched.
func (db *DB) CreateNoteInvite(ctx context.Context, email, noteID string, role Role, createdBy string, ttl time.Duration) (string, *Invite, error) {
if noteID == "" {
return "", nil, errors.New("store: a note invite needs a note")
}
return db.createInvite(ctx, &Invite{Email: email, NoteID: noteID, Role: role, CreatedBy: createdBy}, ttl)
}
func (db *DB) createInvite(ctx context.Context, inv *Invite, ttl time.Duration) (string, *Invite, error) {
if !inv.Role.Valid() {
return "", nil, fmt.Errorf("store: %q is not a role", inv.Role)
}
token, err := auth.NewToken()
if err != nil {
return "", nil, err
}
now := time.Now().UTC()
inv := &Invite{
ID: NewID(),
Email: NormalizeEmail(email),
ProjectID: projectID,
Role: role,
CreatedBy: createdBy,
CreatedAt: now,
ExpiresAt: now.Add(ttl),
}
inv.ID = NewID()
inv.Email = NormalizeEmail(inv.Email)
inv.CreatedAt = now
inv.ExpiresAt = now.Add(ttl)
var project any
if projectID != "" {
project = projectID
}
_, err = db.ExecContext(ctx,
`INSERT INTO invites (id, email, project_id, role, token_hash, created_by, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
inv.ID, inv.Email, project, string(inv.Role), auth.HashToken(token),
`INSERT INTO invites (id, email, project_id, note_id, role, token_hash, created_by, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
inv.ID, inv.Email, nullString(inv.ProjectID), nullString(inv.NoteID),
string(inv.Role), auth.HashToken(token),
inv.CreatedBy, inv.CreatedAt.Unix(), inv.ExpiresAt.Unix())
if err != nil {
return "", nil, err
@@ -112,14 +123,14 @@ func (db *DB) InviteByToken(ctx context.Context, token string) (*Invite, error)
return nil, ErrInviteInvalid
}
var inv Invite
var projectID sql.NullString
var projectID, noteID sql.NullString
var created, expires int64
var accepted sql.NullInt64
err := db.QueryRowContext(ctx,
`SELECT id, email, project_id, role, created_by, created_at, expires_at, accepted_at
`SELECT id, email, project_id, note_id, role, created_by, created_at, expires_at, accepted_at
FROM invites WHERE token_hash = ?`, auth.HashToken(token)).
Scan(&inv.ID, &inv.Email, &projectID, &inv.Role, &inv.CreatedBy, &created, &expires, &accepted)
Scan(&inv.ID, &inv.Email, &projectID, &noteID, &inv.Role, &inv.CreatedBy, &created, &expires, &accepted)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrInviteInvalid
}
@@ -131,6 +142,7 @@ func (db *DB) InviteByToken(ctx context.Context, token string) (*Invite, error)
}
inv.ProjectID = projectID.String
inv.NoteID = noteID.String
inv.CreatedAt = time.Unix(created, 0).UTC()
inv.ExpiresAt = time.Unix(expires, 0).UTC()
return &inv, nil
@@ -144,10 +156,16 @@ type PendingInvite struct {
Email string
ProjectID string
ProjectName string
Role Role
InvitedBy string
CreatedAt time.Time
ExpiresAt time.Time
// NoteID is set for an invitation to a single note. The note is named by its
// id and never by its title: titles are sealed in the database, and a private
// note's first line is not something an administrator's user list should
// spell out. That there is an account on its way is the administrator's
// business; what it was invited to read is not.
NoteID string
Role Role
InvitedBy string
CreatedAt time.Time
ExpiresAt time.Time
}
// ListPendingInvites returns invites that are neither accepted nor expired.
@@ -159,7 +177,8 @@ type PendingInvite struct {
// able to say which it is.
func (db *DB) ListPendingInvites(ctx context.Context) ([]PendingInvite, error) {
rows, err := db.QueryContext(ctx, `
SELECT i.id, i.email, COALESCE(i.project_id, ''), COALESCE(p.name, ''), i.role,
SELECT i.id, i.email, COALESCE(i.project_id, ''), COALESCE(p.name, ''),
COALESCE(i.note_id, ''), i.role,
COALESCE(u.name, ''), i.created_at, i.expires_at
FROM invites i
LEFT JOIN projects p ON p.id = i.project_id
@@ -175,8 +194,8 @@ func (db *DB) ListPendingInvites(ctx context.Context) ([]PendingInvite, error) {
for rows.Next() {
var p PendingInvite
var created, expires int64
if err := rows.Scan(&p.ID, &p.Email, &p.ProjectID, &p.ProjectName, &p.Role,
&p.InvitedBy, &created, &expires); err != nil {
if err := rows.Scan(&p.ID, &p.Email, &p.ProjectID, &p.ProjectName, &p.NoteID,
&p.Role, &p.InvitedBy, &created, &expires); err != nil {
return nil, err
}
p.CreatedAt = time.Unix(created, 0).UTC()
@@ -200,12 +219,16 @@ func (db *DB) DeleteInvite(ctx context.Context, inviteID string) error {
return nil
}
// AcceptInvite marks the invite used and grants the membership it promised, in one
// AcceptInvite marks the invite used and grants what it promised, in one
// transaction. Splitting the two would allow a signup to fail partway and leave a
// live invite behind, which is a link that still works after it has been used.
//
// The UPDATE is guarded on accepted_at IS NULL, so two requests racing with the
// same link produce one membership and one failure rather than two memberships.
// same link produce one grant and one failure rather than two.
//
// What is granted depends on what the invite was to: a project membership, a share
// on a single note, or — when it carries neither — nothing beyond the account the
// caller has just created.
func (db *DB) AcceptInvite(ctx context.Context, inviteID, userID string) error {
return db.Tx(ctx, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx,
@@ -218,13 +241,40 @@ func (db *DB) AcceptInvite(ctx context.Context, inviteID, userID string) error {
return ErrInviteInvalid
}
var projectID sql.NullString
var projectID, noteID sql.NullString
var role Role
var invitedBy string
if err := tx.QueryRowContext(ctx,
`SELECT project_id, role FROM invites WHERE id = ?`, inviteID).
Scan(&projectID, &role); err != nil {
`SELECT project_id, note_id, role, created_by FROM invites WHERE id = ?`, inviteID).
Scan(&projectID, &noteID, &role, &invitedBy); err != nil {
return err
}
if noteID.Valid && noteID.String != "" {
// created_by is the person who invited, not the person arriving: the row
// records who did the sharing, the same as a share made from the panel.
//
// The note may have been deleted between the invite and the signup. The
// foreign key would refuse the row and take the whole signup with it, so
// the note is looked up first and a share on a note that is gone is
// simply not made — the account is still worth creating.
var alive int
if err := tx.QueryRowContext(ctx,
`SELECT count(*) FROM notes WHERE id = ? AND deleted_at IS NULL AND created_by <> ?`,
noteID.String, userID).Scan(&alive); err != nil {
return err
}
if alive == 0 {
return nil
}
_, err = tx.ExecContext(ctx, `
INSERT INTO note_shares (note_id, user_id, role, created_by, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (note_id, user_id) DO UPDATE SET role = excluded.role`,
noteID.String, userID, string(role), invitedBy, time.Now().Unix())
return err
}
if !projectID.Valid || projectID.String == "" {
return nil // an invite to the instance, with no project attached
}
@@ -240,6 +290,81 @@ func (db *DB) AcceptInvite(ctx context.Context, inviteID, userID string) error {
})
}
// --- invitationer til én note ----------------------------------------------------
// NoteInvite is somebody who has been invited to a note and has not arrived yet.
// The email address is the whole of who they are — there is no account to name
// them by, which is the entire reason the row exists.
type NoteInvite struct {
ID string `json:"id"`
Email string `json:"email"`
Role Role `json:"role"`
CreatedAt time.Time `json:"-"`
ExpiresAt time.Time `json:"-"`
}
// ListNoteInvites is who has been invited to a note and not yet taken it up.
//
// The panel needs them beside the people who *are* on the note: without this, an
// invitation sent yesterday is invisible, and the owner sends it again — a second
// link to the same inbox, and no way to take either back.
//
// Expired ones are left out, the same rule the administrator's list follows: an
// invite past its date is not something anybody can act on.
func (db *DB) ListNoteInvites(ctx context.Context, noteID string) ([]NoteInvite, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, email, role, created_at, expires_at
FROM invites
WHERE note_id = ? AND accepted_at IS NULL AND expires_at > ?
ORDER BY created_at`, noteID, time.Now().Unix())
if err != nil {
return nil, err
}
defer rows.Close()
out := []NoteInvite{}
for rows.Next() {
var i NoteInvite
var created, expires int64
if err := rows.Scan(&i.ID, &i.Email, &i.Role, &created, &expires); err != nil {
return nil, err
}
i.CreatedAt = time.Unix(created, 0).UTC()
i.ExpiresAt = time.Unix(expires, 0).UTC()
out = append(out, i)
}
return out, rows.Err()
}
// DeleteNoteInvite withdraws an invitation to a note, and refuses to withdraw
// anything else.
//
// The note id is part of the WHERE rather than checked beforehand: the owner has
// been established for *this* note, and one statement that can only touch this
// note's rows cannot be talked into deleting a project invite by id.
func (db *DB) DeleteNoteInvite(ctx context.Context, noteID, inviteID string) error {
res, err := db.ExecContext(ctx,
`DELETE FROM invites WHERE id = ? AND note_id = ?`, inviteID, noteID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// PendingNoteInvite reports whether this address already has a live invitation to
// this note, so a second attempt can say so instead of sending a second link.
func (db *DB) PendingNoteInvite(ctx context.Context, noteID, email string) (bool, error) {
var n int
err := db.QueryRowContext(ctx, `
SELECT count(*) FROM invites
WHERE note_id = ? AND email = ? AND accepted_at IS NULL AND expires_at > ?`,
noteID, NormalizeEmail(email), time.Now().Unix()).Scan(&n)
return n > 0, err
}
// --- password resets ----------------------------------------------------------
// CreatePasswordReset invalidates any outstanding reset for the user before issuing
@@ -0,0 +1,30 @@
-- En invitation kan handle om en note.
--
-- En note kunne deles med nogen, der allerede havde en konto — og kun med dem.
-- Skulle en, der ikke havde en, læse med, skulle man først invitere dem til
-- instansen, vente på at de oprettede sig, og så finde noten frem og dele den. Tre
-- skridt og en ventetid for det, der er én sætning: "lad Sofie se den her".
--
-- Rækken er den, der allerede findes. En invitation bærer i forvejen en e-mail, en
-- rolle og et forseglet token; det eneste, der manglede, var at kunne sige, hvad
-- den er en invitation *til*. project_id sagde det for projekter, og note_id siger
-- det nu for noter.
--
-- To søjler frem for én kolonne med et id og en art ved siden af — samme valg som
-- notifications traf i 0033, og af samme grund: fremmednøglen. ON DELETE CASCADE
-- er dén, der rydder invitationen væk, når noten slettes, inden nogen når at tage
-- imod en invitation til noget, der ikke er der længere. Et generisk par kan ikke
-- have den nøgle, og oprydningen skulle så skrives i kode, der huskede at køre.
--
-- De to udelukker hinanden i praksis, men det står ikke som en CHECK: en
-- invitation uden nogen af delene er den gyldige "kom med på instansen", og en
-- regel, der skulle tillade nul eller én af to, ville skulle skrives om hver gang
-- der kom en tredje slags ting at invitere til.
ALTER TABLE invites ADD COLUMN note_id TEXT REFERENCES notes (id) ON DELETE CASCADE;
-- Panelet under en note spørger "hvem er inviteret til den her, og har ikke taget
-- imod endnu" hver gang, det åbnes. Det er opslaget, og det er derfor, det er
-- indekset. Kun de uafklarede: en invitation, der er taget imod, er en deling nu
-- og står i note_shares.
CREATE INDEX invites_pending_by_note
ON invites (note_id) WHERE accepted_at IS NULL AND note_id IS NOT NULL;
+6 -1
View File
@@ -75,7 +75,12 @@ func (db *DB) ListNotifications(ctx context.Context, userID string, limit int) (
FROM notifications n
LEFT JOIN users u ON u.id = n.actor_id
WHERE n.user_id = ?
ORDER BY n.created_at DESC
-- Tiden er sekunder, og to ting kan ske i det samme sekund: en note bliver
-- delt og rettet i samme håndbevægelse. Uden andet at falde tilbage på
-- afgør basen selv, hvilken der står øverst, og klokken viser dem i den
-- rækkefølge, den nu engang fandt dem i. id'et er tidsordnet, så det er
-- den rigtige afgørelse at give den — og den er den samme hver gang.
ORDER BY n.created_at DESC, n.id DESC
LIMIT ?`, userID, limit)
if err != nil {
return nil, err