feat(beskeder): klokken siger til, når nogen rører noget, der er dit
En delt note blev rettet af en anden, og man opdagede det kun ved at åbne den. En opgave blev givet til en, og den lå bare der. Det, andre gør ved det, man deler, skal man kunne se uden at lede efter det. Klokken i hovedet: ulæste beskeder om noter, der er blevet rettet, delt med en, eller opgaver, man har fået. Sætningen skrives i browseren, ikke på serveren, så et engelsk interface får en engelsk klokke. Beskeder om den samme note bliver slået sammen, mens de er ulæste. En note gemmes på en tastepause, så en række pr. gem ville lave én person, der skriver i fem minutter, om til tyve ens linjer. Er beskeden læst, er den næste rettelse en ny ting at få at vide — ikke en gentagelse. note_id får sin egen søjle ved siden af project_id og task_id frem for et generisk (kind, id)-par: de tre er ikke det samme slags nul, og fremmednøglen — dén, der rydder beskeden op, når noten slettes — kan et generisk par ikke have. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
48509e3073
commit
ab77024ec9
@@ -0,0 +1,18 @@
|
||||
-- En besked kan handle om en note.
|
||||
--
|
||||
-- Tabellen kunne pege på et projekt og en opgave, fordi det var de to ting, der
|
||||
-- kunne ske noget med. Nu kan der også: en note, der er delt, bliver rettet af en
|
||||
-- anden, og den, den er delt med, skal kunne se at det er sket — uden at åbne den
|
||||
-- for at opdage det.
|
||||
--
|
||||
-- En søjle ved siden af de to andre frem for et generisk (kind, id)-par. De tre er
|
||||
-- ikke det samme slags nul: en besked om en opgave har *også* et projekt, og en om
|
||||
-- en note har måske ingen af delene. Et par ville skulle tolkes hvert sted, det
|
||||
-- læses, og fremmednøglen — som er dén, der rydder beskeden op, når noten slettes
|
||||
-- — kan et generisk par slet ikke have.
|
||||
ALTER TABLE notifications ADD COLUMN note_id TEXT REFERENCES notes (id) ON DELETE CASCADE;
|
||||
|
||||
-- Beskeder om den samme note bliver slået sammen, mens de er ulæste, og opslaget
|
||||
-- er nøjagtig det: min, ulæst, om denne note.
|
||||
CREATE INDEX notifications_unread_by_note
|
||||
ON notifications (user_id, note_id) WHERE read_at IS NULL AND note_id IS NOT NULL;
|
||||
@@ -372,3 +372,41 @@ func (db *DB) SyncLinkedShares(ctx context.Context, ownerID string) ([]LinkedSha
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ type Notification struct {
|
||||
ActorName string
|
||||
ProjectID string
|
||||
TaskID string
|
||||
NoteID string
|
||||
Kind string
|
||||
Title string
|
||||
Body string
|
||||
@@ -32,21 +33,45 @@ func (db *DB) CreateNotification(ctx context.Context, n *Notification) error {
|
||||
n.CreatedAt = time.Now().UTC()
|
||||
|
||||
_, err := db.ExecContext(ctx,
|
||||
`INSERT INTO notifications (id, user_id, actor_id, project_id, task_id, kind,
|
||||
`INSERT INTO notifications (id, user_id, actor_id, project_id, task_id, note_id, kind,
|
||||
title, body, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
n.ID, n.UserID, nullString(n.ActorID), nullString(n.ProjectID),
|
||||
nullString(n.TaskID), n.Kind, n.Title, n.Body, n.CreatedAt.Unix())
|
||||
nullString(n.TaskID), nullString(n.NoteID), n.Kind, n.Title, n.Body, n.CreatedAt.Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
// BumpUnreadNoteNotification folds a second change to the same note into the first,
|
||||
// and says whether it found one to fold into.
|
||||
//
|
||||
// A note is saved on a typing pause, so writing a row per save would turn one
|
||||
// person editing one note for five minutes into a column of twenty identical
|
||||
// lines. Slået sammen, mens beskeden er ulæst: den rykker op som ny og siger hvem
|
||||
// der rørte den sidst.
|
||||
//
|
||||
// Kun mens den er ulæst — og det er hele reglen. Har man læst "Andreas rettede
|
||||
// Aftalen" og går videre, er den næste rettelse en ny ting at få at vide, ikke en
|
||||
// gentagelse af noget, man allerede har set.
|
||||
func (db *DB) BumpUnreadNoteNotification(ctx context.Context, userID, noteID, actorID, title, body string) (bool, error) {
|
||||
res, err := db.ExecContext(ctx, `
|
||||
UPDATE notifications
|
||||
SET created_at = ?, actor_id = ?, title = ?, body = ?
|
||||
WHERE user_id = ? AND note_id = ? AND kind = 'note.changed' AND read_at IS NULL`,
|
||||
time.Now().UTC().Unix(), nullString(actorID), title, body, userID, noteID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
func (db *DB) ListNotifications(ctx context.Context, userID string, limit int) ([]Notification, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 30
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT n.id, n.user_id, n.actor_id, COALESCE(u.name, ''), n.project_id, n.task_id,
|
||||
n.kind, n.title, n.body, n.read_at, n.created_at
|
||||
n.note_id, n.kind, n.title, n.body, n.read_at, n.created_at
|
||||
FROM notifications n
|
||||
LEFT JOIN users u ON u.id = n.actor_id
|
||||
WHERE n.user_id = ?
|
||||
@@ -60,14 +85,15 @@ func (db *DB) ListNotifications(ctx context.Context, userID string, limit int) (
|
||||
out := []Notification{}
|
||||
for rows.Next() {
|
||||
var n Notification
|
||||
var actorID, projectID, taskID sql.NullString
|
||||
var actorID, projectID, taskID, noteID sql.NullString
|
||||
var readAt sql.NullInt64
|
||||
var created int64
|
||||
if err := rows.Scan(&n.ID, &n.UserID, &actorID, &n.ActorName, &projectID,
|
||||
&taskID, &n.Kind, &n.Title, &n.Body, &readAt, &created); err != nil {
|
||||
&taskID, ¬eID, &n.Kind, &n.Title, &n.Body, &readAt, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n.ActorID, n.ProjectID, n.TaskID = actorID.String, projectID.String, taskID.String
|
||||
n.ActorID, n.ProjectID = actorID.String, projectID.String
|
||||
n.TaskID, n.NoteID = taskID.String, noteID.String
|
||||
if readAt.Valid {
|
||||
v := time.Unix(readAt.Int64, 0).UTC()
|
||||
n.ReadAt = &v
|
||||
|
||||
Reference in New Issue
Block a user