package api import ( "crypto/rand" "encoding/hex" "encoding/json" "html/template" "io" "net/http" "os" "path/filepath" "strings" "time" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/mish/dealspace/lib" "golang.org/x/crypto/bcrypt" ) // Handlers holds dependencies for HTTP handlers. type Handlers struct { DB *lib.DB Cfg *lib.Config Store lib.ObjectStore } // NewHandlers creates a new Handlers instance. func NewHandlers(db *lib.DB, cfg *lib.Config, store lib.ObjectStore) *Handlers { return &Handlers{DB: db, Cfg: cfg, Store: store} } // Health returns server status. func (h *Handlers) Health(w http.ResponseWriter, r *http.Request) { JSONResponse(w, http.StatusOK, map[string]string{"status": "ok"}) } // ListEntries returns entries for a project, filtered by query params. func (h *Handlers) ListEntries(w http.ResponseWriter, r *http.Request) { actorID := UserIDFromContext(r.Context()) projectID := chi.URLParam(r, "projectID") if projectID == "" { ErrorResponse(w, http.StatusBadRequest, "missing_project", "Project ID required") return } filter := lib.EntryFilter{ ProjectID: projectID, Type: r.URL.Query().Get("type"), Stage: r.URL.Query().Get("stage"), } if parent := r.URL.Query().Get("parent_id"); parent != "" { filter.ParentID = &parent } entries, err := lib.EntryRead(h.DB, h.Cfg, actorID, projectID, filter) if err != nil { if err == lib.ErrAccessDenied { ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied") return } ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to read entries") return } JSONResponse(w, http.StatusOK, entries) } // CreateEntry creates a new entry. func (h *Handlers) CreateEntry(w http.ResponseWriter, r *http.Request) { actorID := UserIDFromContext(r.Context()) var req struct { ProjectID string `json:"project_id"` ParentID string `json:"parent_id"` Type string `json:"type"` Depth int `json:"depth"` Summary string `json:"summary"` Data string `json:"data"` Stage string `json:"stage"` AssigneeID string `json:"assignee_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") return } entry := &lib.Entry{ ProjectID: req.ProjectID, ParentID: req.ParentID, Type: req.Type, Depth: req.Depth, SummaryText: req.Summary, DataText: req.Data, Stage: req.Stage, AssigneeID: req.AssigneeID, } if entry.Stage == "" { entry.Stage = lib.StagePreDataroom } if err := lib.EntryWrite(h.DB, h.Cfg, actorID, entry); err != nil { if err == lib.ErrAccessDenied { ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied") return } ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create entry") return } JSONResponse(w, http.StatusCreated, entry) } // UpdateEntry updates an existing entry. func (h *Handlers) UpdateEntry(w http.ResponseWriter, r *http.Request) { actorID := UserIDFromContext(r.Context()) entryID := chi.URLParam(r, "entryID") var req struct { ProjectID string `json:"project_id"` ParentID string `json:"parent_id"` Type string `json:"type"` Depth int `json:"depth"` Summary string `json:"summary"` Data string `json:"data"` Stage string `json:"stage"` AssigneeID string `json:"assignee_id"` Version int `json:"version"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") return } entry := &lib.Entry{ EntryID: entryID, ProjectID: req.ProjectID, ParentID: req.ParentID, Type: req.Type, Depth: req.Depth, SummaryText: req.Summary, DataText: req.Data, Stage: req.Stage, AssigneeID: req.AssigneeID, Version: req.Version, } if err := lib.EntryWrite(h.DB, h.Cfg, actorID, entry); err != nil { if err == lib.ErrAccessDenied { ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied") return } if err == lib.ErrVersionConflict { ErrorResponse(w, http.StatusConflict, "version_conflict", err.Error()) return } ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to update entry") return } JSONResponse(w, http.StatusOK, entry) } // DeleteEntry soft-deletes an entry. func (h *Handlers) DeleteEntry(w http.ResponseWriter, r *http.Request) { actorID := UserIDFromContext(r.Context()) entryID := chi.URLParam(r, "entryID") projectID := chi.URLParam(r, "projectID") if err := lib.EntryDelete(h.DB, actorID, projectID, entryID); err != nil { if err == lib.ErrAccessDenied { ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied") return } if err == lib.ErrNotFound { ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found") return } ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to delete entry") return } JSONResponse(w, http.StatusOK, map[string]string{"status": "deleted"}) } // GetMyTasks returns entries assigned to the current user. func (h *Handlers) GetMyTasks(w http.ResponseWriter, r *http.Request) { actorID := UserIDFromContext(r.Context()) projectID := chi.URLParam(r, "projectID") filter := lib.EntryFilter{ ProjectID: projectID, AssigneeID: actorID, } entries, err := lib.EntryRead(h.DB, h.Cfg, actorID, projectID, filter) if err != nil { if err == lib.ErrAccessDenied { ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied") return } ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to read tasks") return } JSONResponse(w, http.StatusOK, entries) } // --------------------------------------------------------------------------- // Auth API endpoints // --------------------------------------------------------------------------- // Login handles POST /api/auth/login func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) { var req struct { Email string `json:"email"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") return } req.Email = strings.TrimSpace(strings.ToLower(req.Email)) if req.Email == "" || req.Password == "" { ErrorResponse(w, http.StatusBadRequest, "missing_fields", "Email and password required") return } user, err := lib.UserByEmail(h.DB, req.Email) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "internal", "Login failed") return } if user == nil { ErrorResponse(w, http.StatusUnauthorized, "invalid_credentials", "Invalid email or password") return } if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil { ErrorResponse(w, http.StatusUnauthorized, "invalid_credentials", "Invalid email or password") return } // Revoke existing sessions _ = lib.SessionRevokeAllForUser(h.DB, user.UserID) // Create session sessionID := generateToken() now := time.Now().UnixMilli() session := &lib.Session{ ID: sessionID, UserID: user.UserID, Fingerprint: r.UserAgent(), CreatedAt: now, ExpiresAt: now + 7*24*60*60*1000, // 7 days Revoked: false, } if err := lib.SessionCreate(h.DB, session); err != nil { ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create session") return } // Create JWT (1 hour) token, err := createJWT(user.UserID, sessionID, h.Cfg.JWTSecret, 3600) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create token") return } JSONResponse(w, http.StatusOK, map[string]any{ "token": token, "user": map[string]string{ "id": user.UserID, "name": user.Name, "email": user.Email, "role": "ib_admin", // simplified for now }, }) } // Logout handles POST /api/auth/logout func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) { auth := r.Header.Get("Authorization") if strings.HasPrefix(auth, "Bearer ") { token := strings.TrimPrefix(auth, "Bearer ") claims, err := validateJWT(token, h.Cfg.JWTSecret) if err == nil { _ = lib.SessionRevoke(h.DB, claims.SessionID) } } JSONResponse(w, http.StatusOK, map[string]string{"status": "ok"}) } // Me handles GET /api/auth/me func (h *Handlers) Me(w http.ResponseWriter, r *http.Request) { actorID := UserIDFromContext(r.Context()) user, err := lib.UserByID(h.DB, actorID) if err != nil || user == nil { ErrorResponse(w, http.StatusUnauthorized, "invalid_session", "User not found") return } JSONResponse(w, http.StatusOK, map[string]string{ "id": user.UserID, "name": user.Name, "email": user.Email, }) } // Setup handles POST /api/setup (first-run admin creation) func (h *Handlers) Setup(w http.ResponseWriter, r *http.Request) { count, err := lib.UserCount(h.DB) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to check users") return } if count > 0 { ErrorResponse(w, http.StatusForbidden, "setup_complete", "Setup already completed") return } var req struct { Name string `json:"name"` Email string `json:"email"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") return } req.Email = strings.TrimSpace(strings.ToLower(req.Email)) if req.Name == "" || req.Email == "" || req.Password == "" { ErrorResponse(w, http.StatusBadRequest, "missing_fields", "Name, email, and password required") return } if len(req.Password) < 8 { ErrorResponse(w, http.StatusBadRequest, "weak_password", "Password must be at least 8 characters") return } hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to hash password") return } now := time.Now().UnixMilli() user := &lib.User{ UserID: uuid.New().String(), Email: req.Email, Name: req.Name, Password: string(hashed), OrgID: "admin", OrgName: "Dealspace", Active: true, CreatedAt: now, UpdatedAt: now, } if err := lib.UserCreate(h.DB, user); err != nil { ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create user") return } JSONResponse(w, http.StatusCreated, map[string]string{ "status": "ok", "user_id": user.UserID, "message": "Admin account created. You can now log in.", }) } // GetAllTasks handles GET /api/tasks (all tasks for current user across all projects) func (h *Handlers) GetAllTasks(w http.ResponseWriter, r *http.Request) { actorID := UserIDFromContext(r.Context()) entries, err := lib.TasksByUser(h.DB, h.Cfg, actorID) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to read tasks") return } if entries == nil { entries = []lib.Entry{} } JSONResponse(w, http.StatusOK, entries) } // GetAllProjects handles GET /api/projects (all projects current user has access to) func (h *Handlers) GetAllProjects(w http.ResponseWriter, r *http.Request) { actorID := UserIDFromContext(r.Context()) entries, err := lib.ProjectsByUser(h.DB, h.Cfg, actorID) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to read projects") return } if entries == nil { entries = []lib.Entry{} } JSONResponse(w, http.StatusOK, entries) } // CreateProject handles POST /api/projects func (h *Handlers) CreateProject(w http.ResponseWriter, r *http.Request) { actorID := UserIDFromContext(r.Context()) var req struct { Name string `json:"name"` DealType string `json:"deal_type"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") return } if req.Name == "" { ErrorResponse(w, http.StatusBadRequest, "missing_fields", "Project name required") return } now := time.Now().UnixMilli() projectID := uuid.New().String() dataJSON := `{"name":"` + req.Name + `","deal_type":"` + req.DealType + `","status":"active"}` entry := &lib.Entry{ ProjectID: projectID, Type: lib.TypeProject, Depth: 0, SummaryText: req.Name, DataText: dataJSON, Stage: lib.StagePreDataroom, } entry.EntryID = projectID entry.CreatedBy = actorID entry.CreatedAt = now entry.UpdatedAt = now entry.Version = 1 entry.KeyVersion = 1 // Pack encrypted fields key, err := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "internal", "Key derivation failed") return } summary, err := lib.Pack(key, entry.SummaryText) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "internal", "Encryption failed") return } data, err := lib.Pack(key, entry.DataText) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "internal", "Encryption failed") return } entry.Summary = summary entry.Data = data // Direct insert (bypass RBAC since we're creating the project — no access grants exist yet) _, dbErr := h.DB.Conn.Exec( `INSERT INTO entries (entry_id, project_id, parent_id, type, depth, search_key, search_key2, summary, data, stage, assignee_id, return_to_id, origin_id, version, deleted_at, deleted_by, key_version, created_at, updated_at, created_by) VALUES (?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`, entry.EntryID, entry.ProjectID, "", entry.Type, entry.Depth, entry.SearchKey, entry.SearchKey2, entry.Summary, entry.Data, entry.Stage, "", "", "", entry.Version, nil, nil, entry.KeyVersion, entry.CreatedAt, entry.UpdatedAt, entry.CreatedBy, ) if dbErr != nil { ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create project") return } // Grant ib_admin access to the creator access := &lib.Access{ ID: uuid.New().String(), ProjectID: projectID, UserID: actorID, Role: lib.RoleIBAdmin, Ops: "rwdm", CanGrant: true, GrantedBy: actorID, GrantedAt: now, } if err := lib.AccessGrant(h.DB, access); err != nil { ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to grant access") return } JSONResponse(w, http.StatusCreated, map[string]string{ "project_id": projectID, "name": req.Name, }) } // GetProjectDetail handles GET /api/projects/{projectID} func (h *Handlers) GetProjectDetail(w http.ResponseWriter, r *http.Request) { actorID := UserIDFromContext(r.Context()) projectID := chi.URLParam(r, "projectID") // Verify access if err := lib.CheckAccessRead(h.DB, actorID, projectID, ""); err != nil { ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied") return } project, err := lib.EntryByID(h.DB, h.Cfg, projectID) if err != nil || project == nil { ErrorResponse(w, http.StatusNotFound, "not_found", "Project not found") return } // Get workstreams workstreams, err := lib.EntriesByParent(h.DB, h.Cfg, projectID) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to read workstreams") return } JSONResponse(w, http.StatusOK, map[string]any{ "project": project, "workstreams": workstreams, }) } // CreateWorkstream handles POST /api/projects/{projectID}/workstreams func (h *Handlers) CreateWorkstream(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 req struct { Name string `json:"name"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") return } if req.Name == "" { ErrorResponse(w, http.StatusBadRequest, "missing_fields", "Name required") return } entry := &lib.Entry{ ProjectID: projectID, ParentID: projectID, Type: lib.TypeWorkstream, Depth: 1, SummaryText: req.Name, DataText: `{"name":"` + req.Name + `"}`, Stage: lib.StagePreDataroom, } if err := lib.EntryWrite(h.DB, h.Cfg, actorID, entry); err != nil { ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create workstream") return } JSONResponse(w, http.StatusCreated, entry) } // UploadObject handles POST /api/projects/{projectID}/objects (file upload) func (h *Handlers) UploadObject(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 } r.Body = http.MaxBytesReader(w, r.Body, 50<<20) // 50MB max if err := r.ParseMultipartForm(50 << 20); err != nil { ErrorResponse(w, http.StatusBadRequest, "file_too_large", "File too large (max 50MB)") return } file, header, err := r.FormFile("file") if err != nil { ErrorResponse(w, http.StatusBadRequest, "missing_file", "No file provided") return } defer file.Close() data, err := io.ReadAll(file) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to read file") return } objectID, err := h.Store.Write(projectID, data) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to store file") return } JSONResponse(w, http.StatusCreated, map[string]string{ "object_id": objectID, "filename": header.Filename, "size": json.Number(strings.TrimRight(strings.TrimRight(json.Number("0").String(), "0"), ".")).String(), }) } // DownloadObject handles GET /api/projects/{projectID}/objects/{objectID} func (h *Handlers) DownloadObject(w http.ResponseWriter, r *http.Request) { actorID := UserIDFromContext(r.Context()) projectID := chi.URLParam(r, "projectID") objectID := chi.URLParam(r, "objectID") if err := lib.CheckAccessRead(h.DB, actorID, projectID, ""); err != nil { ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied") return } data, err := h.Store.Read(projectID, objectID) if err != nil { ErrorResponse(w, http.StatusNotFound, "not_found", "Object not found") return } user, _ := lib.UserByID(h.DB, actorID) userName := "Unknown" if user != nil { userName = user.Name } // Add watermark header for PDFs filename := r.URL.Query().Get("filename") if filename == "" { filename = objectID } w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"") w.Header().Set("X-Watermark", userName+" - "+time.Now().Format("2006-01-02 15:04")+" - CONFIDENTIAL") w.Header().Set("Content-Type", "application/octet-stream") w.Write(data) } // --------------------------------------------------------------------------- // Template serving handlers // --------------------------------------------------------------------------- func (h *Handlers) serveTemplate(w http.ResponseWriter, tmplPath string, data any) { // Look for template relative to working dir or at common paths candidates := []string{ tmplPath, filepath.Join("portal/templates", tmplPath), filepath.Join("/opt/dealspace/portal/templates", tmplPath), } var tmpl *template.Template var err error for _, p := range candidates { if _, statErr := os.Stat(p); statErr == nil { tmpl, err = template.ParseFiles(p) if err == nil { break } } } if tmpl == nil { http.Error(w, "Template not found: "+tmplPath, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") tmpl.Execute(w, data) } // ServeLogin serves the login page func (h *Handlers) ServeLogin(w http.ResponseWriter, r *http.Request) { h.serveTemplate(w, "auth/login.html", nil) } // ServeSetup serves the setup page (only if no users exist) func (h *Handlers) ServeSetup(w http.ResponseWriter, r *http.Request) { count, _ := lib.UserCount(h.DB) if count > 0 { http.Redirect(w, r, "/app/login", http.StatusFound) return } h.serveTemplate(w, "auth/setup.html", nil) } // ServeAppTasks serves the tasks page func (h *Handlers) ServeAppTasks(w http.ResponseWriter, r *http.Request) { h.serveTemplate(w, "app/tasks.html", nil) } // ServeAppProjects serves the projects page func (h *Handlers) ServeAppProjects(w http.ResponseWriter, r *http.Request) { h.serveTemplate(w, "app/projects.html", nil) } // ServeAppProject serves a single project page func (h *Handlers) ServeAppProject(w http.ResponseWriter, r *http.Request) { h.serveTemplate(w, "app/project.html", nil) } // ServeAppRequest serves a request detail page func (h *Handlers) ServeAppRequest(w http.ResponseWriter, r *http.Request) { h.serveTemplate(w, "app/request.html", nil) } // GetRequestDetail handles GET /api/requests/{requestID} func (h *Handlers) GetRequestDetail(w http.ResponseWriter, r *http.Request) { actorID := UserIDFromContext(r.Context()) requestID := chi.URLParam(r, "requestID") entry, err := lib.EntryByID(h.DB, h.Cfg, requestID) if err != nil || entry == nil { ErrorResponse(w, http.StatusNotFound, "not_found", "Request not found") return } // Check access if err := lib.CheckAccessRead(h.DB, actorID, entry.ProjectID, ""); err != nil { ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied") return } // Get children (answers, comments) children, err := lib.EntriesByParent(h.DB, h.Cfg, requestID) if err != nil { children = []lib.Entry{} } JSONResponse(w, http.StatusOK, map[string]any{ "request": entry, "children": children, }) } func generateToken() string { b := make([]byte, 32) rand.Read(b) return hex.EncodeToString(b) }