feat: template system — save/load request templates in DB; Use Template button; wizard uses ImportTemplate; save-as-template after import

This commit is contained in:
James 2026-03-17 15:21:50 -04:00
parent 4a7698ed20
commit cd2b67edd2
6 changed files with 442 additions and 34 deletions

View File

@ -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")

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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"`

View File

@ -24,6 +24,7 @@
<div class="ml-auto flex items-center gap-2 pb-2">
<span id="projectStatus" class="px-2 py-0.5 rounded text-xs font-medium"></span>
<button onclick="openCreateListModal()" class="px-3.5 py-1.5 bg-white/[0.07] hover:bg-white/[0.12] text-white rounded text-sm font-medium transition">+ New List</button>
<button onclick="openTemplateModal()" class="px-3.5 py-1.5 bg-white/[0.07] hover:bg-white/[0.12] text-white rounded text-sm font-medium transition">Use Template</button>
<button onclick="openImportModal()" class="px-3.5 py-1.5 bg-white/[0.07] hover:bg-white/[0.12] text-white rounded text-sm font-medium transition">Import List</button>
<button id="detailToggle" onclick="toggleDetailMode()" class="px-3.5 py-1.5 bg-white/[0.07] hover:bg-white/[0.12] text-[#cbd5e1] hover:text-white rounded text-sm font-medium transition">Summary View</button>
</div>
@ -72,6 +73,30 @@
</div>
</div>
<!-- Template Picker Modal -->
<div id="templateModal" style="display:none" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<div class="bg-[#0d1f3c] rounded-xl w-full max-w-lg border border-white/[0.08]">
<div class="flex items-center justify-between px-6 pt-5 pb-4 border-b border-white/[0.08]">
<h2 class="text-lg font-semibold text-white">Use a Template</h2>
<button onclick="closeTemplateModal()" class="text-[#94a3b8] hover:text-white text-2xl leading-none">&times;</button>
</div>
<div id="templateList" class="px-4 py-3 space-y-2 max-h-80 overflow-y-auto">
<div class="text-sm text-[#94a3b8]">Loading templates...</div>
</div>
<div class="px-6 py-4 border-t border-white/[0.08]">
<label class="block text-xs text-[#94a3b8] mb-1.5">List name (optional — uses template name by default)</label>
<input type="text" id="templateListName" placeholder="Leave blank to use template name"
class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c] mb-4"
onkeydown="if(event.key==='Enter')submitTemplateImport()">
<div id="templateImportError" class="hidden mb-3 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"></div>
<div class="flex gap-3">
<button onclick="closeTemplateModal()" class="flex-1 py-2.5 bg-white/[0.05] hover:bg-white/[0.08] text-white rounded-lg text-sm font-medium transition">Cancel</button>
<button id="templateImportBtn" onclick="submitTemplateImport()" class="flex-1 py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition">Add to Deal</button>
</div>
</div>
</div>
</div>
<!-- Create List Modal -->
<div id="createListModal" style="display:none" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<div class="bg-[#0d1f3c] rounded-xl p-6 w-full max-w-md border border-white/[0.08]">
@ -1354,32 +1379,10 @@
const selectedTemplates = [...document.querySelectorAll('.tmpl-cb:checked')].map(cb => cb.value).filter(v => v !== 'none');
for (const tmpl of selectedTemplates) {
try {
const tmplRes = await fetch('/templates/' + tmpl + '.json');
if (!tmplRes.ok) continue;
const tmplData = await tmplRes.json();
// Create a request list for this template
const listRes = await fetchAPI('/api/projects/' + newProjectID + '/requests', {
await fetchAPI('/api/projects/' + newProjectID + '/requests/import-template', {
method: 'POST',
body: JSON.stringify({ name: tmplData.name, project_id: newProjectID })
body: JSON.stringify({ template_id: tmpl })
});
const listData = await listRes.json();
if (!listRes.ok) continue;
const listId = listData.entry_id;
// Create requests under this list
for (const item of (tmplData.items || [])) {
await fetchAPI('/api/projects/' + newProjectID + '/requests/new', {
method: 'POST',
body: JSON.stringify({
parent_id: listId,
title: item.title,
item_number: item.item_number,
section: item.section,
priority: item.priority || 'medium',
status: 'open',
})
});
}
} catch(e) { console.error('Template import error:', tmpl, e); }
}
@ -1911,6 +1914,112 @@
}
}
// ---- Template picker ----
let selectedTemplateID = null;
async function openTemplateModal() {
selectedTemplateID = null;
document.getElementById('templateListName').value = '';
document.getElementById('templateImportError').classList.add('hidden');
document.getElementById('templateModal').style.display = 'flex';
const list = document.getElementById('templateList');
list.innerHTML = '<div class="text-sm text-[#94a3b8]">Loading...</div>';
try {
// Load saved DB templates
const res = await fetchAPI('/api/templates');
const saved = res.ok ? await res.json() : [];
// Built-in templates
const builtins = [
{ entry_id: 'core-8', name: 'Core Due Diligence', description: 'Essential 8 questions for any deal', item_count: 8, builtin: true },
{ entry_id: 'comprehensive-100', name: 'Comprehensive Due Diligence', description: '100+ questions across all workstreams', item_count: 104, builtin: true },
];
const all = [...builtins, ...saved];
if (all.length === 0) {
list.innerHTML = '<div class="text-sm text-[#94a3b8] py-4 text-center">No templates yet. Import a list and save it as a template.</div>';
return;
}
list.innerHTML = all.map(t => `
<label class="flex items-start gap-3 p-3 rounded-lg cursor-pointer hover:bg-white/[0.04] transition" style="border:1px solid var(--ds-bd)">
<input type="radio" name="tmpl" value="${t.entry_id}" class="mt-1 accent-[#c9a84c]" onchange="selectedTemplateID='${t.entry_id}'">
<div>
<div class="text-sm font-medium text-white">${escHtml(t.name)}</div>
${t.description ? `<div class="text-xs mt-0.5" style="color:var(--ds-tx2)">${escHtml(t.description)}</div>` : ''}
<div class="text-xs mt-0.5" style="color:var(--ds-tx3)">${t.item_count} items${t.builtin ? ' · Built-in' : ''}</div>
</div>
</label>`).join('');
} catch(e) {
list.innerHTML = '<div class="text-sm text-red-400">Failed to load templates.</div>';
}
}
function closeTemplateModal() {
document.getElementById('templateModal').style.display = 'none';
}
async function submitTemplateImport() {
if (!selectedTemplateID) {
document.getElementById('templateImportError').textContent = 'Please select a template.';
document.getElementById('templateImportError').classList.remove('hidden');
return;
}
const btn = document.getElementById('templateImportBtn');
btn.disabled = true; btn.textContent = 'Importing...';
document.getElementById('templateImportError').classList.add('hidden');
try {
const name = document.getElementById('templateListName').value.trim();
const res = await fetchAPI('/api/projects/' + projectID + '/requests/import-template', {
method: 'POST',
body: JSON.stringify({ template_id: selectedTemplateID, name })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Import failed');
closeTemplateModal();
await loadRequestTree();
} catch(e) {
document.getElementById('templateImportError').textContent = e.message;
document.getElementById('templateImportError').classList.remove('hidden');
} finally {
btn.disabled = false; btn.textContent = 'Add to Deal';
}
}
// Save an imported list as a template (called after import)
async function offerSaveTemplate(listId, name, prebuiltItems) {
try {
// Collect items from the just-imported list
let items = prebuiltItems;
if (!items) {
// Build items from treeData
const listItem = treeData.find(t => t.entry_id === listId);
if (!listItem) return;
items = [];
const sections = treeData.filter(t => t.parent_id === listId && t.type === 'section');
for (const sec of sections) {
const reqs = treeData.filter(t => t.parent_id === sec.entry_id && t.type === 'request');
for (const req of reqs) {
const d = req.data || {};
items.push({ section: (sec.data||{}).name || sec.entry_id, title: d.title || req.summary || '', priority: d.priority || 'medium', item_number: d.item_number || '' });
}
}
// Also flat requests directly under list
const flatReqs = treeData.filter(t => t.parent_id === listId && t.type === 'request');
for (const req of flatReqs) {
const d = req.data || {};
items.push({ section: '', title: d.title || req.summary || '', priority: d.priority || 'medium', item_number: d.item_number || '' });
}
}
await fetchAPI('/api/templates', {
method: 'POST',
body: JSON.stringify({ name, items })
});
} catch(e) { console.error('Failed to save template:', e); }
}
async function openImportModal() {
document.getElementById('importModal').style.display='flex';
document.getElementById('importFile').value='';
@ -1950,7 +2059,15 @@
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Import failed');
closeImportModal();
loadRequestTree();
await loadRequestTree();
// Offer to save as template
if (data && (data.total_items > 0 || data.list_id)) {
const listName = document.getElementById('importListName')?.value?.trim() || data.list_name || '';
const tmplName = prompt('Save this as a reusable template for future deals?\nEnter a name or click Cancel to skip:', listName);
if (tmplName && tmplName.trim()) {
await offerSaveTemplate(data.list_id, tmplName.trim(), data.items_for_template);
}
}
} catch(e) { errEl.textContent = e.message; errEl.classList.remove('hidden'); }
finally { btn.disabled = false; btn.textContent = 'Import'; }
}