2026-08-24 07:36:55 +02:00
|
|
|
package store
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"database/sql"
|
|
|
|
|
"errors"
|
2026-09-07 22:28:58 +02:00
|
|
|
"sort"
|
|
|
|
|
"strings"
|
2026-08-24 07:36:55 +02:00
|
|
|
"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()
|
|
|
|
|
}
|
2026-09-07 22:28:58 +02:00
|
|
|
|
|
|
|
|
// --- de noter, en delt note peger på ---------------------------------------------
|
|
|
|
|
|
|
|
|
|
// LinkedShare is one note that followed a share: which note, what it is called,
|
|
|
|
|
// and which of the owner's shared notes brought it. The panel shows all three —
|
|
|
|
|
// "Prisliste came along with Aftale om levering" is a sentence somebody can act
|
|
|
|
|
// on, where a bare id is not.
|
|
|
|
|
type LinkedShare struct {
|
|
|
|
|
NoteID string `json:"note_id"`
|
|
|
|
|
Title string `json:"title"`
|
|
|
|
|
ViaID string `json:"via_id"`
|
|
|
|
|
Via string `json:"via"`
|
|
|
|
|
Role Role `json:"role"`
|
|
|
|
|
UserID string `json:"user_id"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Hvor langt en deling må brede sig.
|
|
|
|
|
//
|
|
|
|
|
// En note, der peger på en note, der peger på en note, er stadig én ting at læse,
|
|
|
|
|
// og den skal følge med. Men noter kan pege i ring og i vifte, og "del denne ene
|
|
|
|
|
// note" må ikke kunne blive til fire hundrede. Loftet er ikke en optimering: det
|
|
|
|
|
// er den grænse, hvor en deling holder op med at være noget, ejeren kan overskue.
|
|
|
|
|
const linkedShareLimit = 100
|
|
|
|
|
|
|
|
|
|
// SyncLinkedShares regner de udledte delinger forfra for alt, hvad én person ejer.
|
|
|
|
|
//
|
|
|
|
|
// Kaldt efter enhver af ejerens egne handlinger, der kan flytte svaret: en deling,
|
|
|
|
|
// en fjernet deling, og en gemning, der har ændret på det, noten peger på. Regnet
|
|
|
|
|
// forfra frem for rettet til, fordi det er den samme kode hver gang og derfor det
|
|
|
|
|
// samme resultat hver gang — en tilføjelse og en fjernelse er ikke to veje gennem
|
|
|
|
|
// koden, men det samme kald med et andet udgangspunkt.
|
|
|
|
|
//
|
|
|
|
|
// Kun *direkte* delinger er udgangspunkt. En note i et delt projekt er delt af
|
|
|
|
|
// projektet, og projektets folk kan se projektets noter; det er en anden vej ind,
|
|
|
|
|
// med sine egne regler, og den blander sig ikke her.
|
|
|
|
|
//
|
|
|
|
|
// Returnerer, hvad der nu følger med, så kalderen kan sige det. Det er meningen at
|
|
|
|
|
// det bliver sagt: en deling, der stille tager tre noter mere med, er ikke til at
|
|
|
|
|
// overskue, uanset hvor rigtigt den gør det.
|
|
|
|
|
func (db *DB) SyncLinkedShares(ctx context.Context, ownerID string) ([]LinkedShare, error) {
|
|
|
|
|
if ownerID == "" {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Har personen ikke delt noget, er der intet at regne — og det er de fleste,
|
|
|
|
|
// det meste af tiden. Uden det her ville hvert eneste klik i notelisten åbne
|
|
|
|
|
// hver eneste note for at finde ud af, at svaret er tomt: fladen spørger til
|
|
|
|
|
// delingerne, hver gang man vælger en note, man selv ejer.
|
|
|
|
|
//
|
|
|
|
|
// Ét spørgsmål dækker begge halvdele. Er der ingen rækker overhovedet, er der
|
|
|
|
|
// hverken en deling at brede ud eller en udledt række at rydde op efter.
|
|
|
|
|
var anyShare int
|
|
|
|
|
if err := db.QueryRowContext(ctx, `
|
|
|
|
|
SELECT EXISTS(
|
|
|
|
|
SELECT 1 FROM note_shares s
|
|
|
|
|
JOIN notes n ON n.id = s.note_id
|
|
|
|
|
WHERE n.created_by = ?)`, ownerID).Scan(&anyShare); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if anyShare == 0 {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ejerens egne noter, åbnet. Titlerne er forseglet i basen, og et link peger på
|
|
|
|
|
// en titel — så der er ikke noget opslag at lave i SQL. Målt andetsteds i denne
|
|
|
|
|
// fil: tolv hundrede noter koster nogle og tredive millisekunder at åbne, og det
|
|
|
|
|
// her kører kun, når ejeren selv har gjort noget.
|
|
|
|
|
mine, err := db.notesWhere(ctx, `WHERE created_by = ? AND deleted_at IS NULL`, ownerID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
byTitle := map[string]string{}
|
|
|
|
|
title := map[string]string{}
|
|
|
|
|
for _, n := range mine {
|
|
|
|
|
title[n.ID] = n.Title
|
|
|
|
|
key := strings.ToLower(strings.TrimSpace(n.Title))
|
|
|
|
|
// Den første vinder, og listen kommer sorteret med den senest rørte først:
|
|
|
|
|
// to noter med samme titel er sjældent, og når det sker, er den, man skrev
|
|
|
|
|
// sidst, den, man mente.
|
|
|
|
|
if key != "" {
|
|
|
|
|
if _, taken := byTitle[key]; !taken {
|
|
|
|
|
byTitle[key] = n.ID
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Hvad hver note peger på, i ét spørgsmål frem for ét pr. note.
|
|
|
|
|
links := map[string][]string{}
|
|
|
|
|
rows, err := db.QueryContext(ctx, `
|
|
|
|
|
SELECT l.note_id, l.target_id
|
|
|
|
|
FROM note_links l
|
|
|
|
|
JOIN notes n ON n.id = l.note_id
|
|
|
|
|
WHERE l.kind = 'note' AND n.created_by = ? AND n.deleted_at IS NULL`, ownerID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
var from, target string
|
|
|
|
|
if err := rows.Scan(&from, &target); err != nil {
|
|
|
|
|
rows.Close()
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
links[from] = append(links[from], target)
|
|
|
|
|
}
|
|
|
|
|
rows.Close()
|
|
|
|
|
if err := rows.Err(); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// De delinger, ejeren selv har sat.
|
|
|
|
|
type grant struct{ noteID, userID string }
|
|
|
|
|
direct := map[grant]Role{}
|
|
|
|
|
rows, err = db.QueryContext(ctx, `
|
|
|
|
|
SELECT s.note_id, s.user_id, s.role
|
|
|
|
|
FROM note_shares s
|
|
|
|
|
JOIN notes n ON n.id = s.note_id
|
|
|
|
|
WHERE s.linked = 0 AND n.created_by = ? AND n.deleted_at IS NULL`, ownerID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
var g grant
|
|
|
|
|
var role Role
|
|
|
|
|
if err := rows.Scan(&g.noteID, &g.userID, &role); err != nil {
|
|
|
|
|
rows.Close()
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if role.Valid() {
|
|
|
|
|
direct[g] = role
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
rows.Close()
|
|
|
|
|
if err := rows.Err(); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Vandringen. Pr. person, fordi det er personen, delingen handler om: to
|
|
|
|
|
// forskellige mennesker kan have to forskellige noter og derfor to forskellige
|
|
|
|
|
// vifter ud fra dem.
|
|
|
|
|
wanted := map[grant]LinkedShare{}
|
|
|
|
|
perUser := map[string][]grant{}
|
|
|
|
|
for g := range direct {
|
|
|
|
|
perUser[g.userID] = append(perUser[g.userID], g)
|
|
|
|
|
}
|
|
|
|
|
for userID, roots := range perUser {
|
|
|
|
|
seen := map[string]bool{}
|
|
|
|
|
type step struct {
|
|
|
|
|
noteID string
|
|
|
|
|
via string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Medredaktør først, læser bagefter, og hver runde tømt for sig.
|
|
|
|
|
//
|
|
|
|
|
// En note kan nås fra to delte noter med hver sin rolle, og så skal den
|
|
|
|
|
// stærkeste gælde. Én kø med det hele i ville afgøre det på afstand frem for
|
|
|
|
|
// på rolle: et barnebarn af en redigerbar note ville tabe til et barn af en
|
|
|
|
|
// læsbar, fordi det stod længere bagude i køen. To runder gør reglen til
|
|
|
|
|
// rækkefølgen — er noten allerede nået som redigerbar, ser den anden runde
|
|
|
|
|
// den ikke.
|
|
|
|
|
for _, role := range []Role{RoleEditor, RoleViewer} {
|
|
|
|
|
var queue []step
|
|
|
|
|
for _, g := range roots {
|
|
|
|
|
if direct[g] != role || seen[g.noteID] {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
seen[g.noteID] = true
|
|
|
|
|
queue = append(queue, step{noteID: g.noteID, via: g.noteID})
|
|
|
|
|
}
|
|
|
|
|
for len(queue) > 0 && len(seen) <= linkedShareLimit {
|
|
|
|
|
cur := queue[0]
|
|
|
|
|
queue = queue[1:]
|
|
|
|
|
for _, target := range links[cur.noteID] {
|
|
|
|
|
id, ok := byTitle[strings.ToLower(strings.TrimSpace(target))]
|
|
|
|
|
// Et link, der ikke rammer en af ejerens egne noter, er enten
|
|
|
|
|
// dødt eller peger på en andens note. Ingen af delene er ejerens
|
|
|
|
|
// at dele.
|
|
|
|
|
if !ok || seen[id] {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
seen[id] = true
|
|
|
|
|
// Rollen arves fra den note, man kom fra. En medredaktør på en
|
|
|
|
|
// note bliver medredaktør på det, den peger på: teksten er én
|
|
|
|
|
// ting at arbejde i, og et link midt i den er ikke en grænse.
|
|
|
|
|
if _, isDirect := direct[grant{noteID: id, userID: userID}]; !isDirect {
|
|
|
|
|
wanted[grant{noteID: id, userID: userID}] = LinkedShare{
|
|
|
|
|
NoteID: id,
|
|
|
|
|
Title: title[id],
|
|
|
|
|
ViaID: cur.via,
|
|
|
|
|
Via: title[cur.via],
|
|
|
|
|
Role: role,
|
|
|
|
|
UserID: userID,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
queue = append(queue, step{noteID: id, via: cur.via})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Skrevet i én transaktion: mellem sletningen og skrivningen står basen med
|
|
|
|
|
// færre delinger, end den skal, og det er ikke et vindue nogen skal kunne læse i.
|
|
|
|
|
err = db.Tx(ctx, func(tx *sql.Tx) error {
|
|
|
|
|
if _, err := tx.ExecContext(ctx, `
|
|
|
|
|
DELETE FROM note_shares
|
|
|
|
|
WHERE linked = 1
|
|
|
|
|
AND note_id IN (SELECT id FROM notes WHERE created_by = ?)`, ownerID); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
for g, l := range wanted {
|
|
|
|
|
// DO NOTHING ved sammenstød: en direkte deling på den samme note er
|
|
|
|
|
// ejerens eget valg og har forrang. Den kan ikke nå herned — den er
|
|
|
|
|
// sorteret fra ovenfor — men reglen skal stå dér, hvor rækken skrives,
|
|
|
|
|
// ikke kun dér, hvor den udregnes.
|
|
|
|
|
if _, err := tx.ExecContext(ctx, `
|
|
|
|
|
INSERT INTO note_shares (note_id, user_id, role, created_by, created_at, linked)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, 1)
|
|
|
|
|
ON CONFLICT (note_id, user_id) DO NOTHING`,
|
|
|
|
|
g.noteID, g.userID, l.Role, ownerID, time.Now().Unix()); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
out := make([]LinkedShare, 0, len(wanted))
|
|
|
|
|
for _, l := range wanted {
|
|
|
|
|
out = append(out, l)
|
|
|
|
|
}
|
|
|
|
|
sort.Slice(out, func(i, j int) bool {
|
|
|
|
|
if out[i].UserID != out[j].UserID {
|
|
|
|
|
return out[i].UserID < out[j].UserID
|
|
|
|
|
}
|
|
|
|
|
return strings.ToLower(out[i].Title) < strings.ToLower(out[j].Title)
|
|
|
|
|
})
|
|
|
|
|
return out, nil
|
|
|
|
|
}
|
2026-09-08 11:40:43 +02:00
|
|
|
|
|
|
|
|
// NoteAudience is everybody who can see a note: its owner, the people it is shared
|
|
|
|
|
// with — chosen or followed along a link — and the members of the project it is
|
|
|
|
|
// filed in.
|
|
|
|
|
//
|
|
|
|
|
// Ét spørgsmål frem for tre, fordi svaret skal være en mængde og ikke tre lister,
|
|
|
|
|
// der skal lægges sammen bagefter: en person kan både eje projektet og have noten
|
|
|
|
|
// delt direkte, og to beskeder om den samme rettelse er én for mange.
|
|
|
|
|
//
|
|
|
|
|
// The note's own row is joined rather than passed in, so a caller cannot ask about
|
|
|
|
|
// a note that has been deleted and get an audience for it anyway.
|
|
|
|
|
func (db *DB) NoteAudience(ctx context.Context, noteID string) ([]string, error) {
|
|
|
|
|
rows, err := db.QueryContext(ctx, `
|
|
|
|
|
SELECT created_by FROM notes WHERE id = ? AND deleted_at IS NULL AND created_by IS NOT NULL
|
|
|
|
|
UNION
|
|
|
|
|
SELECT user_id FROM note_shares WHERE note_id = ?
|
|
|
|
|
UNION
|
|
|
|
|
SELECT p.owner_id FROM notes n JOIN projects p ON p.id = n.project_id
|
|
|
|
|
WHERE n.id = ? AND n.deleted_at IS NULL AND p.deleted_at IS NULL
|
|
|
|
|
UNION
|
|
|
|
|
SELECT m.user_id FROM notes n JOIN project_members m ON m.project_id = n.project_id
|
|
|
|
|
WHERE n.id = ? AND n.deleted_at IS NULL`,
|
|
|
|
|
noteID, noteID, noteID, noteID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
|
|
var out []string
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
var id string
|
|
|
|
|
if err := rows.Scan(&id); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
out = append(out, id)
|
|
|
|
|
}
|
|
|
|
|
return out, rows.Err()
|
|
|
|
|
}
|