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 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
15be5d2165
commit
91671a4f97
@@ -0,0 +1,133 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NoteShare is one person's direct grant on a note: who they are, and what they
|
||||
// may do. The person is carried whole rather than by id, because every caller of
|
||||
// this wants to show them — a name and an avatar colour, never a bare id.
|
||||
type NoteShare struct {
|
||||
User Person `json:"user"`
|
||||
Role Role `json:"role"`
|
||||
}
|
||||
|
||||
// ShareNote grants a person a role on a note, or changes the role if they already
|
||||
// had one. The owner is passed so the row records who did the sharing; the caller
|
||||
// has already checked that they may.
|
||||
//
|
||||
// Sharing a note with its own owner is refused: the owner already has everything a
|
||||
// share could give, and a row saying otherwise is a contradiction waiting to
|
||||
// confuse the access check.
|
||||
func (db *DB) ShareNote(ctx context.Context, noteID, userID string, role Role, byUserID string) error {
|
||||
if !role.Valid() || role == RoleOwner {
|
||||
return errors.New("store: a note can be shared as viewer or editor")
|
||||
}
|
||||
var createdBy sql.NullString
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(created_by, '') FROM notes WHERE id = ? AND deleted_at IS NULL`,
|
||||
noteID).Scan(&createdBy.String); errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if createdBy.String == userID {
|
||||
return errors.New("store: a note is already the owner's")
|
||||
}
|
||||
_, err := db.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, userID, role, byUserID, time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
// UnshareNote removes a person's grant. Removing one that is not there is not an
|
||||
// error: the end state the caller asked for — this person cannot see this note —
|
||||
// is the same either way.
|
||||
func (db *DB) UnshareNote(ctx context.Context, noteID, userID string) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
`DELETE FROM note_shares WHERE note_id = ? AND user_id = ?`, noteID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListNoteShares is who a note is shared with, each with their role, ordered by
|
||||
// name so the list under a note does not reshuffle every time it is opened.
|
||||
func (db *DB) ListNoteShares(ctx context.Context, noteID string) ([]NoteShare, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT u.id, u.name, u.avatar_color, s.role
|
||||
FROM note_shares s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.note_id = ?
|
||||
ORDER BY lower(u.name)`, noteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []NoteShare
|
||||
for rows.Next() {
|
||||
var s NoteShare
|
||||
if err := rows.Scan(&s.User.ID, &s.User.Name, &s.User.AvatarColor, &s.Role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// NoteShareRole is the role a person holds on a note through a direct share, if
|
||||
// any. The bool is whether a share exists at all — distinct from a share whose
|
||||
// role could not be read — so a caller does not have to treat "no share" as an
|
||||
// error.
|
||||
func (db *DB) NoteShareRole(ctx context.Context, noteID, userID string) (Role, bool, error) {
|
||||
var role Role
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT role FROM note_shares WHERE note_id = ? AND user_id = ?`, noteID, userID).Scan(&role)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if !role.Valid() {
|
||||
// A role the database holds but this build does not understand is no access,
|
||||
// never a guess — the same rule project roles follow.
|
||||
return "", false, nil
|
||||
}
|
||||
return role, true, nil
|
||||
}
|
||||
|
||||
// NotesSharedWith returns the ids, among those given, that are shared directly with
|
||||
// the user. It answers the Delt med mig question in one query rather than one per
|
||||
// note, and takes the ids it is scoping so it never has to load a person's whole
|
||||
// world to mark a single page.
|
||||
func (db *DB) NotesSharedWith(ctx context.Context, userID string, noteIDs []string) (map[string]bool, error) {
|
||||
out := map[string]bool{}
|
||||
if len(noteIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
args := make([]any, 0, len(noteIDs)+1)
|
||||
args = append(args, userID)
|
||||
for _, id := range noteIDs {
|
||||
args = append(args, id)
|
||||
}
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT note_id FROM note_shares WHERE user_id = ? AND note_id IN (`+placeholders(len(noteIDs))+`)`,
|
||||
args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id] = true
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
Reference in New Issue
Block a user