feat: Archive tab — role-scoped list of archived tasks with search
New /archive page (nav link for all roles) backed by GET /api/v1/archive: - admins see every archived task; - consultants see archived tasks for customers they're assigned to; - developers see archived tasks they were assigned to, claimed, or acted on. Supports full-text ?q= search; cards link to the task detail view. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||||
|
|
||||||
"bountyboard/internal/atomize"
|
"bountyboard/internal/atomize"
|
||||||
"bountyboard/internal/domain"
|
"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/publish", s.authedRole("consultant", s.handlePublishBulk))
|
||||||
mux.Handle("POST /api/v1/tasks/{id}/archive", s.authed(s.handleArchiveTask))
|
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("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/tasks/{id}", s.requireAuth(http.HandlerFunc(s.handleTaskDetail)))
|
||||||
mux.Handle("GET /api/v1/service-health", s.requireAuth(http.HandlerFunc(s.handleServiceHealth)))
|
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
|
// consultantTask loads a task and authorizes the current user: admins and
|
||||||
// every consultant assigned to the task's customer may manage it (§4.4).
|
// 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) {
|
func (s *Server) consultantTask(w http.ResponseWriter, r *http.Request, id string) (*domain.Task, *domain.Customer, bool) {
|
||||||
|
|||||||
@@ -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/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("/consultant/pool", "pool.html", "Developer Pool", "pool", "/static/js/pool.js", isCons)
|
||||||
rolePage("/notifications", "notifications.html", "Notifications", "", "/static/js/notifications-page.js", nil)
|
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) {
|
mux.HandleFunc("GET /tasks/{id}", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
|
||||||
s.render(w, r, "task.html", &pageData{
|
s.render(w, r, "task.html", &pageData{
|
||||||
|
|||||||
@@ -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 = `
|
||||||
|
<div class="spread">
|
||||||
|
${names[t.customerId] ? `<span class="card-project">${esc(names[t.customerId])}</span>` : '<span></span>'}
|
||||||
|
<span class="badge accent">◈ ${t.bounty}</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="mt"><a href="/tasks/${t.id}">${esc(t.title)}</a></h3>
|
||||||
|
<p class="muted">${esc((t.description || '').slice(0, 200))}</p>
|
||||||
|
<p class="muted" style="font-size:0.8rem">archived${when ? ' · ' + esc(when) : ''}</p>`;
|
||||||
|
return card;
|
||||||
|
}));
|
||||||
|
if (!tasks.length) {
|
||||||
|
host.innerHTML = '<p class="muted">No archived tasks match.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>Archive</h1>
|
||||||
|
<div class="error-box" id="error" role="alert"></div>
|
||||||
|
<input type="search" id="archive-search" class="mt"
|
||||||
|
placeholder="Search archived tasks…" aria-label="Search archived tasks"
|
||||||
|
style="max-width:440px; width:100%">
|
||||||
|
<p class="muted" style="margin-top:6px">Archived tasks you can access.</p>
|
||||||
|
<div class="grid mt" id="archive-list"></div>
|
||||||
|
{{end}}
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
<a href="/messages" {{if eq .Active "messages"}}aria-current="page"{{end}}>Messages</a>
|
<a href="/messages" {{if eq .Active "messages"}}aria-current="page"{{end}}>Messages</a>
|
||||||
<a href="/metrics" {{if eq .Active "metrics"}}aria-current="page"{{end}}>Metrics</a>
|
<a href="/metrics" {{if eq .Active "metrics"}}aria-current="page"{{end}}>Metrics</a>
|
||||||
|
<a href="/archive" {{if eq .Active "archive"}}aria-current="page"{{end}}>Archive</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="right">
|
<div class="right">
|
||||||
|
|||||||
Reference in New Issue
Block a user