feat(groups): a group becomes somewhere you can go
It was a heading over some rows: a name, a colour, and whether it was folded.
Enough to tidy a list, and not enough to be what people mean by it — "Arbejde" is
a body of work with context of its own, and documents that belong to all of it
rather than to any one project inside it.
`/gruppe/{id}` carries three things a heading cannot. The projects in it, each
with what is still open — open tasks, not all of them, because "12" beside a
project somebody finished last year is a number that means nothing. A description,
written in place and saved on blur, the same shape a task's description has. And
the files that govern the whole group: the contract, the drawing, the agreement
everything else rests on.
The name in the sidebar is a link to it and the chevron beside it still folds.
Two intentions, two targets — the single one they used to share could only ever
serve the smaller.
Migration 0012 rebuilds `attachments`, because its CHECK spelled out exactly which
parent columns may be set and SQLite cannot alter a CHECK. The rule is still
"exactly one parent", counted rather than written out as pairwise comparisons:
with three columns that is three clauses, with four it is six, and the version
that is wrong looks exactly like the one that is right.
A group's file is reachable by its owner and nobody else. A group belongs to one
person and is never shared, so `reachAttachment` asks about ownership rather than
a project role — both old callers resolved straight to a task, so a group's
attachment answered 404 for the person who uploaded it.
Three tests also stopped racing their own navigation. Creating a project navigates
and `goto` is a promise nobody was holding, so a quick add fired straight after
could land in the Inbox instead — intermittent, and it read as the quick add
having failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0b467beb3e
commit
804963b222
@@ -2,6 +2,8 @@ package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -17,16 +19,17 @@ import (
|
||||
// that is not yours is not found.
|
||||
|
||||
type projectGroupJSON struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
Collapsed bool `json:"collapsed"`
|
||||
SortOrder float64 `json:"sort_order"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
Description string `json:"description"`
|
||||
Collapsed bool `json:"collapsed"`
|
||||
SortOrder float64 `json:"sort_order"`
|
||||
}
|
||||
|
||||
func toProjectGroupJSON(g store.ProjectGroup) projectGroupJSON {
|
||||
return projectGroupJSON{
|
||||
ID: g.ID, Name: g.Name, Color: g.Color,
|
||||
ID: g.ID, Name: g.Name, Color: g.Color, Description: g.Description,
|
||||
Collapsed: g.Collapsed, SortOrder: g.SortOrder,
|
||||
}
|
||||
}
|
||||
@@ -45,9 +48,10 @@ func (s *Server) handleListProjectGroups(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
type projectGroupRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Color *string `json:"color"`
|
||||
Collapsed *bool `json:"collapsed"`
|
||||
Name *string `json:"name"`
|
||||
Color *string `json:"color"`
|
||||
Description *string `json:"description"`
|
||||
Collapsed *bool `json:"collapsed"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateProjectGroup(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -73,6 +77,9 @@ func (s *Server) handleCreateProjectGroup(w http.ResponseWriter, r *http.Request
|
||||
if req.Color != nil {
|
||||
g.Color = *req.Color
|
||||
}
|
||||
if req.Description != nil {
|
||||
g.Description = strings.TrimSpace(*req.Description)
|
||||
}
|
||||
if req.Collapsed != nil {
|
||||
g.Collapsed = *req.Collapsed
|
||||
}
|
||||
@@ -105,8 +112,18 @@ func (s *Server) handleUpdateProjectGroup(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
if req.Description != nil {
|
||||
description := strings.TrimSpace(*req.Description)
|
||||
if len(description) > 4000 {
|
||||
writeFieldErrors(w, map[string]string{"description": "must be at most 4000 characters"})
|
||||
return
|
||||
}
|
||||
req.Description = &description
|
||||
}
|
||||
|
||||
if err := s.db.UpdateProjectGroup(r.Context(), groupID, userID, store.ProjectGroupUpdate{
|
||||
Name: req.Name, Color: req.Color, Collapsed: req.Collapsed,
|
||||
Name: req.Name, Color: req.Color, Description: req.Description,
|
||||
Collapsed: req.Collapsed,
|
||||
}); err != nil {
|
||||
s.storeError(w, r, "update project group", err)
|
||||
return
|
||||
@@ -120,6 +137,126 @@ func (s *Server) handleUpdateProjectGroup(w http.ResponseWriter, r *http.Request
|
||||
writeJSON(w, http.StatusOK, toProjectGroupJSON(*g))
|
||||
}
|
||||
|
||||
// groupProjectJSON is a project as the group's page lists it: the project, plus
|
||||
// how much is left in it. Open tasks, not all of them — "12" beside a project
|
||||
// somebody finished last year is a number that means nothing.
|
||||
type groupProjectJSON struct {
|
||||
projectJSON
|
||||
OpenTasks int `json:"open_tasks"`
|
||||
}
|
||||
|
||||
// handleGetProjectGroup is the group as a place rather than a heading.
|
||||
//
|
||||
// A heading can carry a name. A page carries what the group *is* — the sentence
|
||||
// that says why these projects are one body of work, the documents that belong to
|
||||
// all of them rather than to any one, and the projects themselves with what is
|
||||
// left in each.
|
||||
//
|
||||
// The projects come with the group rather than being fetched per row, because the
|
||||
// page is nothing without them and two round trips to draw one list is one too
|
||||
// many.
|
||||
func (s *Server) handleGetProjectGroup(w http.ResponseWriter, r *http.Request) {
|
||||
groupID := chi.URLParam(r, "groupID")
|
||||
userID := userFrom(r.Context()).ID
|
||||
|
||||
g, err := s.db.GetProjectGroup(r.Context(), groupID, userID)
|
||||
if err != nil {
|
||||
s.storeError(w, r, "get project group", err)
|
||||
return
|
||||
}
|
||||
|
||||
projects, err := s.db.ListProjects(r.Context(), userID, false)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list projects", err)
|
||||
return
|
||||
}
|
||||
counts, err := s.db.OpenTaskCounts(r.Context(), groupID, userID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "open task counts", err)
|
||||
return
|
||||
}
|
||||
inside := make([]groupProjectJSON, 0, 4)
|
||||
for _, p := range projects {
|
||||
if p.GroupID == groupID {
|
||||
inside = append(inside, groupProjectJSON{
|
||||
projectJSON: toProjectJSON(p), OpenTasks: counts[p.ID],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
files, err := s.db.ListGroupAttachments(r.Context(), groupID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list group attachments", err)
|
||||
return
|
||||
}
|
||||
attachments := make([]attachmentJSON, 0, len(files))
|
||||
for _, a := range files {
|
||||
attachments = append(attachments, toAttachmentJSON(a))
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"group": toProjectGroupJSON(*g), "projects": inside, "attachments": attachments,
|
||||
})
|
||||
}
|
||||
|
||||
// handleUploadGroupAttachment puts a document on the group itself.
|
||||
//
|
||||
// The same storage as everywhere else — content-addressed by hash, so the path
|
||||
// never contains anything the uploader chose. What differs is only the parent.
|
||||
func (s *Server) handleUploadGroupAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
groupID := chi.URLParam(r, "groupID")
|
||||
user := userFrom(r.Context())
|
||||
|
||||
// Scoped by the lookup rather than by a separate check: a group that is not
|
||||
// yours is not found, which is the rule for everything in this file.
|
||||
if _, err := s.db.GetProjectGroup(r.Context(), groupID, user.ID); err != nil {
|
||||
s.storeError(w, r, "get project group", err)
|
||||
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, r, "store upload", err)
|
||||
return
|
||||
}
|
||||
|
||||
a := &store.Attachment{
|
||||
GroupID: groupID,
|
||||
Filename: safeFilename(header.Filename),
|
||||
MimeType: detectMime(header.Filename, header.Header.Get("Content-Type")),
|
||||
Size: size,
|
||||
Path: stored,
|
||||
UploadedBy: user.ID,
|
||||
}
|
||||
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
|
||||
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
|
||||
s.internal(w, r, "record attachment", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toAttachmentJSON(*a))
|
||||
}
|
||||
|
||||
// handleDeleteProjectGroup removes the heading. The projects under it come back
|
||||
// out as ungrouped, which is why this is not behind a confirmation about losing
|
||||
// work: there is none to lose.
|
||||
|
||||
Reference in New Issue
Block a user