feat(notes): share a note with a person, not only through a project

Until now a note was shared exactly one way: file it in a shared project, and
everyone who could read the project could read the note. That is right when the
note belongs with a body of work and wrong when it is just "let Sofie see this" —
it made you stand up a project to hand over a single page.

A note can now be shared with named people directly, at viewer or editor — the
same two roles a project membership uses, so there is one idea of a role in the
program and not two. The paths stack: a note can be loose, filed, shared with
people, or any mix, and access is the most any of them allow.

The store:
  - note_shares (migration 0030): note ↔ user ↔ role, one row per person, owner
    recorded. Visibility (AllNotes, and search via mayTouchNote) widens to include
    notes shared directly with the reader.
  - the effective role folds a direct share on top of the owner and project paths.

The rules, drawn where they protect the owner:
  - read = viewer, edit text = editor, on any path.
  - MOVE a note (change its project) is owner-only: an editor filing it into a
    project the owner is not in would take the owner's own note away from them.
  - DELETE is owner or project-editor — a direct share, however high its role,
    does not reach to throwing the note away.
  - SHARE and UNSHARE are the owner's alone, like a project's invitations.
  - the picker is everyone on the instance (a closed, invited set) minus who is
    already on the note; a made-up id and the owner themselves are refused.

The list:
  - a "Delt med mig" group at the top, collapsible, that appears only when it has
    something in it — the same rule Favoritter follows — so it costs nothing when
    you share nothing. A shared note lives there, not also in a time bucket.
  - every shared note carries a small chip in its owner's avatar colour, the
    project mark's shape said for a person.
  - no star or archive button on a note somebody else owns: those change the note
    itself, and it is not yours to put away.
  - the editor grows a "Del med personer" panel for the owner: who can see it, at
    what role, and an add row drawn from the instance's other people.

The old comment in the notes footer argued against a note having an access list
of its own. That was the right call until the case it did not cover — sharing
without a project — turned out to be the one actually asked for. Updated to match.

Person now carries json tags, so a note's owner serialises as id/name/avatar_color
like every other person in the API rather than as Go field names.

Tests: a shared note reaches the other person marked and owned; a viewer reads but
cannot write; an editor writes but cannot re-share or delete; unshare removes
access and is owner-only; the candidate list excludes who is already on and the
owner; and a made-up id and self are refused. OpenAPI covers the three new routes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kristianwind
2026-08-24 07:36:55 +02:00
co-authored by Claude Opus 5
parent 15be5d2165
commit 91671a4f97
13 changed files with 1067 additions and 44 deletions
+51 -18
View File
@@ -19,21 +19,38 @@ import (
// Notes.
//
// Access follows the project, because that is where a note lives: a note in a
// shared project is readable by everybody who can read the project and writable
// by an editor. A note with no project is its author's alone — the same answer a
// task in the inbox gets, arrived at the same way rather than by a rule of its own.
// Access has three ways in, and a note may have any of them at once. A note with
// no project is its author's alone — the same answer a task in the inbox gets. A
// note in a project is readable by everyone who can read the project and writable
// by an editor. And a note can be shared with a person directly, which grants that
// person a role of its own. The effective role is the most any of the three allow.
// mayReadNote reports whether this person may see the note, and mayWriteNote
// whether they may change it. Split, because a viewer on a shared project can do
// the first and not the second.
// mayTouchNote reports whether this person may act on the note at the given role —
// RoleViewer to read, RoleEditor to change. Split by role because a viewer, whether
// on a shared project or a shared note, may do the first and not the second.
func (s *Server) mayTouchNote(r *http.Request, n *store.Note, role store.Role) bool {
user := userFrom(r.Context())
// The owner and the project path, exactly as before.
if n.ProjectID == "" {
return n.CreatedBy == user.ID
if n.CreatedBy == user.ID {
return true
}
} else if _, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, role); err == nil {
return true
}
_, err := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, user.ID, role)
return err == nil
// A direct share grants its role on top of whatever the above allow.
if shareRole, ok, err := s.db.NoteShareRole(r.Context(), n.ID, user.ID); err == nil && ok {
return shareRole.AtLeast(role)
}
return false
}
// ownsNote reports whether this person is the note's owner — its created_by — which
// is who, and only who, may hand it to other people. An editor on a shared note can
// change its text; giving away access is the owner's alone, the same shape a
// project takes where members edit but only the owner invites.
func (s *Server) ownsNote(r *http.Request, n *store.Note) bool {
return n != nil && n.CreatedBy == userFrom(r.Context()).ID
}
func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
@@ -48,7 +65,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "search notes", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(s.readable(r, found))})
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(s.readable(r, found)))})
return
}
@@ -62,7 +79,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "notes in project", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
return
}
@@ -75,7 +92,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "list archived notes", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
return
}
@@ -87,7 +104,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "list notes", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"notes": briefly(found)})
writeJSON(w, http.StatusOK, map[string]any{"notes": s.markShared(r, briefly(found))})
}
// briefly replaces each note's body with the first of it.
@@ -206,9 +223,16 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
return
}
if body.ProjectID != nil {
// Moving it means being allowed on both sides: you cannot file somebody
// else's note into your project, nor yours into theirs.
if body.ProjectID != nil && *body.ProjectID != n.ProjectID {
// Filing a note is the owner's call, not an editor's. A shared editor may
// change the text; letting them also move the note would let them file
// somebody else's note into a project the owner is not in — and the owner,
// who is not a member there, would lose their own note. So the move is
// owner-only, and then only into a project the owner may write to.
if !s.ownsNote(r, n) {
writeError(w, http.StatusForbidden, CodeForbidden, "only the note's owner can move it")
return
}
if *body.ProjectID != "" {
if _, err := store.RequireProjectRole(r.Context(), s.db, *body.ProjectID,
userFrom(r.Context()).ID, store.RoleEditor); err != nil {
@@ -238,7 +262,16 @@ func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "delete note", err)
return
}
if n == nil || !s.mayTouchNote(r, n, store.RoleEditor) {
// Deleting is not editing. Someone the note was shared with — even as an editor
// — may change its text, but throwing away the owner's note is the owner's to
// do. A project editor keeps the reach they have always had over a project's
// notes; a direct share does not extend that far.
mayDelete := s.ownsNote(r, n)
if !mayDelete && n != nil && n.ProjectID != "" {
_, perr := store.RequireProjectRole(r.Context(), s.db, n.ProjectID, userFrom(r.Context()).ID, store.RoleEditor)
mayDelete = perr == nil
}
if n == nil || !mayDelete {
writeError(w, http.StatusNotFound, CodeNotFound, "no such note")
return
}