diff --git a/internal/httpapi/comments_handlers.go b/internal/httpapi/comments_handlers.go new file mode 100644 index 0000000..3fa8ad8 --- /dev/null +++ b/internal/httpapi/comments_handlers.go @@ -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" +} diff --git a/internal/httpapi/context.go b/internal/httpapi/context.go index 52c95f6..08e3047 100644 --- a/internal/httpapi/context.go +++ b/internal/httpapi/context.go @@ -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) +} diff --git a/internal/httpapi/import_handlers.go b/internal/httpapi/import_handlers.go new file mode 100644 index 0000000..75997b3 --- /dev/null +++ b/internal/httpapi/import_handlers.go @@ -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))) +} diff --git a/internal/httpapi/notifications_handlers.go b/internal/httpapi/notifications_handlers.go new file mode 100644 index 0000000..85f1546 --- /dev/null +++ b/internal/httpapi/notifications_handlers.go @@ -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) +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index bdb306e..bf28016 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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) diff --git a/internal/httpapi/tasks_test.go b/internal/httpapi/tasks_test.go index 41cb402..06b632e 100644 --- a/internal/httpapi/tasks_test.go +++ b/internal/httpapi/tasks_test.go @@ -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"]) + } +} diff --git a/internal/push/push.go b/internal/push/push.go new file mode 100644 index 0000000..81823df --- /dev/null +++ b/internal/push/push.go @@ -0,0 +1,249 @@ +// Package push sends Web Push messages (RFC 8030) with VAPID (RFC 8292) and +// aes128gcm payload encryption (RFC 8291). +// +// Written directly rather than pulled in as a dependency because the whole thing is +// three specs' worth of well-defined crypto and about two hundred lines, and +// because the alternative libraries carry a JWT package and a crypto stack that +// would each be larger than this file. +// +// The shape of it: derive a shared secret with the browser's public key, build a +// key and nonce from that plus both salts, encrypt the payload with AES-128-GCM, +// and authorise the request with a signed JWT naming this server. +package push + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "math/big" + "net/http" + "net/url" + "strings" + "time" +) + +type Subscription struct { + Endpoint string + P256dh string // the browser's public key, base64url + Auth string // the browser's auth secret, base64url +} + +type VAPID struct { + Public string + Private string + // Subject identifies the sender to the push service, as a mailto: or https: + // URL. Firefox rejects a request without one. + Subject string +} + +// Payload is what the service worker receives. +type Payload struct { + Title string `json:"title"` + Body string `json:"body,omitempty"` + URL string `json:"url,omitempty"` + Tag string `json:"tag,omitempty"` +} + +var b64 = base64.RawURLEncoding + +// ErrGone means the subscription is dead and should be deleted rather than retried. +var ErrGone = errors.New("push: the subscription no longer exists") + +func IsGone(err error) bool { return errors.Is(err, ErrGone) } + +// GenerateVAPIDKeys produces a P-256 keypair, base64url encoded. +func GenerateVAPIDKeys() (public, private string, err error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return "", "", err + } + pub := elliptic.Marshal(elliptic.P256(), key.PublicKey.X, key.PublicKey.Y) + return b64.EncodeToString(pub), b64.EncodeToString(key.D.Bytes()), nil +} + +// Send delivers one message to one subscription. +func Send(ctx context.Context, sub Subscription, payload Payload, vapid VAPID) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + + encrypted, err := encrypt(sub, body) + if err != nil { + return fmt.Errorf("push: encrypt: %w", err) + } + + token, err := signJWT(sub.Endpoint, vapid) + if err != nil { + return fmt.Errorf("push: sign: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, sub.Endpoint, bytes.NewReader(encrypted)) + if err != nil { + return err + } + req.Header.Set("Content-Encoding", "aes128gcm") + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("TTL", "86400") + // Low urgency would let a service sit on a reminder for hours. + req.Header.Set("Urgency", "normal") + req.Header.Set("Authorization", "vapid t="+token+", k="+vapid.Public) + + resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + switch { + case resp.StatusCode == http.StatusNotFound, resp.StatusCode == http.StatusGone: + return ErrGone + case resp.StatusCode >= 300: + return fmt.Errorf("push: %s said %s", resp.Request.URL.Host, resp.Status) + } + return nil +} + +// encrypt builds an RFC 8291 aes128gcm message. +func encrypt(sub Subscription, plaintext []byte) ([]byte, error) { + clientPubBytes, err := b64.DecodeString(sub.P256dh) + if err != nil { + return nil, fmt.Errorf("client key: %w", err) + } + authSecret, err := b64.DecodeString(sub.Auth) + if err != nil { + return nil, fmt.Errorf("auth secret: %w", err) + } + + curve := ecdh.P256() + clientPub, err := curve.NewPublicKey(clientPubBytes) + if err != nil { + return nil, fmt.Errorf("client key: %w", err) + } + // A fresh keypair per message: reusing one would let anybody who recovered a + // single message key read every later one. + serverPriv, err := curve.GenerateKey(rand.Reader) + if err != nil { + return nil, err + } + shared, err := serverPriv.ECDH(clientPub) + if err != nil { + return nil, err + } + serverPubBytes := serverPriv.PublicKey().Bytes() + + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return nil, err + } + + // The key derivation is prescribed by RFC 8291 §3.3, down to the exact info + // strings and their trailing NULs. Every byte here is load-bearing. + prkKey := hkdf(authSecret, shared, + append(append([]byte("WebPush: info\x00"), clientPubBytes...), serverPubBytes...), 32) + + cek := hkdf(salt, prkKey, []byte("Content-Encoding: aes128gcm\x00"), 16) + nonce := hkdf(salt, prkKey, []byte("Content-Encoding: nonce\x00"), 12) + + block, err := aes.NewCipher(cek) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + // A single record, so the padding delimiter is 0x02 ("last record") rather + // than 0x01. + record := append(append([]byte{}, plaintext...), 0x02) + ciphertext := gcm.Seal(nil, nonce, record, nil) + + // Header: salt(16) | record size(4) | key id length(1) | key id. + var out bytes.Buffer + out.Write(salt) + binary.Write(&out, binary.BigEndian, uint32(4096)) + out.WriteByte(byte(len(serverPubBytes))) + out.Write(serverPubBytes) + out.Write(ciphertext) + return out.Bytes(), nil +} + +// hkdf is HKDF-SHA256 for outputs of at most one hash block, which is all the sizes +// used here. Written out rather than imported because the full generalised form +// would be more code than this. +func hkdf(salt, ikm, info []byte, length int) []byte { + extract := hmac.New(sha256.New, salt) + extract.Write(ikm) + prk := extract.Sum(nil) + + expand := hmac.New(sha256.New, prk) + expand.Write(info) + expand.Write([]byte{0x01}) + return expand.Sum(nil)[:length] +} + +// signJWT builds the VAPID authorisation token: an ES256 JWT whose audience is the +// push service's origin. +func signJWT(endpoint string, vapid VAPID) (string, error) { + u, err := url.Parse(endpoint) + if err != nil { + return "", err + } + subject := vapid.Subject + if subject == "" || strings.HasSuffix(subject, "@localhost") { + // Firefox rejects a missing or obviously bogus subject. A generic mailto + // is better than being refused outright on an instance with no mail set up. + subject = "mailto:verdande@example.invalid" + } + + header := b64.EncodeToString([]byte(`{"typ":"JWT","alg":"ES256"}`)) + claims, err := json.Marshal(map[string]any{ + "aud": u.Scheme + "://" + u.Host, + // Twelve hours. The spec caps this at 24; shorter limits how long a + // captured token is worth anything. + "exp": time.Now().Add(12 * time.Hour).Unix(), + "sub": subject, + }) + if err != nil { + return "", err + } + signingInput := header + "." + b64.EncodeToString(claims) + + privBytes, err := b64.DecodeString(vapid.Private) + if err != nil { + return "", err + } + key := &ecdsa.PrivateKey{ + PublicKey: ecdsa.PublicKey{Curve: elliptic.P256()}, + D: new(big.Int).SetBytes(privBytes), + } + key.PublicKey.X, key.PublicKey.Y = elliptic.P256().ScalarBaseMult(privBytes) + + sum := sha256.Sum256([]byte(signingInput)) + r, sSig, err := ecdsa.Sign(rand.Reader, key, sum[:]) + if err != nil { + return "", err + } + + // JOSE wants the two halves as fixed-width 32-byte values, not the ASN.1 + // encoding ecdsa.Sign's other form produces — a short R silently breaks + // verification on some services and not others. + signature := make([]byte, 64) + r.FillBytes(signature[:32]) + sSig.FillBytes(signature[32:]) + + return signingInput + "." + b64.EncodeToString(signature), nil +} diff --git a/internal/store/comments.go b/internal/store/comments.go new file mode 100644 index 0000000..697a169 --- /dev/null +++ b/internal/store/comments.go @@ -0,0 +1,271 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "time" +) + +type Comment struct { + ID string + TaskID string + UserID string + UserName string + UserColor string + Body string + CreatedAt time.Time + UpdatedAt time.Time + + Attachments []Attachment +} + +type Attachment struct { + ID string + TaskID string + CommentID string + Filename string + MimeType string + Size int64 + Path string + UploadedBy string + CreatedAt time.Time +} + +func (db *DB) ListComments(ctx context.Context, taskID string) ([]Comment, error) { + rows, err := db.QueryContext(ctx, ` + SELECT c.id, c.task_id, c.user_id, u.name, u.avatar_color, c.body, + c.created_at, c.updated_at + FROM comments c JOIN users u ON u.id = c.user_id + WHERE c.task_id = ? AND c.deleted_at IS NULL + ORDER BY c.created_at`, taskID) + if err != nil { + return nil, err + } + + comments := []Comment{} + ids := []string{} + for rows.Next() { + var c Comment + var created, updated int64 + if err := rows.Scan(&c.ID, &c.TaskID, &c.UserID, &c.UserName, &c.UserColor, + &c.Body, &created, &updated); err != nil { + rows.Close() + return nil, err + } + c.CreatedAt = time.Unix(created, 0).UTC() + c.UpdatedAt = time.Unix(updated, 0).UTC() + comments = append(comments, c) + ids = append(ids, c.ID) + } + err = rows.Err() + // Closed before the attachment lookup: an open result set holds its connection. + rows.Close() + if err != nil { + return nil, err + } + + if len(ids) == 0 { + return comments, nil + } + byComment, err := db.attachmentsByComment(ctx, ids) + if err != nil { + return nil, err + } + for i := range comments { + comments[i].Attachments = byComment[comments[i].ID] + } + return comments, nil +} + +func (db *DB) CreateComment(ctx context.Context, taskID, userID, body string) (*Comment, error) { + c := &Comment{ID: NewID(), TaskID: taskID, UserID: userID, Body: body} + now := time.Now().UTC() + c.CreatedAt, c.UpdatedAt = now, now + + _, err := db.ExecContext(ctx, + `INSERT INTO comments (id, task_id, user_id, body, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)`, + c.ID, taskID, userID, body, now.Unix(), now.Unix()) + if err != nil { + return nil, err + } + return c, nil +} + +// UpdateComment is scoped to the author. Editing somebody else's words is not +// something a project role should confer, however senior. +func (db *DB) UpdateComment(ctx context.Context, commentID, userID, body string) error { + res, err := db.ExecContext(ctx, + `UPDATE comments SET body = ?, updated_at = ? + WHERE id = ? AND user_id = ? AND deleted_at IS NULL`, + body, time.Now().Unix(), commentID, userID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// DeleteComment allows the author, or the project's owner — who has to be able to +// remove something inappropriate from a project they are responsible for. +func (db *DB) DeleteComment(ctx context.Context, commentID, userID string, isProjectOwner bool) error { + query := `UPDATE comments SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL` + args := []any{time.Now().Unix(), commentID} + if !isProjectOwner { + query += ` AND user_id = ?` + args = append(args, userID) + } + + res, err := db.ExecContext(ctx, query, args...) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// CommentTask reports which task a comment belongs to, so a handler can check the +// project's permissions before touching it. +func (db *DB) CommentTask(ctx context.Context, commentID string) (string, error) { + var taskID string + err := db.QueryRowContext(ctx, + `SELECT task_id FROM comments WHERE id = ? AND deleted_at IS NULL`, commentID).Scan(&taskID) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + return taskID, err +} + +// --- attachments ----------------------------------------------------------------- + +// CreateAttachment records a file that has already been written to disk. The row is +// written after the bytes, so a crash between the two leaves an orphan file rather +// than a row pointing at nothing — the first is invisible, the second is a broken +// download. +func (db *DB) CreateAttachment(ctx context.Context, a *Attachment) error { + if a.ID == "" { + a.ID = NewID() + } + a.CreatedAt = time.Now().UTC() + + _, err := db.ExecContext(ctx, + `INSERT INTO attachments (id, task_id, comment_id, filename, mime_type, size, + path, uploaded_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + a.ID, nullString(a.TaskID), nullString(a.CommentID), a.Filename, + a.MimeType, a.Size, a.Path, a.UploadedBy, a.CreatedAt.Unix()) + return err +} + +func (db *DB) ListTaskAttachments(ctx context.Context, taskID string) ([]Attachment, error) { + rows, err := db.QueryContext(ctx, + `SELECT id, task_id, comment_id, filename, mime_type, size, path, uploaded_by, created_at + FROM attachments WHERE task_id = ? ORDER BY created_at`, taskID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanAttachments(rows) +} + +func (db *DB) attachmentsByComment(ctx context.Context, commentIDs []string) (map[string][]Attachment, error) { + args := make([]any, len(commentIDs)) + for i, id := range commentIDs { + args[i] = id + } + rows, err := db.QueryContext(ctx, + `SELECT id, task_id, comment_id, filename, mime_type, size, path, uploaded_by, created_at + FROM attachments WHERE comment_id IN (`+placeholders(len(commentIDs))+`) + ORDER BY created_at`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + list, err := scanAttachments(rows) + if err != nil { + return nil, err + } + out := map[string][]Attachment{} + for _, a := range list { + out[a.CommentID] = append(out[a.CommentID], a) + } + return out, nil +} + +func scanAttachments(rows *sql.Rows) ([]Attachment, error) { + out := []Attachment{} + for rows.Next() { + var a Attachment + var taskID, commentID sql.NullString + var created int64 + if err := rows.Scan(&a.ID, &taskID, &commentID, &a.Filename, &a.MimeType, + &a.Size, &a.Path, &a.UploadedBy, &created); err != nil { + return nil, err + } + a.TaskID = taskID.String + a.CommentID = commentID.String + a.CreatedAt = time.Unix(created, 0).UTC() + out = append(out, a) + } + return out, rows.Err() +} + +func (db *DB) GetAttachment(ctx context.Context, attachmentID string) (*Attachment, error) { + rows, err := db.QueryContext(ctx, + `SELECT id, task_id, comment_id, filename, mime_type, size, path, uploaded_by, created_at + FROM attachments WHERE id = ?`, attachmentID) + if err != nil { + return nil, err + } + defer rows.Close() + + list, err := scanAttachments(rows) + if err != nil { + return nil, err + } + if len(list) == 0 { + return nil, ErrNotFound + } + return &list[0], nil +} + +// AttachmentTask resolves an attachment to the task whose project governs it — +// directly, or through the comment it hangs on. +func (db *DB) AttachmentTask(ctx context.Context, attachmentID string) (string, error) { + var taskID sql.NullString + err := db.QueryRowContext(ctx, ` + SELECT COALESCE(a.task_id, c.task_id) + FROM attachments a + LEFT JOIN comments c ON c.id = a.comment_id + WHERE a.id = ?`, attachmentID).Scan(&taskID) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", err + } + if !taskID.Valid { + return "", ErrNotFound + } + return taskID.String, nil +} + +// DeleteAttachment removes the row and returns the path, so the caller can delete +// the file. The row goes first: a file with no row is invisible, a row with no file +// is a download that fails. +func (db *DB) DeleteAttachment(ctx context.Context, attachmentID string) (string, error) { + a, err := db.GetAttachment(ctx, attachmentID) + if err != nil { + return "", err + } + if _, err := db.ExecContext(ctx, `DELETE FROM attachments WHERE id = ?`, attachmentID); err != nil { + return "", err + } + return a.Path, nil +} diff --git a/internal/store/migrations/0004_comments_notifications.sql b/internal/store/migrations/0004_comments_notifications.sql new file mode 100644 index 0000000..0bb42a7 --- /dev/null +++ b/internal/store/migrations/0004_comments_notifications.sql @@ -0,0 +1,66 @@ +-- Notifications, Web Push, and the mail-to-task address. + +-- What a person needs to be told about: an assignment, a comment on something they +-- are in, an invite accepted. Stored rather than only pushed, so the bell icon has +-- a history and a notification survives the browser being shut. +CREATE TABLE notifications ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + actor_id TEXT REFERENCES users (id) ON DELETE SET NULL, + project_id TEXT REFERENCES projects (id) ON DELETE CASCADE, + task_id TEXT REFERENCES tasks (id) ON DELETE CASCADE, + kind TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + read_at INTEGER, + created_at INTEGER NOT NULL +); + +CREATE INDEX idx_notifications_user ON notifications (user_id, created_at DESC); +CREATE INDEX idx_notifications_unread ON notifications (user_id) WHERE read_at IS NULL; + +-- Web Push (RFC 8030) subscriptions. One row per browser per device: the same +-- person signed in on a laptop and a phone has two, and revoking one must not +-- silence the other. +CREATE TABLE push_subscriptions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + user_agent TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + last_used_at INTEGER, + -- A push service returns 404 or 410 when a subscription is dead. Counting the + -- failures lets a flaky network be forgiven while a genuinely gone endpoint is + -- eventually dropped. + failures INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX idx_push_user ON push_subscriptions (user_id); + +-- The VAPID keypair identifying this instance to push services. One row, ever. +-- Generated on first use rather than configured: it is not a secret an operator +-- should have to produce, and rotating it only costs re-subscribing. +CREATE TABLE instance_keys ( + id TEXT PRIMARY KEY, + public_key TEXT NOT NULL, + private_key TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +-- The per-user address that turns an email into a task: todo+@domain. +ALTER TABLE users ADD COLUMN mail_token TEXT; + +CREATE UNIQUE INDEX idx_users_mail_token ON users (mail_token) WHERE mail_token IS NOT NULL; + +-- Settings that belong to a person rather than to the instance: which AI provider +-- they use and with whose key, what their Gmail connection is. Kept as JSON for +-- the same reason templates are — written whole, read whole, and it would +-- otherwise be a migration every time an integration gains an option. +CREATE TABLE user_settings ( + user_id TEXT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE, + scope TEXT NOT NULL DEFAULT 'general', + values_json TEXT NOT NULL DEFAULT '{}', + updated_at INTEGER NOT NULL +); diff --git a/internal/store/notifications.go b/internal/store/notifications.go new file mode 100644 index 0000000..6437067 --- /dev/null +++ b/internal/store/notifications.go @@ -0,0 +1,232 @@ +package store + +import ( + "context" + "database/sql" + "time" +) + +type Notification struct { + ID string + UserID string + ActorID string + ActorName string + ProjectID string + TaskID string + Kind string + Title string + Body string + ReadAt *time.Time + CreatedAt time.Time +} + +func (db *DB) CreateNotification(ctx context.Context, n *Notification) error { + if n.ID == "" { + n.ID = NewID() + } + n.CreatedAt = time.Now().UTC() + + _, err := db.ExecContext(ctx, + `INSERT INTO notifications (id, user_id, actor_id, project_id, task_id, kind, + title, body, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + n.ID, n.UserID, nullString(n.ActorID), nullString(n.ProjectID), + nullString(n.TaskID), n.Kind, n.Title, n.Body, n.CreatedAt.Unix()) + return 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 + FROM notifications n + LEFT JOIN users u ON u.id = n.actor_id + WHERE n.user_id = ? + ORDER BY n.created_at DESC + LIMIT ?`, userID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []Notification{} + for rows.Next() { + var n Notification + var actorID, projectID, taskID 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 { + return nil, err + } + n.ActorID, n.ProjectID, n.TaskID = actorID.String, projectID.String, taskID.String + if readAt.Valid { + v := time.Unix(readAt.Int64, 0).UTC() + n.ReadAt = &v + } + n.CreatedAt = time.Unix(created, 0).UTC() + out = append(out, n) + } + return out, rows.Err() +} + +func (db *DB) UnreadNotificationCount(ctx context.Context, userID string) (int, error) { + var n int + err := db.QueryRowContext(ctx, + `SELECT count(*) FROM notifications WHERE user_id = ? AND read_at IS NULL`, + userID).Scan(&n) + return n, err +} + +// MarkNotificationsRead marks one, or all of them when id is empty. +func (db *DB) MarkNotificationsRead(ctx context.Context, userID, id string) error { + query := `UPDATE notifications SET read_at = ? WHERE user_id = ? AND read_at IS NULL` + args := []any{time.Now().Unix(), userID} + if id != "" { + query += ` AND id = ?` + args = append(args, id) + } + _, err := db.ExecContext(ctx, query, args...) + return err +} + +// ProjectMemberIDs is everyone who can see a project, owner included — the audience +// for a notification about something that happened inside it. +func (db *DB) ProjectMemberIDs(ctx context.Context, projectID string) ([]string, error) { + rows, err := db.QueryContext(ctx, ` + SELECT owner_id FROM projects WHERE id = ? + UNION + SELECT user_id FROM project_members WHERE project_id = ?`, projectID, projectID) + 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() +} + +// --- Web Push subscriptions -------------------------------------------------------- + +type PushSubscription struct { + ID string + UserID string + Endpoint string + P256dh string + Auth string + UserAgent string + Failures int +} + +// SavePushSubscription is an upsert on the endpoint: a browser that re-subscribes +// produces the same endpoint, and a second row would mean two identical +// notifications on one device. +func (db *DB) SavePushSubscription(ctx context.Context, sub *PushSubscription) error { + if sub.ID == "" { + sub.ID = NewID() + } + _, err := db.ExecContext(ctx, + `INSERT INTO push_subscriptions (id, user_id, endpoint, p256dh, auth, user_agent, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (endpoint) DO UPDATE SET + user_id = excluded.user_id, + p256dh = excluded.p256dh, + auth = excluded.auth, + user_agent = excluded.user_agent, + failures = 0`, + sub.ID, sub.UserID, sub.Endpoint, sub.P256dh, sub.Auth, sub.UserAgent, + time.Now().Unix()) + return err +} + +func (db *DB) ListPushSubscriptions(ctx context.Context, userID string) ([]PushSubscription, error) { + rows, err := db.QueryContext(ctx, + `SELECT id, user_id, endpoint, p256dh, auth, user_agent, failures + FROM push_subscriptions WHERE user_id = ?`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []PushSubscription{} + for rows.Next() { + var s PushSubscription + if err := rows.Scan(&s.ID, &s.UserID, &s.Endpoint, &s.P256dh, &s.Auth, + &s.UserAgent, &s.Failures); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +func (db *DB) DeletePushSubscription(ctx context.Context, endpoint string) error { + _, err := db.ExecContext(ctx, `DELETE FROM push_subscriptions WHERE endpoint = ?`, endpoint) + return err +} + +// RecordPushFailure counts a delivery failure and drops the subscription once it +// has failed enough times. A push service says 404 or 410 when an endpoint is gone, +// but it also has bad days — forgiving a few failures keeps a flaky network from +// silently unsubscribing somebody's phone. +func (db *DB) RecordPushFailure(ctx context.Context, endpoint string, permanent bool) error { + if permanent { + return db.DeletePushSubscription(ctx, endpoint) + } + _, err := db.ExecContext(ctx, + `UPDATE push_subscriptions SET failures = failures + 1 WHERE endpoint = ?`, endpoint) + if err != nil { + return err + } + _, err = db.ExecContext(ctx, + `DELETE FROM push_subscriptions WHERE endpoint = ? AND failures >= 10`, endpoint) + return err +} + +// --- VAPID keypair ------------------------------------------------------------------ + +// InstanceKeys returns the VAPID keypair, generating it on first use. +// +// Generated rather than configured: it identifies this instance to push services +// and is not something an operator should have to produce. Rotating it only costs +// every browser re-subscribing. +func (db *DB) InstanceKeys(ctx context.Context, generate func() (public, private string, err error)) (string, string, error) { + var public, private string + err := db.QueryRowContext(ctx, + `SELECT public_key, private_key FROM instance_keys LIMIT 1`).Scan(&public, &private) + if err == nil { + return public, private, nil + } + if err != sql.ErrNoRows { + return "", "", err + } + + public, private, err = generate() + if err != nil { + return "", "", err + } + _, err = db.ExecContext(ctx, + `INSERT INTO instance_keys (id, public_key, private_key, created_at) VALUES (?, ?, ?, ?)`, + NewID(), public, private, time.Now().Unix()) + if err != nil { + // Another request generated one first; take theirs so the two agree. + var existingPub, existingPriv string + if qErr := db.QueryRowContext(ctx, + `SELECT public_key, private_key FROM instance_keys LIMIT 1`). + Scan(&existingPub, &existingPriv); qErr == nil { + return existingPub, existingPriv, nil + } + return "", "", err + } + return public, private, nil +} diff --git a/internal/todoist/convert.go b/internal/todoist/convert.go new file mode 100644 index 0000000..e384f0d --- /dev/null +++ b/internal/todoist/convert.go @@ -0,0 +1,180 @@ +package todoist + +import ( + "sort" + "strings" +) + +// Project is the shape both sides of the conversion work in: a project with +// sections and a tree of tasks. It is deliberately not verdande's store.Task — +// keeping the CSV layer ignorant of the database is what lets it be tested without +// one, and what makes the round trip a pure function. +type Project struct { + Name string + Sections []Section + Tasks []Task +} + +type Section struct { + Name string + Tasks []Task +} + +type Task struct { + Content string + Description string + Priority int // verdande's numbering: 1 highest, 4 none + Date string + Assignee string + Author string + Comments []string + Children []Task +} + +// ToRows flattens a project into CSV rows. +// +// Order is the data here — a task's parent is whichever row above it has a smaller +// indent — so this walks the tree depth-first and never sorts. +func ToRows(p Project) []Row { + var rows []Row + + appendTask := func(t Task, indent int) {} + appendTask = func(t Task, indent int) { + rows = append(rows, Row{ + Type: TypeTask, + Content: t.Content, + Description: t.Description, + Priority: PriorityFromVerdande(t.Priority), + Indent: indent, + Author: t.Author, + Responsible: t.Assignee, + Date: t.Date, + DateLang: dateLang(t.Date), + Timezone: "", + }) + // A comment belongs to the task above it, so notes go immediately after + // their task and before any of its children. + for _, note := range t.Comments { + rows = append(rows, Row{Type: TypeNote, Content: note}) + } + for _, child := range t.Children { + // Todoist caps display nesting at four levels; deeper tasks are still + // written, flattened to the deepest level it will show. + next := indent + 1 + if next > 4 { + next = 4 + } + appendTask(child, next) + } + } + + for _, t := range p.Tasks { + appendTask(t, 1) + } + + for _, section := range p.Sections { + // Todoist separates sections with a blank row; its own exports have one + // and its importer is happier with it. + rows = append(rows, Row{}) + rows = append(rows, Row{Type: TypeSection, Content: section.Name}) + for _, t := range section.Tasks { + appendTask(t, 1) + } + } + return rows +} + +// FromRows rebuilds the project from CSV rows. +// +// The indent stack is the whole algorithm: each task attaches to the last task seen +// at one level shallower, and a task at indent 1 starts a new top-level branch. +func FromRows(rows []Row, projectName string) Project { + p := Project{Name: projectName} + + // Pointers into the tree at each depth, so a child can be appended to whoever + // is currently open one level up. + var stack []*Task + var currentSection *Section + + // The most recently added task, which is what a note attaches to. + var lastTask *Task + + addTask := func(t Task, indent int) *Task { + if indent < 1 { + indent = 1 + } + if indent > len(stack)+1 { + // A file that jumps from indent 1 to indent 3 — possible after + // hand-editing. Treat it as one level deeper than what is open, + // rather than dropping the task. + indent = len(stack) + 1 + } + stack = stack[:indent-1] + + if indent == 1 { + if currentSection != nil { + currentSection.Tasks = append(currentSection.Tasks, t) + added := ¤tSection.Tasks[len(currentSection.Tasks)-1] + stack = append(stack, added) + return added + } + p.Tasks = append(p.Tasks, t) + added := &p.Tasks[len(p.Tasks)-1] + stack = append(stack, added) + return added + } + + parent := stack[indent-2] + parent.Children = append(parent.Children, t) + added := &parent.Children[len(parent.Children)-1] + stack = append(stack, added) + return added + } + + for _, row := range rows { + switch row.Type { + case TypeSection: + p.Sections = append(p.Sections, Section{Name: row.Content}) + currentSection = &p.Sections[len(p.Sections)-1] + // A new section resets the tree: nothing above it can be a parent of + // anything below. + stack = nil + lastTask = nil + + case TypeTask: + if strings.TrimSpace(row.Content) == "" { + continue + } + lastTask = addTask(Task{ + Content: row.Content, + Description: row.Description, + Priority: PriorityToVerdande(row.Priority), + Date: row.Date, + Assignee: row.Responsible, + Author: row.Author, + }, row.Indent) + + case TypeNote: + if lastTask != nil && strings.TrimSpace(row.Content) != "" { + lastTask.Comments = append(lastTask.Comments, row.Content) + } + } + } + return p +} + +// dateLang is the language Todoist should parse the DATE string in. Only set when +// there is a date to parse; an empty column with a language beside it confuses its +// importer. +func dateLang(date string) string { + if strings.TrimSpace(date) == "" { + return "" + } + return "en" +} + +// SortSections puts sections in a stable order for export, so two exports of an +// unchanged project produce identical bytes. +func SortSections(p *Project) { + sort.SliceStable(p.Sections, func(i, j int) bool { return p.Sections[i].Name < p.Sections[j].Name }) +} diff --git a/internal/todoist/csv.go b/internal/todoist/csv.go new file mode 100644 index 0000000..fba0933 --- /dev/null +++ b/internal/todoist/csv.go @@ -0,0 +1,281 @@ +// Package todoist reads and writes Todoist's own CSV template format. +// +// The format is what Todoist's "export as template" produces and what its "import +// from template" accepts, so a file written here opens there and a file exported +// from there opens here. That is the whole point: leaving a hosted product should +// not mean leaving the data behind, and it should not be a one-way door either. +// +// The shape of a Todoist CSV is unusual and worth stating, because it explains most +// of this file. Every row has the same eight columns, but the TYPE column decides +// what a row *is*: +// +// task — a task; INDENT gives its depth, so 2 is a sub-task of the task above +// section — a section heading; only CONTENT is meaningful +// note — a comment on the task above it +// "" — a blank spacer row, which Todoist emits between sections +// +// Nesting is therefore positional: a row's parent is the nearest preceding row with +// a smaller indent. Reordering the rows changes the tree, which is why the writer +// walks the tree in order rather than sorting by anything. +package todoist + +import ( + "encoding/csv" + "fmt" + "io" + "strconv" + "strings" + "time" +) + +// Columns, in Todoist's order. The header has to match exactly or Todoist refuses +// the file. +var header = []string{ + "TYPE", "CONTENT", "DESCRIPTION", "PRIORITY", "INDENT", "AUTHOR", "RESPONSIBLE", "DATE", "DATE_LANG", "TIMEZONE", +} + +type Row struct { + Type string + Content string + Description string + Priority int // Todoist's own numbering: 4 is highest, 1 is none + Indent int + Author string + Responsible string + Date string + DateLang string + Timezone string +} + +const ( + TypeTask = "task" + TypeSection = "section" + TypeNote = "note" +) + +// Parse reads a Todoist CSV. +// +// It is lenient about what it accepts and strict about what it produces: a file +// that has been through a spreadsheet — reordered columns, a BOM, semicolons +// instead of commas, missing trailing fields — still reads, because that is what +// actually arrives when somebody exports and then opens the file "just to look". +func Parse(r io.Reader) ([]Row, error) { + reader := csv.NewReader(newBOMStripper(r)) + // Rows genuinely vary in length once a spreadsheet has touched them. + reader.FieldsPerRecord = -1 + reader.LazyQuotes = true + + records, err := reader.ReadAll() + if err != nil { + // Retry with semicolons: that is what Excel writes in a Danish locale, and + // it is the single most common reason a re-saved export will not read. + if semi, semiErr := readSemicolon(r); semiErr == nil { + records = semi + } else { + return nil, fmt.Errorf("todoist: read csv: %w", err) + } + } + if len(records) == 0 { + return nil, fmt.Errorf("todoist: the file is empty") + } + + index, err := mapColumns(records[0]) + if err != nil { + return nil, err + } + + var rows []Row + for _, record := range records[1:] { + get := func(name string) string { + i, ok := index[name] + if !ok || i >= len(record) { + return "" + } + return strings.TrimSpace(record[i]) + } + + row := Row{ + Type: strings.ToLower(get("TYPE")), + Content: get("CONTENT"), + Description: get("DESCRIPTION"), + Author: get("AUTHOR"), + Responsible: get("RESPONSIBLE"), + Date: get("DATE"), + DateLang: get("DATE_LANG"), + Timezone: get("TIMEZONE"), + } + row.Priority, _ = strconv.Atoi(get("PRIORITY")) + row.Indent, _ = strconv.Atoi(get("INDENT")) + + // Blank spacer rows carry nothing; keeping them would produce empty tasks. + if row.Type == "" && row.Content == "" { + continue + } + rows = append(rows, row) + } + return rows, nil +} + +func readSemicolon(r io.Reader) ([][]string, error) { + seeker, ok := r.(io.Seeker) + if !ok { + return nil, fmt.Errorf("cannot re-read the input") + } + if _, err := seeker.Seek(0, io.SeekStart); err != nil { + return nil, err + } + reader := csv.NewReader(newBOMStripper(r)) + reader.Comma = ';' + reader.FieldsPerRecord = -1 + reader.LazyQuotes = true + return reader.ReadAll() +} + +// mapColumns finds each column by name rather than by position, so a file whose +// columns have been reordered still reads. +func mapColumns(headerRow []string) (map[string]int, error) { + index := map[string]int{} + for i, name := range headerRow { + index[strings.ToUpper(strings.TrimSpace(strings.Trim(name, `"`)))] = i + } + if _, ok := index["CONTENT"]; !ok { + return nil, fmt.Errorf("todoist: this does not look like a Todoist export — there is no CONTENT column") + } + return index, nil +} + +// Write emits a Todoist-compatible CSV. +func Write(w io.Writer, rows []Row) error { + writer := csv.NewWriter(w) + if err := writer.Write(header); err != nil { + return err + } + for _, row := range rows { + record := []string{ + row.Type, row.Content, row.Description, + // Todoist writes an empty priority for a row that is not a task, and + // so must this: a "4" on a section makes its own importer complain. + priorityField(row), indentField(row), + row.Author, row.Responsible, row.Date, row.DateLang, row.Timezone, + } + if err := writer.Write(record); err != nil { + return err + } + } + writer.Flush() + return writer.Error() +} + +func priorityField(row Row) string { + if row.Type != TypeTask || row.Priority == 0 { + return "" + } + return strconv.Itoa(row.Priority) +} + +func indentField(row Row) string { + if row.Type != TypeTask { + return "" + } + if row.Indent == 0 { + return "1" + } + return strconv.Itoa(row.Indent) +} + +// --- priority ------------------------------------------------------------------ + +// Todoist numbers priorities the opposite way round to how it displays them: the +// CSV says 4 for what the interface calls P1. verdande stores what the interface +// means, so the two have to be converted rather than copied — and getting this +// backwards would silently invert the urgency of every imported task. +func PriorityToVerdande(todoist int) int { + switch todoist { + case 4: + return 1 + case 3: + return 2 + case 2: + return 3 + default: + return 4 + } +} + +func PriorityFromVerdande(verdande int) int { + switch verdande { + case 1: + return 4 + case 2: + return 3 + case 3: + return 2 + default: + return 1 + } +} + +// --- dates --------------------------------------------------------------------- + +// ParseDate reads the DATE column, which Todoist fills with whatever the person +// typed — "tomorrow", "every Monday", "2026-03-15", "15 Mar" — because it stores +// the natural-language string rather than a date. +// +// Only unambiguous machine formats are converted here. Anything else is handed back +// as text for the caller's own natural-language parser, which already understands +// far more of it and does so in the user's timezone. +func ParseDate(value string) (date string, remaining string, ok bool) { + value = strings.TrimSpace(value) + if value == "" { + return "", "", false + } + + for _, layout := range []string{ + "2006-01-02", "2006-01-02 15:04", "2006-01-02T15:04:05Z07:00", + "02-01-2006", "02/01/2006", "Jan 2 2006", "2 Jan 2006", + } { + if t, err := time.Parse(layout, value); err == nil { + return t.Format("2006-01-02"), "", true + } + } + return "", value, false +} + +// --- BOM ------------------------------------------------------------------------ + +// newBOMStripper drops a UTF-8 byte order mark. Excel writes one, and left in place +// it becomes part of the first column's name — so "TYPE" is not found and the file +// reads as having no recognisable columns at all. +func newBOMStripper(r io.Reader) io.Reader { + return &bomStripper{r: r} +} + +type bomStripper struct { + r io.Reader + checked bool + buf []byte +} + +func (b *bomStripper) Read(p []byte) (int, error) { + if !b.checked { + b.checked = true + head := make([]byte, 3) + n, err := io.ReadFull(b.r, head) + if n > 0 { + head = head[:n] + if n == 3 && head[0] == 0xEF && head[1] == 0xBB && head[2] == 0xBF { + head = nil + } + b.buf = head + } + if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { + return 0, err + } + } + if len(b.buf) > 0 { + n := copy(p, b.buf) + b.buf = b.buf[n:] + return n, nil + } + return b.r.Read(p) +} diff --git a/internal/todoist/todoist_test.go b/internal/todoist/todoist_test.go new file mode 100644 index 0000000..ba61fd9 --- /dev/null +++ b/internal/todoist/todoist_test.go @@ -0,0 +1,436 @@ +package todoist + +import ( + "bytes" + "strings" + "testing" +) + +// A real Todoist template export, with the shapes that matter: a task tree, a +// section, notes, priorities and dates. +const sampleCSV = `TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE +task,Betal moms,husk bilag,4,1,Kristian,,2026-03-15,en, +note,Bilag ligger i Dropbox,,,,,,,, +task,Find bilag,,3,2,Kristian,,,, +task,Scan dem,,1,3,Kristian,,,, +task,Ring til revisor,,1,1,Kristian,Anders,,, +,,,,,,,,, +section,Til gennemsyn,,,,,,,, +task,Gennemgå kvartal,,2,1,Kristian,,,, +task,Underopgave,,1,2,Kristian,,,, +` + +func TestParseReadsTheShape(t *testing.T) { + rows, err := Parse(strings.NewReader(sampleCSV)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + // The blank spacer row is dropped; everything else survives. + var tasks, sections, notes int + for _, r := range rows { + switch r.Type { + case TypeTask: + tasks++ + case TypeSection: + sections++ + case TypeNote: + notes++ + } + } + if tasks != 6 { + t.Errorf("got %d tasks, want 6", tasks) + } + if sections != 1 { + t.Errorf("got %d sections, want 1", sections) + } + if notes != 1 { + t.Errorf("got %d notes, want 1", notes) + } +} + +// Todoist's CSV numbers priorities the opposite way to how its interface shows +// them. Getting this backwards silently inverts the urgency of every task. +func TestPriorityIsInverted(t *testing.T) { + cases := map[int]int{4: 1, 3: 2, 2: 3, 1: 4, 0: 4} + for todoist, want := range cases { + if got := PriorityToVerdande(todoist); got != want { + t.Errorf("PriorityToVerdande(%d) = %d, want %d", todoist, got, want) + } + } + for verdande, want := range map[int]int{1: 4, 2: 3, 3: 2, 4: 1} { + if got := PriorityFromVerdande(verdande); got != want { + t.Errorf("PriorityFromVerdande(%d) = %d, want %d", verdande, got, want) + } + } + // And the two are inverses, which is what the round trip depends on. + for v := 1; v <= 4; v++ { + if got := PriorityToVerdande(PriorityFromVerdande(v)); got != v { + t.Errorf("priority %d survived a round trip as %d", v, got) + } + } +} + +func TestFromRowsBuildsTheTree(t *testing.T) { + rows, err := Parse(strings.NewReader(sampleCSV)) + if err != nil { + t.Fatal(err) + } + p := FromRows(rows, "Firma") + + if p.Name != "Firma" { + t.Errorf("Name = %q", p.Name) + } + if len(p.Tasks) != 2 { + t.Fatalf("got %d top-level tasks, want 2", len(p.Tasks)) + } + + moms := p.Tasks[0] + if moms.Content != "Betal moms" { + t.Fatalf("first task = %q", moms.Content) + } + if moms.Priority != 1 { + t.Errorf("priority = %d, want 1 (Todoist's 4)", moms.Priority) + } + if moms.Date != "2026-03-15" { + t.Errorf("date = %q", moms.Date) + } + if len(moms.Comments) != 1 || moms.Comments[0] != "Bilag ligger i Dropbox" { + t.Errorf("comments = %v", moms.Comments) + } + + // Indent 2 is a child of the indent-1 task above it; indent 3 is a child of that. + if len(moms.Children) != 1 { + t.Fatalf("got %d children, want 1", len(moms.Children)) + } + if moms.Children[0].Content != "Find bilag" { + t.Errorf("child = %q", moms.Children[0].Content) + } + if len(moms.Children[0].Children) != 1 { + t.Fatalf("got %d grandchildren, want 1", len(moms.Children[0].Children)) + } + if moms.Children[0].Children[0].Content != "Scan dem" { + t.Errorf("grandchild = %q", moms.Children[0].Children[0].Content) + } + + // The assignee survives. + if p.Tasks[1].Assignee != "Anders" { + t.Errorf("assignee = %q, want Anders", p.Tasks[1].Assignee) + } + + // A section resets the tree, and its own tasks nest inside it. + if len(p.Sections) != 1 { + t.Fatalf("got %d sections", len(p.Sections)) + } + section := p.Sections[0] + if section.Name != "Til gennemsyn" { + t.Errorf("section name = %q", section.Name) + } + if len(section.Tasks) != 1 { + t.Fatalf("the section has %d top-level tasks, want 1", len(section.Tasks)) + } + if len(section.Tasks[0].Children) != 1 { + t.Errorf("the section's task has %d children, want 1", len(section.Tasks[0].Children)) + } +} + +// The requirement from the brief, stated directly: a Todoist CSV in, and out again, +// has to be the same thing. This is the test that stops an import from quietly +// losing a sub-task, a comment or a priority. +func TestRoundTripIsLossless(t *testing.T) { + rows, err := Parse(strings.NewReader(sampleCSV)) + if err != nil { + t.Fatal(err) + } + project := FromRows(rows, "Firma") + + var out bytes.Buffer + if err := Write(&out, ToRows(project)); err != nil { + t.Fatalf("Write: %v", err) + } + + // Read what was written and rebuild. The two projects must be identical. + reparsed, err := Parse(bytes.NewReader(out.Bytes())) + if err != nil { + t.Fatalf("re-parsing our own output: %v", err) + } + again := FromRows(reparsed, "Firma") + + assertSameProject(t, project, again) +} + +// And a second pass must produce byte-identical output, or "export" is not a +// repeatable operation and diffing two exports is meaningless. +func TestExportIsStable(t *testing.T) { + rows, err := Parse(strings.NewReader(sampleCSV)) + if err != nil { + t.Fatal(err) + } + project := FromRows(rows, "Firma") + + var first, second bytes.Buffer + if err := Write(&first, ToRows(project)); err != nil { + t.Fatal(err) + } + reparsed, err := Parse(bytes.NewReader(first.Bytes())) + if err != nil { + t.Fatal(err) + } + if err := Write(&second, ToRows(FromRows(reparsed, "Firma"))); err != nil { + t.Fatal(err) + } + + if first.String() != second.String() { + t.Errorf("two exports of the same project differ:\n--- first ---\n%s\n--- second ---\n%s", + first.String(), second.String()) + } +} + +// What is written has to be readable by Todoist, which means the exact header and +// no priority or indent on rows that are not tasks. +func TestWrittenFileMatchesTodoistsFormat(t *testing.T) { + var out bytes.Buffer + err := Write(&out, ToRows(Project{ + Name: "Firma", + Tasks: []Task{{Content: "En opgave", Priority: 1}}, + Sections: []Section{{ + Name: "En sektion", + Tasks: []Task{{Content: "I sektionen", Priority: 4, Comments: []string{"en note"}}}, + }}, + })) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + + want := "TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE" + if strings.TrimSpace(lines[0]) != want { + t.Errorf("header:\n got %q\nwant %q", lines[0], want) + } + + for _, line := range lines[1:] { + fields := strings.Split(line, ",") + if len(fields) < 5 { + continue + } + // Only task rows carry a priority and an indent. + if fields[0] != TypeTask && (fields[3] != "" || fields[4] != "") { + t.Errorf("a %q row carries a priority or indent: %q", fields[0], line) + } + if fields[0] == TypeTask && fields[4] == "" { + t.Errorf("a task row has no indent: %q", line) + } + } +} + +// Files arrive having been opened in a spreadsheet, which is where BOMs, semicolons +// and reordered columns come from. Refusing those means refusing most real exports. +func TestParseSurvivesRealWorldFiles(t *testing.T) { + t.Run("utf-8 BOM", func(t *testing.T) { + withBOM := "\xEF\xBB\xBF" + sampleCSV + rows, err := Parse(strings.NewReader(withBOM)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(rows) == 0 { + t.Fatal("no rows were read") + } + if rows[0].Content != "Betal moms" { + t.Errorf("first row = %+v; the BOM was probably left in the header", rows[0]) + } + }) + + t.Run("reordered columns", func(t *testing.T) { + reordered := "CONTENT,TYPE,INDENT,PRIORITY,DESCRIPTION,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE\n" + + "Betal moms,task,1,4,,Kristian,,2026-03-15,en,\n" + rows, err := Parse(strings.NewReader(reordered)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(rows) != 1 { + t.Fatalf("got %d rows", len(rows)) + } + if rows[0].Content != "Betal moms" || rows[0].Type != TypeTask || rows[0].Priority != 4 { + t.Errorf("columns were read by position, not by name: %+v", rows[0]) + } + }) + + t.Run("short rows", func(t *testing.T) { + short := "TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT\ntask,Kort række\n" + rows, err := Parse(strings.NewReader(short)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(rows) != 1 || rows[0].Content != "Kort række" { + t.Errorf("rows = %+v", rows) + } + }) + + t.Run("danish characters and commas in content", func(t *testing.T) { + var out bytes.Buffer + original := Project{Tasks: []Task{ + {Content: `Køb mælk, brød og smør`, Description: "på vej hjem", Priority: 2}, + }} + if err := Write(&out, ToRows(original)); err != nil { + t.Fatal(err) + } + rows, err := Parse(bytes.NewReader(out.Bytes())) + if err != nil { + t.Fatal(err) + } + back := FromRows(rows, "") + if back.Tasks[0].Content != `Køb mælk, brød og smør` { + t.Errorf("content = %q; the comma was probably not quoted", back.Tasks[0].Content) + } + }) +} + +func TestParseRejectsWhatIsNotAnExport(t *testing.T) { + for _, in := range []string{ + "", + "navn;pris\nkaffe;40\n", + "just some text\n", + } { + t.Run(in, func(t *testing.T) { + if _, err := Parse(strings.NewReader(in)); err == nil { + t.Error("a file that is not a Todoist export was accepted") + } + }) + } +} + +// A hand-edited file can jump from indent 1 to indent 3. Dropping those tasks would +// lose work silently; attaching them one level deeper than what is open does not. +func TestBrokenIndentationDoesNotLoseTasks(t *testing.T) { + broken := `TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE +task,Forælder,,1,1,,,,, +task,Springer til tre,,1,3,,,,, +task,Tilbage til en,,1,1,,,,, +` + rows, err := Parse(strings.NewReader(broken)) + if err != nil { + t.Fatal(err) + } + p := FromRows(rows, "x") + + if got := countTasks(p); got != 3 { + t.Errorf("%d tasks survived, want all 3", got) + } + if len(p.Tasks) != 2 { + t.Errorf("got %d top-level tasks, want 2", len(p.Tasks)) + } +} + +func TestParseDate(t *testing.T) { + cases := []struct { + in string + wantDate string + wantOK bool + }{ + {"2026-03-15", "2026-03-15", true}, + {"2026-03-15 10:00", "2026-03-15", true}, + {"15-03-2026", "2026-03-15", true}, + {"15/03/2026", "2026-03-15", true}, + // Todoist stores what was typed, so most dates are natural language and + // belong to the caller's own parser. + {"every Monday", "", false}, + {"tomorrow", "", false}, + {"i morgen", "", false}, + {"", "", false}, + } + for _, tc := range cases { + t.Run(tc.in, func(t *testing.T) { + date, remaining, ok := ParseDate(tc.in) + if ok != tc.wantOK { + t.Fatalf("ok = %v, want %v", ok, tc.wantOK) + } + if date != tc.wantDate { + t.Errorf("date = %q, want %q", date, tc.wantDate) + } + if !ok && tc.in != "" && remaining != tc.in { + t.Errorf("remaining = %q, want the original text back", remaining) + } + }) + } +} + +// --- helpers --------------------------------------------------------------------- + +func assertSameProject(t *testing.T, want, got Project) { + t.Helper() + + if len(want.Tasks) != len(got.Tasks) { + t.Fatalf("top-level tasks: %d vs %d", len(want.Tasks), len(got.Tasks)) + } + for i := range want.Tasks { + assertSameTask(t, "task["+want.Tasks[i].Content+"]", want.Tasks[i], got.Tasks[i]) + } + + if len(want.Sections) != len(got.Sections) { + t.Fatalf("sections: %d vs %d", len(want.Sections), len(got.Sections)) + } + for i := range want.Sections { + if want.Sections[i].Name != got.Sections[i].Name { + t.Errorf("section name: %q vs %q", want.Sections[i].Name, got.Sections[i].Name) + } + if len(want.Sections[i].Tasks) != len(got.Sections[i].Tasks) { + t.Fatalf("section %q tasks: %d vs %d", want.Sections[i].Name, + len(want.Sections[i].Tasks), len(got.Sections[i].Tasks)) + } + for j := range want.Sections[i].Tasks { + assertSameTask(t, "section "+want.Sections[i].Name, + want.Sections[i].Tasks[j], got.Sections[i].Tasks[j]) + } + } +} + +func assertSameTask(t *testing.T, where string, want, got Task) { + t.Helper() + + if want.Content != got.Content { + t.Errorf("%s content: %q vs %q", where, want.Content, got.Content) + } + if want.Description != got.Description { + t.Errorf("%s description: %q vs %q", where, want.Description, got.Description) + } + if want.Priority != got.Priority { + t.Errorf("%s priority: %d vs %d", where, want.Priority, got.Priority) + } + if want.Date != got.Date { + t.Errorf("%s date: %q vs %q", where, want.Date, got.Date) + } + if want.Assignee != got.Assignee { + t.Errorf("%s assignee: %q vs %q", where, want.Assignee, got.Assignee) + } + if len(want.Comments) != len(got.Comments) { + t.Errorf("%s comments: %v vs %v", where, want.Comments, got.Comments) + } else { + for i := range want.Comments { + if want.Comments[i] != got.Comments[i] { + t.Errorf("%s comment %d: %q vs %q", where, i, want.Comments[i], got.Comments[i]) + } + } + } + if len(want.Children) != len(got.Children) { + t.Fatalf("%s children: %d vs %d", where, len(want.Children), len(got.Children)) + } + for i := range want.Children { + assertSameTask(t, where+" > "+want.Children[i].Content, want.Children[i], got.Children[i]) + } +} + +func countTasks(p Project) int { + var count func([]Task) int + count = func(tasks []Task) int { + n := 0 + for _, t := range tasks { + n += 1 + count(t.Children) + } + return n + } + total := count(p.Tasks) + for _, s := range p.Sections { + total += count(s.Tasks) + } + return total +}