From cd2b67edd25e8805e2197e240b25142035aad074 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 17 Mar 2026 15:21:50 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20template=20system=20=E2=80=94=20save/lo?= =?UTF-8?q?ad=20request=20templates=20in=20DB;=20Use=20Template=20button;?= =?UTF-8?q?=20wizard=20uses=20ImportTemplate;=20save-as-template=20after?= =?UTF-8?q?=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/handlers.go | 200 ++++++++++++++++++++++++++++++ api/routes.go | 3 + lib/dbcore.go | 58 +++++++++ lib/rbac.go | 14 +++ lib/types.go | 34 +++-- portal/templates/app/project.html | 167 +++++++++++++++++++++---- 6 files changed, 442 insertions(+), 34 deletions(-) diff --git a/api/handlers.go b/api/handlers.go index b3765f1..d0174e3 100644 --- a/api/handlers.go +++ b/api/handlers.go @@ -1966,6 +1966,206 @@ func (h *Handlers) ServeWebsiteSOC2(w http.ResponseWriter, r *http.Request) { // ListRequests handles GET /api/projects/{projectID}/requests // Returns all request entries for a project, sorted by section + item_number +// ListTemplates returns all saved request templates. +func (h *Handlers) ListTemplates(w http.ResponseWriter, r *http.Request) { + templates, err := lib.ListTemplates(h.DB, h.Cfg) + if err != nil { + ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to list templates") + return + } + result := []map[string]any{} + for _, t := range templates { + var data lib.RequestTemplateData + json.Unmarshal([]byte(t.DataText), &data) + result = append(result, map[string]any{ + "entry_id": t.EntryID, + "name": data.Name, + "description": data.Description, + "item_count": len(data.Items), + "created_at": t.CreatedAt, + }) + } + // Also include built-in disk templates as a fallback + JSONResponse(w, http.StatusOK, result) +} + +// SaveTemplate saves a request list as a reusable template. +func (h *Handlers) SaveTemplate(w http.ResponseWriter, r *http.Request) { + actorID := UserIDFromContext(r.Context()) + var body struct { + Name string `json:"name"` + Description string `json:"description"` + Items []lib.RequestTemplateItem `json:"items"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" { + ErrorResponse(w, http.StatusBadRequest, "invalid_body", "name and items required") + return + } + data := lib.RequestTemplateData{ + Name: strings.TrimSpace(body.Name), + Description: body.Description, + Items: body.Items, + } + dataJSON, _ := json.Marshal(data) + entry := &lib.Entry{ + ProjectID: lib.TemplateProjectID, + ParentID: lib.TemplateProjectID, + Type: lib.TypeRequestTemplate, + Depth: 1, + SortOrder: 0, + SummaryText: data.Name, + DataText: string(dataJSON), + Stage: lib.StageDataroom, + } + if err := lib.EntryWrite(h.DB, h.Cfg, actorID, entry); err != nil { + ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to save template") + return + } + JSONResponse(w, http.StatusOK, map[string]any{"entry_id": entry.EntryID, "name": data.Name}) +} + +// ImportTemplate imports a template (built-in or saved) into a project as a new request list. +func (h *Handlers) ImportTemplate(w http.ResponseWriter, r *http.Request) { + actorID := UserIDFromContext(r.Context()) + projectID := chi.URLParam(r, "projectID") + if err := lib.CheckAccessWrite(h.DB, actorID, projectID, ""); err != nil { + ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied") + return + } + + var body struct { + TemplateID string `json:"template_id"` // entry_id of saved template, or built-in name + Name string `json:"name"` // override list name (optional) + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.TemplateID == "" { + ErrorResponse(w, http.StatusBadRequest, "invalid_body", "template_id required") + return + } + + var tmplData lib.RequestTemplateData + + // Try loading from DB first + tmplEntry, err := lib.TemplateByID(h.DB, h.Cfg, body.TemplateID) + if err == nil && tmplEntry != nil { + json.Unmarshal([]byte(tmplEntry.DataText), &tmplData) + } else { + // Fall back to disk templates + dirs := []string{"templates", "/opt/dealspace/templates"} + loaded := false + for _, dir := range dirs { + fp := dir + "/" + body.TemplateID + ".json" + if data, err := os.ReadFile(fp); err == nil { + json.Unmarshal(data, &tmplData) + loaded = true + break + } + } + if !loaded { + ErrorResponse(w, http.StatusNotFound, "not_found", "Template not found") + return + } + } + + listName := strings.TrimSpace(body.Name) + if listName == "" { + listName = tmplData.Name + } + + // Create the request list + siblings, _ := lib.EntriesByParent(h.DB, h.Cfg, projectID) + listCount := 0 + for _, s := range siblings { + if s.Type == lib.TypeRequestList { + listCount++ + } + } + rlData := lib.RequestListData{Name: listName} + rlDataJSON, _ := json.Marshal(rlData) + listEntry := &lib.Entry{ + ProjectID: projectID, + ParentID: projectID, + Type: lib.TypeRequestList, + Depth: 1, + SortOrder: (listCount + 1) * 1000, + SummaryText: listName, + DataText: string(rlDataJSON), + Stage: lib.StagePreDataroom, + } + if err := lib.EntryWrite(h.DB, h.Cfg, actorID, listEntry); err != nil { + ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create request list") + return + } + + // Group items by section, create section entries then requests + type sectionGroup struct { + name string + items []lib.RequestTemplateItem + } + var sections []sectionGroup + sectionIndex := map[string]int{} + for _, item := range tmplData.Items { + sec := item.Section + if sec == "" { + sec = "General" + } + if idx, ok := sectionIndex[sec]; ok { + sections[idx].items = append(sections[idx].items, item) + } else { + sectionIndex[sec] = len(sections) + sections = append(sections, sectionGroup{name: sec, items: []lib.RequestTemplateItem{item}}) + } + } + + for si, sg := range sections { + secData := lib.SectionData{Name: sg.name} + secDataJSON, _ := json.Marshal(secData) + secEntry := &lib.Entry{ + ProjectID: projectID, + ParentID: listEntry.EntryID, + Type: lib.TypeSection, + Depth: 2, + SortOrder: (si + 1) * 1000, + SummaryText: sg.name, + DataText: string(secDataJSON), + Stage: lib.StagePreDataroom, + } + if err := lib.EntryWrite(h.DB, h.Cfg, actorID, secEntry); err != nil { + continue + } + for ri, item := range sg.items { + reqData := lib.RequestData{ + Title: item.Title, + ItemNumber: item.ItemNumber, + Section: sg.name, + Priority: item.Priority, + Status: "open", + } + if reqData.Priority == "" { + reqData.Priority = "medium" + } + reqDataJSON, _ := json.Marshal(reqData) + reqEntry := &lib.Entry{ + ProjectID: projectID, + ParentID: secEntry.EntryID, + Type: lib.TypeRequest, + Depth: 3, + SortOrder: (ri + 1) * 100, + SummaryText: item.Title, + DataText: string(reqDataJSON), + Stage: lib.StagePreDataroom, + } + lib.EntryWrite(h.DB, h.Cfg, actorID, reqEntry) + } + } + + JSONResponse(w, http.StatusOK, map[string]any{ + "entry_id": listEntry.EntryID, + "name": listName, + "sections": len(sections), + "items": len(tmplData.Items), + }) +} + func (h *Handlers) ListRequests(w http.ResponseWriter, r *http.Request) { actorID := UserIDFromContext(r.Context()) projectID := chi.URLParam(r, "projectID") diff --git a/api/routes.go b/api/routes.go index d6a49db..bbf2c60 100644 --- a/api/routes.go +++ b/api/routes.go @@ -75,6 +75,9 @@ func NewRouter(db *lib.DB, cfg *lib.Config, store lib.ObjectStore, websiteFS fs. r.Post("/projects/{projectID}/requests/new", h.CreateRequest) r.Post("/projects/{projectID}/sections", h.CreateSection) r.Post("/projects/{projectID}/requests/import", h.ImportRequests) + r.Post("/projects/{projectID}/requests/import-template", h.ImportTemplate) + r.Get("/templates", h.ListTemplates) + r.Post("/templates", h.SaveTemplate) // Request detail r.Get("/requests/{requestID}", h.GetRequestDetail) diff --git a/lib/dbcore.go b/lib/dbcore.go index 49fc1e0..2442d01 100644 --- a/lib/dbcore.go +++ b/lib/dbcore.go @@ -414,6 +414,25 @@ func unpackEntry(cfg *Config, e *Entry) error { return nil } +// unpackEntryWithKey decrypts an entry using a pre-derived key (used for templates). +func unpackEntryWithKey(key []byte, e *Entry) error { + if len(e.Summary) > 0 { + text, err := Unpack(key, e.Summary) + if err != nil { + return err + } + e.SummaryText = text + } + if len(e.Data) > 0 { + text, err := Unpack(key, e.Data) + if err != nil { + return err + } + e.DataText = text + } + return nil +} + // --------------------------------------------------------------------------- // User operations // --------------------------------------------------------------------------- @@ -1102,6 +1121,45 @@ func OAuthTokenRevoke(db *DB, tokenStr string) error { } // SetSessionTestRole updates the test_role on a session (super-admin only). +// ListTemplates returns all global request templates. +func ListTemplates(db *DB, cfg *Config) ([]Entry, error) { + filter := EntryFilter{ + ProjectID: TemplateProjectID, + Type: TypeRequestTemplate, + } + entries, err := entryQuery(db, filter) + if err != nil { + return nil, err + } + // Use system key for template project + templateKey, err := DeriveProjectKey(cfg.MasterKey, TemplateProjectID) + if err != nil { + return nil, err + } + for i := range entries { + if err := unpackEntryWithKey(templateKey, &entries[i]); err != nil { + continue + } + } + return entries, nil +} + +// TemplateByID returns a single request template by entry_id. +func TemplateByID(db *DB, cfg *Config, entryID string) (*Entry, error) { + e, err := entryReadSystem(db, entryID) + if err != nil || e == nil { + return e, err + } + templateKey, err := DeriveProjectKey(cfg.MasterKey, TemplateProjectID) + if err != nil { + return nil, err + } + if err := unpackEntryWithKey(templateKey, e); err != nil { + return nil, err + } + return e, nil +} + func SetSessionTestRole(db *DB, sessionID, role string) error { _, err := db.Conn.Exec(`UPDATE sessions SET test_role = ? WHERE id = ?`, role, sessionID) return err diff --git a/lib/rbac.go b/lib/rbac.go index 14fc77b..ccf6903 100644 --- a/lib/rbac.go +++ b/lib/rbac.go @@ -11,6 +11,10 @@ var ( ErrInsufficientOps = errors.New("insufficient permissions") ) +// TemplateProjectID is the well-known project_id for global request templates. +// All authenticated users have full rwdm access to this "project". +const TemplateProjectID = "00000000-0000-0000-0000-000000000000" + // CheckAccess verifies that actorID has the required operation on the given project/entry. // op is one of "r", "w", "d", "m" (read, write, delete, manage). // Returns the matching Access grant or an error. @@ -29,6 +33,16 @@ func CheckAccess(db *DB, actorID, projectID string, workstreamID string, op stri }, nil } + // Template project — all authenticated users have full access + if projectID == TemplateProjectID && actorID != "" { + return &Access{ + UserID: actorID, + ProjectID: TemplateProjectID, + Role: RoleIBMember, + Ops: "rwdm", + }, nil + } + grants, err := getUserAccess(db, actorID, projectID) if err != nil { return nil, err diff --git a/lib/types.go b/lib/types.go index 2e822b5..3c3cd20 100644 --- a/lib/types.go +++ b/lib/types.go @@ -43,15 +43,16 @@ type Entry struct { // Entry types const ( - TypeProject = "project" - TypeWorkstream = "workstream" - TypeRequestList = "request_list" - TypeRequest = "request" - TypeAnswer = "answer" - TypeSection = "section" - TypeComment = "comment" - TypeOrganization = "organization" - TypeDealOrg = "deal_org" + TypeProject = "project" + TypeWorkstream = "workstream" + TypeRequestList = "request_list" + TypeRequest = "request" + TypeAnswer = "answer" + TypeSection = "section" + TypeComment = "comment" + TypeOrganization = "organization" + TypeDealOrg = "deal_org" + TypeRequestTemplate = "request_template" // global template stored under TemplateProjectID ) // Stages @@ -300,6 +301,21 @@ type RequestListData struct { VisibilityOrgID string `json:"visibility_org_id,omitempty"` // restrict visibility to this org (+ IB) } +// RequestTemplateData is the JSON structure packed into a request_template entry's Data field. +type RequestTemplateData struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Items []RequestTemplateItem `json:"items"` +} + +// RequestTemplateItem is a single item in a request template. +type RequestTemplateItem struct { + Section string `json:"section"` + ItemNumber string `json:"item_number,omitempty"` + Title string `json:"title"` + Priority string `json:"priority,omitempty"` +} + // WorkstreamData is the JSON structure packed into a workstream entry's Data field. type WorkstreamData struct { Name string `json:"name"` diff --git a/portal/templates/app/project.html b/portal/templates/app/project.html index e51a3bb..9a63c8a 100644 --- a/portal/templates/app/project.html +++ b/portal/templates/app/project.html @@ -24,6 +24,7 @@
+
@@ -72,6 +73,30 @@ + + +