diff --git a/internal/http/tasks.go b/internal/http/tasks.go index ec3f79d..92ee399 100644 --- a/internal/http/tasks.go +++ b/internal/http/tasks.go @@ -8,6 +8,7 @@ import ( "time" "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo/options" "bountyboard/internal/atomize" "bountyboard/internal/domain" @@ -23,10 +24,66 @@ func (s *Server) routesTasks(mux *http.ServeMux) { mux.Handle("POST /api/v1/tasks/publish", s.authedRole("consultant", s.handlePublishBulk)) mux.Handle("POST /api/v1/tasks/{id}/archive", s.authed(s.handleArchiveTask)) mux.Handle("POST /api/v1/tasks/archive", s.authedRole("consultant", s.handleArchiveBulk)) + mux.Handle("GET /api/v1/archive", s.requireAuth(http.HandlerFunc(s.handleArchive))) mux.Handle("GET /api/v1/tasks/{id}", s.requireAuth(http.HandlerFunc(s.handleTaskDetail))) mux.Handle("GET /api/v1/service-health", s.requireAuth(http.HandlerFunc(s.handleServiceHealth))) } +// handleArchive lists archived tasks scoped by role: admins see everything, +// consultants see their customers' tasks, developers see tasks they were +// assigned to or worked on. Optional ?q= full-text search. +func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) { + me := CurrentUser(r.Context()) + q := bson.M{"status": domain.StatusArchived} + if !me.Roles.Admin { + ors := []bson.M{} + if me.Roles.Consultant { + cs, err := s.store.ListCustomers(r.Context(), me.ID, true) + if err != nil { + s.internalError(w, r, "archive customers", err) + return + } + ids := make([]string, 0, len(cs)) + for _, c := range cs { + ids = append(ids, c.ID) + } + ors = append(ors, bson.M{"customerId": bson.M{"$in": ids}}) + } + if me.Roles.Developer { + ors = append(ors, + bson.M{"assignee.userId": me.ID}, + bson.M{"claimRequests.developerId": me.ID}) + } + // everyone also sees archived tasks they personally acted on + ors = append(ors, bson.M{"timeline.actorId": me.ID}) + q["$or"] = ors + } + if search := strings.TrimSpace(r.URL.Query().Get("q")); search != "" { + q["$text"] = bson.M{"$search": search} + } + cur, err := s.store.DB.Collection("tasks").Find(r.Context(), q, + options.Find().SetSort(bson.D{{Key: "_id", Value: -1}}).SetLimit(200)) + if err != nil { + s.internalError(w, r, "list archive", err) + return + } + tasks := []domain.Task{} + if err := cur.All(r.Context(), &tasks); err != nil { + s.internalError(w, r, "decode archive", err) + return + } + // resolve customer names for display + names := map[string]string{} + for _, t := range tasks { + if t.CustomerID != "" && names[t.CustomerID] == "" { + if c, err := s.store.CustomerByID(r.Context(), t.CustomerID); err == nil { + names[t.CustomerID] = c.Name + } + } + } + writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "customerNames": names}) +} + // consultantTask loads a task and authorizes the current user: admins and // every consultant assigned to the task's customer may manage it (§4.4). func (s *Server) consultantTask(w http.ResponseWriter, r *http.Request, id string) (*domain.Task, *domain.Customer, bool) { diff --git a/internal/http/web.go b/internal/http/web.go index 783ccba..e43a3ec 100644 --- a/internal/http/web.go +++ b/internal/http/web.go @@ -246,6 +246,7 @@ func (s *Server) routesWeb(mux *http.ServeMux) { rolePage("/consultant/reviews", "reviews.html", "Review Queue", "reviews", "/static/js/reviews.js", isCons) rolePage("/consultant/pool", "pool.html", "Developer Pool", "pool", "/static/js/pool.js", isCons) rolePage("/notifications", "notifications.html", "Notifications", "", "/static/js/notifications-page.js", nil) + rolePage("/archive", "archive.html", "Archive", "archive", "/static/js/archive.js", nil) mux.HandleFunc("GET /tasks/{id}", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) { s.render(w, r, "task.html", &pageData{ diff --git a/web/static/js/archive.js b/web/static/js/archive.js new file mode 100644 index 0000000..0ba5097 --- /dev/null +++ b/web/static/js/archive.js @@ -0,0 +1,44 @@ +// Archive page: archived tasks scoped by role (server-side), with search. +import { api } from '/static/js/api.js'; + +const host = document.getElementById('archive-list'); +const errorBox = document.getElementById('error'); +const search = document.getElementById('archive-search'); +let names = {}; + +const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', +}[c])); + +function render(tasks) { + host.replaceChildren(...tasks.map((t) => { + const card = document.createElement('div'); + card.className = 'card'; + const when = t.updatedAt ? new Date(t.updatedAt).toLocaleString() : ''; + card.innerHTML = ` +
${esc((t.description || '').slice(0, 200))}
+archived${when ? ' · ' + esc(when) : ''}
`; + return card; + })); + if (!tasks.length) { + host.innerHTML = 'No archived tasks match.
'; + } +} + +let timer = null; +async function load() { + try { + const q = search.value.trim(); + const res = await api('GET', '/api/v1/archive' + (q ? '?q=' + encodeURIComponent(q) : '')); + names = res.customerNames || {}; + render(res.tasks || []); + } catch (e) { errorBox.textContent = e.message; } +} + +search.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(load, 250); }); +load(); diff --git a/web/templates/archive.html b/web/templates/archive.html new file mode 100644 index 0000000..746a245 --- /dev/null +++ b/web/templates/archive.html @@ -0,0 +1,9 @@ +{{define "content"}} +Archived tasks you can access.
+ +{{end}} diff --git a/web/templates/layout.html b/web/templates/layout.html index fcb2af6..42b0a6e 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -28,6 +28,7 @@ {{end}} Messages Metrics + Archive {{end}}