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>
134 lines
4.4 KiB
Go
134 lines
4.4 KiB
Go
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()
|
|
}
|