feat(phase2,phase4): comments, attachments, notifications, Web Push, import/export

Comments and attachments close out phase 2. Files are stored
content-addressed by hash, so the path never contains anything the
uploader chose — a filename is attacker-controlled text, and building a
path from it is how "../" becomes a write outside the data directory.
Everything downloads as application/octet-stream: an uploaded SVG served
inline would run its own script on this origin with the session cookie
attached.

Web Push is written directly against RFC 8030/8291/8292 rather than
pulled in — it is three specs' worth of well-defined crypto and about
two hundred lines, and the alternatives carry a JWT and crypto stack
each larger than the file.

Phase 4 imports Todoist's CSV and exports it back. Todoist numbers
priorities the opposite way to how it displays them — its 4 is P1 — so
the two are converted rather than copied; getting it backwards silently
inverts the urgency of every imported task. Nesting is positional, so
the reader keeps an indent stack and the writer never sorts.

The round trip is tested as the brief asks: a CSV in, out, and back
produces an identical project, and two exports of one project are
byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 07:42:55 +02:00
co-authored by Claude Opus 5
parent 0ee72d399c
commit 4058ff0533
13 changed files with 3215 additions and 0 deletions
+414
View File
@@ -0,0 +1,414 @@
package httpapi
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"unicode/utf8"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/store"
)
type commentJSON struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
UserID string `json:"user_id"`
UserName string `json:"user_name"`
UserColor string `json:"user_color"`
Body string `json:"body"`
Attachments []attachmentJSON `json:"attachments"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type attachmentJSON struct {
ID string `json:"id"`
Filename string `json:"filename"`
MimeType string `json:"mime_type"`
Size int64 `json:"size"`
URL string `json:"url"`
}
func toAttachmentJSON(a store.Attachment) attachmentJSON {
return attachmentJSON{
ID: a.ID, Filename: a.Filename, MimeType: a.MimeType, Size: a.Size,
URL: "/api/v1/attachments/" + a.ID,
}
}
func toCommentJSON(c store.Comment) commentJSON {
j := commentJSON{
ID: c.ID, TaskID: c.TaskID, UserID: c.UserID, UserName: c.UserName,
UserColor: c.UserColor, Body: c.Body,
Attachments: make([]attachmentJSON, 0, len(c.Attachments)),
CreatedAt: c.CreatedAt.Format(time.RFC3339),
UpdatedAt: c.UpdatedAt.Format(time.RFC3339),
}
for _, a := range c.Attachments {
j.Attachments = append(j.Attachments, toAttachmentJSON(a))
}
return j
}
func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "taskID")
if _, err := store.TaskRole(r.Context(), s.db, taskID, userFrom(r.Context()).ID); err != nil {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
comments, err := s.db.ListComments(r.Context(), taskID)
if err != nil {
s.internal(w, "list comments", err)
return
}
out := make([]commentJSON, 0, len(comments))
for _, c := range comments {
out = append(out, toCommentJSON(c))
}
files, err := s.db.ListTaskAttachments(r.Context(), taskID)
if err != nil {
s.internal(w, "list attachments", err)
return
}
direct := make([]attachmentJSON, 0, len(files))
for _, a := range files {
direct = append(direct, toAttachmentJSON(a))
}
writeJSON(w, http.StatusOK, map[string]any{"comments": out, "attachments": direct})
}
type commentRequest struct {
Body string `json:"body"`
}
func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) {
var req commentRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
taskID := chi.URLParam(r, "taskID")
user := userFrom(r.Context())
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
if err != nil || !role.CanEdit() {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
body := strings.TrimSpace(req.Body)
if body == "" {
writeFieldErrors(w, map[string]string{"body": "required"})
return
}
if utf8.RuneCountInString(body) > 10000 {
writeFieldErrors(w, map[string]string{"body": "must be 10000 characters or fewer"})
return
}
comment, err := s.db.CreateComment(r.Context(), taskID, user.ID, body)
if err != nil {
s.internal(w, "create comment", err)
return
}
comment.UserName, comment.UserColor = user.Name, user.AvatarColor
task, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err == nil {
s.activity(r, task.ProjectID, taskID, "comment.created", map[string]any{"body": body})
s.publish(task.ProjectID, "comment.created", toCommentJSON(*comment))
// Everybody else who can see the project hears about it; the author does
// not need telling what they just wrote.
s.notifyProject(r, task.ProjectID, taskID, user.ID, "comment",
user.Name+" kommenterede", task.Content)
}
writeJSON(w, http.StatusCreated, toCommentJSON(*comment))
}
func (s *Server) handleUpdateComment(w http.ResponseWriter, r *http.Request) {
var req commentRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
body := strings.TrimSpace(req.Body)
if body == "" {
writeFieldErrors(w, map[string]string{"body": "required"})
return
}
err := s.db.UpdateComment(r.Context(), chi.URLParam(r, "commentID"), userFrom(r.Context()).ID, body)
if err != nil {
s.storeError(w, "update comment", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleDeleteComment(w http.ResponseWriter, r *http.Request) {
commentID := chi.URLParam(r, "commentID")
user := userFrom(r.Context())
taskID, err := s.db.CommentTask(r.Context(), commentID)
if err != nil {
s.storeError(w, "comment task", err)
return
}
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
if err != nil {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
if err := s.db.DeleteComment(r.Context(), commentID, user.ID, role.CanManage()); err != nil {
s.storeError(w, "delete comment", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- attachments -------------------------------------------------------------------
// maxUploadBytes caps a single file at 25 MiB.
//
// This is a to-do app on somebody's homelab, not a file host: the attachments that
// matter are a photo of a receipt or a PDF of a contract. A cap keeps one paste of
// a video from filling the volume the database lives on.
const maxUploadBytes = 25 << 20
func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "taskID")
user := userFrom(r.Context())
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
if err != nil || !role.CanEdit() {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+(1<<20))
if err := r.ParseMultipartForm(8 << 20); err != nil {
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
"that file is too large (25 MB maximum)")
return
}
defer r.MultipartForm.RemoveAll()
file, header, err := r.FormFile("file")
if err != nil {
writeFieldErrors(w, map[string]string{"file": "required"})
return
}
defer file.Close()
if header.Size > maxUploadBytes {
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
"that file is too large (25 MB maximum)")
return
}
stored, size, err := s.storeUpload(file)
if err != nil {
s.internal(w, "store upload", err)
return
}
a := &store.Attachment{
TaskID: taskID,
CommentID: r.FormValue("comment_id"),
Filename: safeFilename(header.Filename),
MimeType: detectMime(header.Filename, header.Header.Get("Content-Type")),
Size: size,
Path: stored,
UploadedBy: user.ID,
}
// An attachment hangs on a task or on a comment, never both — the schema says
// so, and the form may have supplied a comment id.
if a.CommentID != "" {
a.TaskID = ""
}
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
s.internal(w, "record attachment", err)
return
}
if task, err := s.db.GetTask(r.Context(), taskID, user.ID); err == nil {
s.activity(r, task.ProjectID, taskID, "attachment.added",
map[string]any{"filename": a.Filename})
}
writeJSON(w, http.StatusCreated, toAttachmentJSON(*a))
}
// storeUpload writes the bytes under a content-addressed path.
//
// Naming by hash means the same file attached to ten tasks is stored once, and —
// more importantly — the path never contains anything the uploader chose. A
// filename is attacker-controlled text; using it to build a path is how "../"
// becomes a write outside the data directory.
func (s *Server) storeUpload(file io.Reader) (string, int64, error) {
// Created here rather than assumed: startup makes the directory, but a volume
// remounted underneath a running container would otherwise turn every upload
// into a 500 with nothing in the log explaining why.
if err := os.MkdirAll(s.cfg.FilesDir(), 0o750); err != nil {
return "", 0, err
}
tmp, err := os.CreateTemp(s.cfg.FilesDir(), "upload-*")
if err != nil {
return "", 0, err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath) // no-op once it has been renamed away
hash := sha256.New()
size, err := io.Copy(io.MultiWriter(tmp, hash), file)
if err != nil {
tmp.Close()
return "", 0, err
}
if err := tmp.Close(); err != nil {
return "", 0, err
}
sum := hex.EncodeToString(hash.Sum(nil))
// Two levels of fan-out: tens of thousands of files in one directory is slow
// to list and slow to open on most filesystems.
rel := filepath.Join(sum[:2], sum[2:4], sum)
full := filepath.Join(s.cfg.FilesDir(), rel)
if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil {
return "", 0, err
}
if _, err := os.Stat(full); err == nil {
// Already stored by an earlier upload of the identical file.
return rel, size, nil
}
if err := os.Rename(tmpPath, full); err != nil {
return "", 0, err
}
return rel, size, nil
}
func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request) {
attachmentID := chi.URLParam(r, "attachmentID")
user := userFrom(r.Context())
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
if err != nil {
s.storeError(w, "attachment task", err)
return
}
if _, err := store.TaskRole(r.Context(), s.db, taskID, user.ID); err != nil {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
a, err := s.db.GetAttachment(r.Context(), attachmentID)
if err != nil {
s.storeError(w, "get attachment", err)
return
}
// The stored path came from a hash this server computed, but it is still
// checked against the files directory before being opened — a path traversal
// that got into the database must not become one that reads /etc/passwd.
full := filepath.Join(s.cfg.FilesDir(), filepath.Clean("/"+a.Path))
if !strings.HasPrefix(full, s.cfg.FilesDir()+string(os.PathSeparator)) {
s.log.Error("attachment path escapes the files directory", "id", a.ID, "path", a.Path)
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
f, err := os.Open(full)
if err != nil {
s.log.Error("open attachment", "err", err, "id", a.ID)
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
defer f.Close()
// Everything is served as a download rather than rendered. An uploaded SVG or
// HTML file displayed inline would run its own script on this origin, with the
// session cookie attached — attachments are the one place a user supplies
// content that another user opens.
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+
strings.ReplaceAll(mime.QEncoding.Encode("utf-8", a.Filename), " ", "%20"))
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Length", fmt.Sprint(a.Size))
w.Header().Set("Cache-Control", "private, max-age=3600")
http.ServeContent(w, r, a.Filename, a.CreatedAt, f)
}
func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) {
attachmentID := chi.URLParam(r, "attachmentID")
user := userFrom(r.Context())
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
if err != nil {
s.storeError(w, "attachment task", err)
return
}
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
if err != nil || !role.CanEdit() {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
path, err := s.db.DeleteAttachment(r.Context(), attachmentID)
if err != nil {
s.storeError(w, "delete attachment", err)
return
}
// The file is left on disk. It is content-addressed, so another attachment may
// point at the same bytes; removing it here would break that one. A sweeper
// for genuinely unreferenced files is a separate, safer job.
s.log.Debug("attachment row removed; file retained", "path", path)
w.WriteHeader(http.StatusNoContent)
}
// safeFilename keeps the name for display only — never for a path — but still
// strips the parts that would confuse a browser saving it.
func safeFilename(name string) string {
name = filepath.Base(strings.ReplaceAll(name, `\`, "/"))
name = strings.Map(func(r rune) rune {
if r < 32 || r == 127 || r == '/' || r == '"' {
return -1
}
return r
}, name)
name = strings.TrimSpace(name)
if name == "" || name == "." || name == ".." {
return "fil"
}
if utf8.RuneCountInString(name) > 200 {
runes := []rune(name)
name = string(runes[:200])
}
return name
}
// detectMime records what was declared, for display. It is never used to decide how
// to serve the file — see the download handler.
func detectMime(filename, declared string) string {
if declared != "" && declared != "application/octet-stream" {
return declared
}
if byExt := mime.TypeByExtension(filepath.Ext(filename)); byExt != "" {
return byExt
}
return "application/octet-stream"
}
+11
View File
@@ -11,3 +11,14 @@ import (
func contextWithTimeout(r *http.Request, d time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(r.Context(), d)
}
// contextLike is the read surface store.InstanceKeys needs, so the same call works
// from a request and from a detached background goroutine.
type contextLike = context.Context
// contextWithBackgroundTimeout bounds work that has outlived its request. A push
// round trip is started from a handler but must not be cancelled when that handler
// returns, or the notification is dropped the moment the response is written.
func contextWithBackgroundTimeout(d time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), d)
}
+476
View File
@@ -0,0 +1,476 @@
package httpapi
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/ics"
"github.com/kristianwind/verdande/internal/quickadd"
"github.com/kristianwind/verdande/internal/store"
"github.com/kristianwind/verdande/internal/todoist"
)
// maxImportBytes caps an uploaded CSV at 10 MiB, which is far more than any real
// Todoist export — the largest personal accounts run to a few hundred kilobytes.
const maxImportBytes = 10 << 20
type importResult struct {
ProjectID string `json:"project_id"`
Projects int `json:"projects"`
Tasks int `json:"tasks"`
Sections int `json:"sections"`
Comments int `json:"comments"`
// Warnings are the things that could not be brought across exactly — an
// assignee who is not a member here, a date that was natural language in a
// language we do not parse. Reported rather than silently dropped, because a
// silent partial import is discovered weeks later.
Warnings []string `json:"warnings"`
}
func (s *Server) handleImportTodoist(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
r.Body = http.MaxBytesReader(w, r.Body, maxImportBytes)
if err := r.ParseMultipartForm(4 << 20); err != nil {
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
"the file is too large")
return
}
defer r.MultipartForm.RemoveAll()
file, header, err := r.FormFile("file")
if err != nil {
writeFieldErrors(w, map[string]string{"file": "required"})
return
}
defer file.Close()
rows, err := todoist.Parse(file)
if err != nil {
writeFieldErrors(w, map[string]string{"file": err.Error()})
return
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
// Todoist names the file after the project, which is the best guess
// available when the form did not say.
name = strings.TrimSuffix(safeFilename(header.Filename), ".csv")
}
if name == "" {
name = "Importeret"
}
project := todoist.FromRows(rows, name)
result, err := s.importProject(r, user, project)
if err != nil {
s.internal(w, "import project", err)
return
}
writeJSON(w, http.StatusCreated, result)
}
// importProject writes a parsed project into the database.
//
// Tasks are created one at a time rather than in a single transaction: an import of
// two thousand tasks that fails on the last one would otherwise leave nothing, and
// a partial import that says what it managed is far more useful than an empty
// project and an error.
func (s *Server) importProject(r *http.Request, user *store.User, p todoist.Project) (*importResult, error) {
ctx := r.Context()
result := &importResult{Warnings: []string{}}
project := &store.Project{Name: p.Name, OwnerID: user.ID}
if err := s.db.CreateProject(ctx, project); err != nil {
return nil, err
}
result.ProjectID = project.ID
result.Projects = 1
loc := userLocation(user.Timezone)
var addTask func(t todoist.Task, sectionID, parentID string) error
addTask = func(t todoist.Task, sectionID, parentID string) error {
task := &store.Task{
ProjectID: project.ID, SectionID: sectionID, ParentID: parentID,
Content: t.Content, Description: t.Description, Priority: t.Priority,
CreatedBy: user.ID,
}
if t.Date != "" {
if date, remaining, ok := todoist.ParseDate(t.Date); ok {
task.DueDate = date
} else if remaining != "" {
// Todoist stores what somebody typed, so most dates are natural
// language. The quick-add parser already understands a great deal
// of it, in both languages — and a recurrence phrase like
// "every Monday" comes across as a real repeating task.
parsed := quickadd.Parse(remaining, time.Now().In(loc), user.Locale)
task.DueDate = parsed.DueDate
task.RecurrenceRule = parsed.Recurrence
if task.DueDate == "" && task.RecurrenceRule == "" {
result.Warnings = append(result.Warnings,
fmt.Sprintf("%q: datoen %q kunne ikke læses", t.Content, t.Date))
}
}
}
if err := s.db.CreateTask(ctx, task, nil); err != nil {
return err
}
result.Tasks++
if t.Assignee != "" {
result.Warnings = append(result.Warnings,
fmt.Sprintf("%q var tildelt %q, som ikke er medlem her", t.Content, t.Assignee))
}
for _, note := range t.Comments {
if _, err := s.db.CreateComment(ctx, task.ID, user.ID, note); err != nil {
return err
}
result.Comments++
}
for _, child := range t.Children {
if err := addTask(child, sectionID, task.ID); err != nil {
return err
}
}
return nil
}
for _, t := range p.Tasks {
if err := addTask(t, "", ""); err != nil {
return result, err
}
}
for _, section := range p.Sections {
s3 := &store.Section{ProjectID: project.ID, Name: section.Name}
if err := s.db.CreateSection(ctx, s3); err != nil {
return result, err
}
result.Sections++
for _, t := range section.Tasks {
if err := addTask(t, s3.ID, ""); err != nil {
return result, err
}
}
}
s.activity(r, project.ID, "", "project.imported", map[string]any{
"name": project.Name, "tasks": result.Tasks,
})
return result, nil
}
// --- generic CSV -------------------------------------------------------------------
type genericImportRequest struct {
Name string `json:"name"`
// Mapping says which column holds what: {"content": "Opgave", "due": "Frist"}.
// Supplied by the mapping UI after it has shown the file's own headers.
Mapping map[string]string `json:"mapping"`
Rows []map[string]string `json:"rows"`
}
// handleImportCSV takes a file that is not a Todoist export at all, with the caller
// saying which column means what.
//
// The parsing happens in the browser and the mapped rows arrive as JSON. That keeps
// the column-mapping interface — which needs to show a preview and let somebody
// change their mind — from requiring the file to be uploaded twice.
func (s *Server) handleImportCSV(w http.ResponseWriter, r *http.Request) {
var req genericImportRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
user := userFrom(r.Context())
contentColumn := req.Mapping["content"]
if contentColumn == "" {
writeFieldErrors(w, map[string]string{"mapping": "der skal peges på en kolonne med opgaveteksten"})
return
}
if len(req.Rows) == 0 {
writeFieldErrors(w, map[string]string{"rows": "der er ingen rækker"})
return
}
if len(req.Rows) > 5000 {
writeFieldErrors(w, map[string]string{"rows": "højst 5000 rækker ad gangen"})
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
name = "Importeret"
}
project := todoist.Project{Name: name}
for _, row := range req.Rows {
content := strings.TrimSpace(row[contentColumn])
if content == "" {
continue
}
task := todoist.Task{
Content: content,
Description: row[req.Mapping["description"]],
Date: row[req.Mapping["due"]],
Priority: 4,
}
if col := req.Mapping["priority"]; col != "" {
// Whatever the source called it, 1 through 4 is read as verdande's own
// numbering — an arbitrary foreign scale cannot be guessed at.
switch strings.TrimSpace(row[col]) {
case "1":
task.Priority = 1
case "2":
task.Priority = 2
case "3":
task.Priority = 3
}
}
project.Tasks = append(project.Tasks, task)
}
result, err := s.importProject(r, user, project)
if err != nil {
s.internal(w, "import csv", err)
return
}
writeJSON(w, http.StatusCreated, result)
}
// --- export --------------------------------------------------------------------------
// handleExportProject writes one project as a Todoist-compatible CSV.
func (s *Server) handleExportProject(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
projectID := chi.URLParam(r, "projectID")
project, err := s.db.GetProject(r.Context(), projectID, user.ID)
if err != nil {
s.storeError(w, "get project", err)
return
}
exported, err := s.buildExport(r, user, project)
if err != nil {
s.internal(w, "export project", err)
return
}
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition",
`attachment; filename="`+strings.TrimSuffix(ics.Filename(project.Name), ".ics")+`.csv"`)
if err := todoist.Write(w, todoist.ToRows(exported)); err != nil {
s.log.Error("write export", "err", err)
}
}
// buildExport turns a stored project back into the neutral shape the CSV writer
// takes, rebuilding the task tree from parent ids.
func (s *Server) buildExport(r *http.Request, user *store.User, project *store.Project) (todoist.Project, error) {
ctx := r.Context()
tasks, err := s.db.ListTasks(ctx, user.ID, store.TaskFilter{
ProjectIDs: []string{project.ID}, IncludeCompleted: true, Limit: 5000,
})
if err != nil {
return todoist.Project{}, err
}
sections, err := s.db.ListSections(ctx, project.ID)
if err != nil {
return todoist.Project{}, err
}
// Children indexed by parent, so the tree can be built in one pass.
children := map[string][]store.Task{}
for _, t := range tasks {
if t.ParentID != "" {
children[t.ParentID] = append(children[t.ParentID], t)
}
}
var build func(t store.Task) todoist.Task
build = func(t store.Task) todoist.Task {
out := todoist.Task{
Content: t.Content, Description: t.Description, Priority: t.Priority,
Date: t.DueDate, Author: user.Name,
}
if comments, err := s.db.ListComments(ctx, t.ID); err == nil {
for _, c := range comments {
out.Comments = append(out.Comments, c.Body)
}
}
for _, child := range children[t.ID] {
out.Children = append(out.Children, build(child))
}
return out
}
exported := todoist.Project{Name: project.Name}
bySection := map[string][]todoist.Task{}
for _, t := range tasks {
if t.ParentID != "" {
continue // reached through its parent
}
built := build(t)
if t.SectionID == "" {
exported.Tasks = append(exported.Tasks, built)
} else {
bySection[t.SectionID] = append(bySection[t.SectionID], built)
}
}
for _, section := range sections {
exported.Sections = append(exported.Sections, todoist.Section{
Name: section.Name, Tasks: bySection[section.ID],
})
}
return exported, nil
}
// handleExportAccount writes everything: every project, task, comment and label, as
// JSON.
//
// This is the "you can leave" guarantee. It is deliberately the raw shape rather
// than a tidied one — a backup that has been prettified is a backup that has lost
// something, and this is what an importer on the other side would want.
func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
ctx := r.Context()
projects, err := s.db.ListProjects(ctx, user.ID, true)
if err != nil {
s.internal(w, "export projects", err)
return
}
type exportedTask struct {
taskJSON
Comments []commentJSON `json:"comments,omitempty"`
}
type exportedProject struct {
projectJSON
Sections []sectionJSON `json:"sections"`
Tasks []exportedTask `json:"tasks"`
Activity []any `json:"activity,omitempty"`
}
out := struct {
Version int `json:"version"`
ExportedAt string `json:"exported_at"`
User userJSON `json:"user"`
Projects []exportedProject `json:"projects"`
Labels []labelJSON `json:"labels"`
Filters []filterJSON `json:"filters"`
}{
Version: 1,
ExportedAt: time.Now().UTC().Format(time.RFC3339),
User: toUserJSON(user),
Projects: []exportedProject{},
Labels: []labelJSON{},
Filters: []filterJSON{},
}
for _, p := range projects {
ep := exportedProject{projectJSON: toProjectJSON(p), Sections: []sectionJSON{}, Tasks: []exportedTask{}}
if sections, err := s.db.ListSections(ctx, p.ID); err == nil {
for _, sec := range sections {
ep.Sections = append(ep.Sections, sectionJSON{sec.ID, sec.ProjectID, sec.Name, sec.SortOrder})
}
}
tasks, err := s.db.ListTasks(ctx, user.ID, store.TaskFilter{
ProjectIDs: []string{p.ID}, IncludeCompleted: true, Limit: 5000,
})
if err != nil {
s.internal(w, "export tasks", err)
return
}
for _, t := range tasks {
et := exportedTask{taskJSON: toTaskJSON(t)}
if comments, err := s.db.ListComments(ctx, t.ID); err == nil {
for _, c := range comments {
et.Comments = append(et.Comments, toCommentJSON(c))
}
}
ep.Tasks = append(ep.Tasks, et)
}
if entries, err := s.db.ListActivity(ctx, p.ID, 200); err == nil {
for _, a := range entries {
ep.Activity = append(ep.Activity, map[string]any{
"event": a.Event, "user": a.UserName, "payload": a.Payload,
"created_at": a.CreatedAt.Format(time.RFC3339),
})
}
}
out.Projects = append(out.Projects, ep)
}
if labels, err := s.db.ListLabels(ctx, user.ID); err == nil {
for _, l := range labels {
out.Labels = append(out.Labels, labelJSON{l.ID, l.Name, l.Color, l.SortOrder, l.TaskCount})
}
}
if filters, err := s.db.ListFilters(ctx, user.ID); err == nil {
for _, f := range filters {
out.Filters = append(out.Filters, toFilterJSON(f))
}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Content-Disposition",
`attachment; filename="verdande-`+time.Now().Format("2006-01-02")+`.json"`)
encoder := json.NewEncoder(w)
encoder.SetIndent("", " ")
if err := encoder.Encode(out); err != nil {
s.log.Error("write account export", "err", err)
}
}
// handleExportProjectICS writes one project as a calendar file, for a one-off
// import into something else. The subscribable feed is a different thing: this is a
// snapshot somebody downloads.
func (s *Server) handleExportProjectICS(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
projectID := chi.URLParam(r, "projectID")
project, err := s.db.GetProject(r.Context(), projectID, user.ID)
if err != nil {
s.storeError(w, "get project", err)
return
}
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
ProjectIDs: []string{projectID}, IncludeCompleted: true, Limit: 5000,
})
if err != nil {
s.internal(w, "export ics", err)
return
}
cal := ics.Calendar{Name: project.Name, Domain: feedDomain(s.cfg.BaseURL)}
for _, t := range tasks {
if t.DueDate == "" {
continue
}
cal.Tasks = append(cal.Tasks, ics.Task{
ID: t.ID, Content: t.Content, Description: t.Description,
ProjectName: project.Name, DueDate: t.DueDate, DueDatetime: t.DueDatetime,
DurationMin: t.DurationMin, Priority: t.Priority, Recurrence: t.RecurrenceRule,
Completed: t.Completed(), CompletedAt: t.CompletedAt,
CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt,
})
}
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="`+ics.Filename(project.Name)+`"`)
w.Write([]byte(ics.Render(cal)))
}
+191
View File
@@ -0,0 +1,191 @@
package httpapi
import (
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/push"
"github.com/kristianwind/verdande/internal/store"
)
type notificationJSON struct {
ID string `json:"id"`
ActorName string `json:"actor_name,omitempty"`
ProjectID string `json:"project_id,omitempty"`
TaskID string `json:"task_id,omitempty"`
Kind string `json:"kind"`
Title string `json:"title"`
Body string `json:"body,omitempty"`
Read bool `json:"read"`
CreatedAt string `json:"created_at"`
}
func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
list, err := s.db.ListNotifications(r.Context(), user.ID, 30)
if err != nil {
s.internal(w, "list notifications", err)
return
}
unread, err := s.db.UnreadNotificationCount(r.Context(), user.ID)
if err != nil {
s.internal(w, "count notifications", err)
return
}
out := make([]notificationJSON, 0, len(list))
for _, n := range list {
out = append(out, notificationJSON{
ID: n.ID, ActorName: n.ActorName, ProjectID: n.ProjectID, TaskID: n.TaskID,
Kind: n.Kind, Title: n.Title, Body: n.Body, Read: n.ReadAt != nil,
CreatedAt: n.CreatedAt.Format(time.RFC3339),
})
}
writeJSON(w, http.StatusOK, map[string]any{"notifications": out, "unread": unread})
}
func (s *Server) handleMarkNotificationsRead(w http.ResponseWriter, r *http.Request) {
err := s.db.MarkNotificationsRead(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "notificationID"))
if err != nil {
s.internal(w, "mark notifications read", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// notify delivers one notification through every channel the person has: stored for
// the bell, pushed live to any open tab, and sent to their devices as Web Push.
//
// Never fails the request that caused it. Somebody assigning a task must not see an
// error because a push service was slow.
func (s *Server) notify(r *http.Request, n *store.Notification) {
if n.UserID == "" || n.UserID == n.ActorID {
return // nobody needs telling what they just did themselves
}
if err := s.db.CreateNotification(r.Context(), n); err != nil {
s.log.Warn("create notification", "err", err)
return
}
s.hub.PublishToUser(n.UserID, "notification", notificationJSON{
ID: n.ID, ProjectID: n.ProjectID, TaskID: n.TaskID, Kind: n.Kind,
Title: n.Title, Body: n.Body, CreatedAt: n.CreatedAt.Format(time.RFC3339),
})
// Detached from the request: a push round trip to Google or Mozilla has no
// business holding up the response to the person who caused it.
go s.pushToUser(n.UserID, n.Title, n.Body, n.ProjectID)
}
// notifyProject tells everybody who can see a project, except whoever did it.
func (s *Server) notifyProject(r *http.Request, projectID, taskID, actorID, kind, title, body string) {
members, err := s.db.ProjectMemberIDs(r.Context(), projectID)
if err != nil {
s.log.Warn("project members for notification", "err", err)
return
}
for _, userID := range members {
if userID == actorID {
continue
}
s.notify(r, &store.Notification{
UserID: userID, ActorID: actorID, ProjectID: projectID, TaskID: taskID,
Kind: kind, Title: title, Body: body,
})
}
}
func (s *Server) pushToUser(userID, title, body, projectID string) {
ctx, cancel := contextWithBackgroundTimeout(30 * time.Second)
defer cancel()
subs, err := s.db.ListPushSubscriptions(ctx, userID)
if err != nil || len(subs) == 0 {
return
}
public, private, err := s.vapidKeys(ctx)
if err != nil {
s.log.Warn("vapid keys", "err", err)
return
}
payload := push.Payload{Title: title, Body: body, URL: "/projekt/" + projectID}
for _, sub := range subs {
err := push.Send(ctx, push.Subscription{
Endpoint: sub.Endpoint, P256dh: sub.P256dh, Auth: sub.Auth,
}, payload, push.VAPID{
Public: public, Private: private, Subject: "mailto:" + s.cfg.SMTP.From,
})
if err == nil {
continue
}
s.log.Debug("web push failed", "err", err, "endpoint", sub.Endpoint)
if recErr := s.db.RecordPushFailure(ctx, sub.Endpoint, push.IsGone(err)); recErr != nil {
s.log.Warn("record push failure", "err", recErr)
}
}
}
func (s *Server) vapidKeys(ctx contextLike) (string, string, error) {
return s.db.InstanceKeys(ctx, push.GenerateVAPIDKeys)
}
// --- subscription endpoints -----------------------------------------------------------
type pushSubscriptionRequest struct {
Endpoint string `json:"endpoint"`
Keys struct {
P256dh string `json:"p256dh"`
Auth string `json:"auth"`
} `json:"keys"`
}
// handlePushKey hands the browser the public half of the VAPID pair, which is what
// PushManager.subscribe needs before it will produce a subscription at all.
func (s *Server) handlePushKey(w http.ResponseWriter, r *http.Request) {
public, _, err := s.vapidKeys(r.Context())
if err != nil {
s.internal(w, "vapid keys", err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"public_key": public})
}
func (s *Server) handleSubscribePush(w http.ResponseWriter, r *http.Request) {
var req pushSubscriptionRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
if req.Endpoint == "" || req.Keys.P256dh == "" || req.Keys.Auth == "" {
writeFieldErrors(w, map[string]string{"endpoint": "an incomplete subscription"})
return
}
err := s.db.SavePushSubscription(r.Context(), &store.PushSubscription{
UserID: userFrom(r.Context()).ID,
Endpoint: req.Endpoint,
P256dh: req.Keys.P256dh,
Auth: req.Keys.Auth,
UserAgent: r.UserAgent(),
})
if err != nil {
s.internal(w, "save push subscription", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleUnsubscribePush(w http.ResponseWriter, r *http.Request) {
var req pushSubscriptionRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
if err := s.db.DeletePushSubscription(r.Context(), req.Endpoint); err != nil {
s.internal(w, "delete push subscription", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+34
View File
@@ -131,8 +131,39 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
})
r.Get("/feed", s.handleGetFeed)
r.Route("/notifications", func(r chi.Router) {
r.Get("/", s.handleListNotifications)
r.Post("/read", s.handleMarkNotificationsRead)
r.Post("/{notificationID}/read", s.handleMarkNotificationsRead)
})
r.Route("/push", func(r chi.Router) {
r.Get("/key", s.handlePushKey)
r.Post("/subscribe", s.handleSubscribePush)
r.Post("/unsubscribe", s.handleUnsubscribePush)
})
r.Route("/attachments/{attachmentID}", func(r chi.Router) {
r.Get("/", s.handleDownloadAttachment)
r.Delete("/", s.handleDeleteAttachment)
})
r.Route("/comments/{commentID}", func(r chi.Router) {
r.Patch("/", s.handleUpdateComment)
r.Delete("/", s.handleDeleteComment)
})
r.Post("/feed/rotate", s.handleRotateFeed)
r.Route("/import", func(r chi.Router) {
r.Post("/todoist", s.handleImportTodoist)
r.Post("/csv", s.handleImportCSV)
})
r.Get("/export/account", s.handleExportAccount)
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
r.Route("/templates", func(r chi.Router) {
r.Get("/", s.handleListTemplates)
r.Post("/", s.handleSaveTemplate)
@@ -200,6 +231,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Get("/", s.handleGetTask)
r.Patch("/", s.handleUpdateTask)
r.Delete("/", s.handleDeleteTask)
r.Get("/comments", s.handleListComments)
r.Post("/comments", s.handleCreateComment)
r.Post("/attachments", s.handleUploadAttachment)
r.Get("/reminders", s.handleListReminders)
r.Post("/reminders", s.handleCreateReminder)
r.Post("/complete", s.handleCompleteTask)
+374
View File
@@ -1,7 +1,10 @@
package httpapi
import (
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"strings"
"testing"
@@ -1308,3 +1311,374 @@ func TestTemplatesAreScopedToTheirOwner(t *testing.T) {
t.Errorf("another user's template list shows %d entries", len(got))
}
}
// --- comments, attachments and import/export -----------------------------------------
func TestCommentsAndAttachments(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "med kommentarer"})
taskID := task["id"].(string)
resp, comment := ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/comments",
map[string]any{"body": "første kommentar"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create comment: %d %v", resp.StatusCode, comment)
}
if comment["user_name"] != "Kristian" {
t.Errorf("user_name = %v", comment["user_name"])
}
// A file attached directly to the task.
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "bilag.pdf")
if err != nil {
t.Fatal(err)
}
part.Write([]byte("%PDF-1.4 ikke en rigtig pdf"))
writer.Close()
req, err := http.NewRequest("POST", ts.URL+"/api/v1/tasks/"+taskID+"/attachments", body)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Sec-Fetch-Site", "same-origin")
upload, err := ts.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer upload.Body.Close()
if upload.StatusCode != http.StatusCreated {
t.Fatalf("upload: status %d", upload.StatusCode)
}
var attachment map[string]any
raw, _ := io.ReadAll(upload.Body)
json.Unmarshal(raw, &attachment)
if attachment["filename"] != "bilag.pdf" {
t.Errorf("filename = %v", attachment["filename"])
}
_, list := ts.do(t, "GET", "/api/v1/tasks/"+taskID+"/comments", nil)
if got, _ := list["comments"].([]any); len(got) != 1 {
t.Errorf("got %d comments, want 1", len(got))
}
if got, _ := list["attachments"].([]any); len(got) != 1 {
t.Errorf("got %d attachments on the task, want 1", len(got))
}
// The file downloads, and never as something a browser would render.
dl, dlBody := ts.do(t, "GET", "/api/v1/attachments/"+attachment["id"].(string), nil)
_ = dlBody
if dl.StatusCode != http.StatusOK {
t.Fatalf("download: status %d", dl.StatusCode)
}
if ct := dl.Header.Get("Content-Type"); ct != "application/octet-stream" {
t.Errorf("Content-Type = %q; an uploaded file must never be served inline", ct)
}
if cd := dl.Header.Get("Content-Disposition"); !strings.HasPrefix(cd, "attachment") {
t.Errorf("Content-Disposition = %q, want an attachment", cd)
}
}
// A viewer can read a discussion but not join it, and cannot upload.
func TestViewersCannotComment(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
viewer := ts.newUser(t, "viewer@example.dk", "Viewer")
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
projectID := project["id"].(string)
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "opgave", "project_id": projectID,
})
taskID := task["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"email": "viewer@example.dk", "role": "viewer"})
ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/comments", map[string]any{"body": "ejerens ord"})
if r, body := viewer.do(t, "GET", "/api/v1/tasks/"+taskID+"/comments", nil); r.StatusCode != http.StatusOK {
t.Errorf("a viewer cannot read comments: %d %v", r.StatusCode, body)
}
if r, _ := viewer.do(t, "POST", "/api/v1/tasks/"+taskID+"/comments",
map[string]any{"body": "smuglet ind"}); r.StatusCode < 400 {
t.Errorf("a viewer could comment: status %d", r.StatusCode)
}
}
// Somebody else's comment is theirs. A project role does not confer the right to
// rewrite what another person said.
func TestOnlyTheAuthorCanEditAComment(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
editor := ts.newUser(t, "editor@example.dk", "Editor")
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
projectID := project["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"email": "editor@example.dk", "role": "editor"})
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "opgave", "project_id": projectID,
})
taskID := task["id"].(string)
_, comment := ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/comments",
map[string]any{"body": "ejerens ord"})
commentID := comment["id"].(string)
resp, _ := editor.do(t, "PATCH", "/api/v1/comments/"+commentID,
map[string]any{"body": "omskrevet"})
if resp.StatusCode < 400 {
t.Errorf("an editor rewrote somebody else's comment: status %d", resp.StatusCode)
}
_, list := ts.do(t, "GET", "/api/v1/tasks/"+taskID+"/comments", nil)
comments, _ := list["comments"].([]any)
if comments[0].(map[string]any)["body"] != "ejerens ord" {
t.Error("the comment was changed")
}
}
func TestImportTodoistCSV(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
csv := "TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE\n" +
"task,Betal moms,husk bilag,4,1,Kristian,,2026-03-15,en,\n" +
"note,En kommentar,,,,,,,,\n" +
"task,Find bilag,,1,2,Kristian,,,,\n" +
"section,Til gennemsyn,,,,,,,,\n" +
"task,Gennemgå,,3,1,Kristian,,,,\n"
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
writer.WriteField("name", "Fra Todoist")
part, err := writer.CreateFormFile("file", "firma.csv")
if err != nil {
t.Fatal(err)
}
part.Write([]byte(csv))
writer.Close()
req, err := http.NewRequest("POST", ts.URL+"/api/v1/import/todoist", body)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Sec-Fetch-Site", "same-origin")
resp, err := ts.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
raw, _ := io.ReadAll(resp.Body)
t.Fatalf("import: status %d, %s", resp.StatusCode, raw)
}
var result map[string]any
raw, _ := io.ReadAll(resp.Body)
json.Unmarshal(raw, &result)
if result["tasks"] != float64(3) {
t.Errorf("imported %v tasks, want 3", result["tasks"])
}
if result["sections"] != float64(1) {
t.Errorf("imported %v sections, want 1", result["sections"])
}
if result["comments"] != float64(1) {
t.Errorf("imported %v comments, want 1", result["comments"])
}
projectID := result["project_id"].(string)
_, list := ts.do(t, "GET", "/api/v1/tasks?project_id="+projectID, nil)
tasks, _ := list["tasks"].([]any)
var found bool
for _, raw := range tasks {
task := raw.(map[string]any)
if task["content"] != "Betal moms" {
continue
}
found = true
// Todoist's 4 is verdande's 1. Getting this backwards would invert the
// urgency of every imported task.
if task["priority"] != float64(1) {
t.Errorf("priority = %v, want 1", task["priority"])
}
if task["due_date"] != "2026-03-15" {
t.Errorf("due_date = %v", task["due_date"])
}
}
if !found {
t.Error("the imported task was not found")
}
}
// The whole point of import and export together: what goes in comes back out.
func TestTodoistImportExportRoundTrip(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
original := "TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE\n" +
"task,Betal moms,husk bilag,4,1,Kristian,,,,\n" +
"task,Find bilag,,2,2,Kristian,,,,\n" +
"task,Ring til revisor,,1,1,Kristian,,,,\n"
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
writer.WriteField("name", "Rundtur")
part, _ := writer.CreateFormFile("file", "firma.csv")
part.Write([]byte(original))
writer.Close()
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/import/todoist", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Sec-Fetch-Site", "same-origin")
resp, err := ts.client.Do(req)
if err != nil {
t.Fatal(err)
}
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var result map[string]any
json.Unmarshal(raw, &result)
projectID, _ := result["project_id"].(string)
if projectID == "" {
t.Fatalf("import failed: %s", raw)
}
// Export it again and compare the shape.
out, exported := ts.do(t, "GET", "/api/v1/export/projects/"+projectID+".csv", nil)
_ = exported
if out.StatusCode != http.StatusOK {
t.Fatalf("export: status %d", out.StatusCode)
}
// The response body was consumed by `do`; fetch it as text instead.
req2, _ := http.NewRequest("GET", ts.URL+"/api/v1/export/projects/"+projectID+".csv", nil)
req2.Header.Set("Sec-Fetch-Site", "same-origin")
csvResp, err := ts.client.Do(req2)
if err != nil {
t.Fatal(err)
}
defer csvResp.Body.Close()
csvBytes, _ := io.ReadAll(csvResp.Body)
roundTripped := string(csvBytes)
for _, want := range []string{"Betal moms", "Find bilag", "Ring til revisor", "husk bilag"} {
if !strings.Contains(roundTripped, want) {
t.Errorf("%q is missing from the export:\n%s", want, roundTripped)
}
}
// The priorities have to come back as Todoist's numbering, not verdande's.
if !strings.Contains(roundTripped, "task,Betal moms,husk bilag,4,1") {
t.Errorf("the exported row does not match Todoist's format:\n%s", roundTripped)
}
// And the sub-task comes back exactly as it went in: Todoist priority 2 became
// verdande's 3 on the way in and has to become 2 again on the way out.
if !strings.Contains(roundTripped, "task,Find bilag,,2,2") {
t.Errorf("the sub-task lost its indent or priority:\n%s", roundTripped)
}
}
func TestAccountExport(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "en opgave", "project_id": project["id"].(string), "labels": []string{"vigtig"},
})
ts.do(t, "POST", "/api/v1/tasks/"+task["id"].(string)+"/comments",
map[string]any{"body": "en kommentar"})
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/export/account", nil)
req.Header.Set("Sec-Fetch-Site", "same-origin")
resp, err := ts.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("export: status %d", resp.StatusCode)
}
raw, _ := io.ReadAll(resp.Body)
var export map[string]any
if err := json.Unmarshal(raw, &export); err != nil {
t.Fatalf("the export is not valid JSON: %v", err)
}
if export["version"] != float64(1) {
t.Errorf("version = %v", export["version"])
}
// A password hash must never be in an export somebody emails to themselves.
if strings.Contains(string(raw), "$argon2id$") {
t.Error("the account export contains a password hash")
}
projects, _ := export["projects"].([]any)
if len(projects) < 2 {
t.Fatalf("got %d projects, want the Inbox and Firma", len(projects))
}
var foundComment bool
for _, p := range projects {
tasks, _ := p.(map[string]any)["tasks"].([]any)
for _, raw := range tasks {
comments, _ := raw.(map[string]any)["comments"].([]any)
if len(comments) > 0 {
foundComment = true
}
}
}
if !foundComment {
t.Error("comments are missing from the account export")
}
if labels, _ := export["labels"].([]any); len(labels) == 0 {
t.Error("labels are missing from the account export")
}
}
func TestNotifications(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
projectID := project["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"email": "anden@example.dk", "role": "editor"})
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "opgave", "project_id": projectID,
})
ts.do(t, "POST", "/api/v1/tasks/"+task["id"].(string)+"/comments",
map[string]any{"body": "sig noget"})
// The other member hears about it.
_, list := other.do(t, "GET", "/api/v1/notifications", nil)
notifications, _ := list["notifications"].([]any)
if len(notifications) == 0 {
t.Fatal("the other member was not notified about the comment")
}
if list["unread"] == float64(0) {
t.Error("the notification is not counted as unread")
}
// The author is not told what they just wrote.
_, own := ts.do(t, "GET", "/api/v1/notifications", nil)
if got, _ := own["notifications"].([]any); len(got) != 0 {
t.Errorf("the author was notified about their own comment: %v", got)
}
other.do(t, "POST", "/api/v1/notifications/read", nil)
_, after := other.do(t, "GET", "/api/v1/notifications", nil)
if after["unread"] != float64(0) {
t.Errorf("unread = %v after marking everything read", after["unread"])
}
}