diff --git a/api/integration_test.go b/api/integration_test.go
index dad2628..44cd4d6 100644
--- a/api/integration_test.go
+++ b/api/integration_test.go
@@ -472,3 +472,298 @@ func TestChallengeVerifyFlow(t *testing.T) {
}
resp.Body.Close()
}
+
+// ---- Additional integration tests ----
+
+func TestFullDealWorkflow(t *testing.T) {
+ tmpFile, err := os.CreateTemp("", "dealspace-deal-workflow-*.db")
+ if err != nil {
+ t.Fatalf("create temp file: %v", err)
+ }
+ tmpFile.Close()
+ defer os.Remove(tmpFile.Name())
+
+ db, err := lib.OpenDB(tmpFile.Name(), "../migrations/001_initial.sql")
+ if err != nil {
+ t.Fatalf("OpenDB: %v", err)
+ }
+ defer db.Close()
+
+ masterKey := make([]byte, 32)
+ for i := range masterKey {
+ masterKey[i] = byte(i)
+ }
+ jwtSecret := []byte("test-jwt-secret-32-bytes-long!!")
+
+ cfg := &lib.Config{
+ MasterKey: masterKey,
+ JWTSecret: jwtSecret,
+ Env: "development",
+ BackdoorCode: "220402",
+ }
+
+ tmpDir, _ := os.MkdirTemp("", "dealspace-deal-workflow-store")
+ defer os.RemoveAll(tmpDir)
+ store, _ := lib.NewLocalStore(tmpDir)
+
+ router := NewRouter(db, cfg, store, nil, nil)
+ server := httptest.NewServer(router)
+ defer server.Close()
+
+ client := &http.Client{}
+
+ // Step 1: Create IB admin user
+ t.Log("Step 1: Create IB admin user")
+ setupTestUser(t, db, cfg, "ibadmin@workflow.com", "IB Admin")
+ ibToken := loginWithBackdoor(t, client, server.URL, "ibadmin@workflow.com")
+
+ // Step 2: Create a project
+ t.Log("Step 2: Create project")
+ projectBody, _ := json.Marshal(map[string]string{
+ "name": "Acme Acquisition",
+ "deal_type": "M&A",
+ })
+ req, _ := http.NewRequest("POST", server.URL+"/api/projects", bytes.NewReader(projectBody))
+ req.Header.Set("Authorization", "Bearer "+ibToken)
+ req.Header.Set("Content-Type", "application/json")
+ resp, _ := client.Do(req)
+ if resp.StatusCode != http.StatusCreated {
+ t.Fatalf("create project expected 201, got %d", resp.StatusCode)
+ }
+ var projectResp map[string]interface{}
+ json.NewDecoder(resp.Body).Decode(&projectResp)
+ resp.Body.Close()
+ projectID := projectResp["project_id"].(string)
+ t.Logf("Created project: %s", projectID)
+
+ // Step 3: Create an organization
+ t.Log("Step 3: Create organization")
+ orgBody, _ := json.Marshal(map[string]interface{}{
+ "name": "Acme Corp",
+ "domains": []string{"acme.com", "acmecorp.com"},
+ "role": "seller",
+ })
+ req, _ = http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
+ req.Header.Set("Authorization", "Bearer "+ibToken)
+ req.Header.Set("Content-Type", "application/json")
+ resp, _ = client.Do(req)
+ if resp.StatusCode != http.StatusCreated {
+ var errResp map[string]string
+ json.NewDecoder(resp.Body).Decode(&errResp)
+ t.Fatalf("create org expected 201, got %d: %v", resp.StatusCode, errResp)
+ }
+ var orgResp map[string]interface{}
+ json.NewDecoder(resp.Body).Decode(&orgResp)
+ resp.Body.Close()
+ orgID := orgResp["entry_id"].(string)
+ t.Logf("Created org: %s", orgID)
+
+ // Step 4: Link org to project (deal_org)
+ t.Log("Step 4: Link org to project")
+ dealOrgBody, _ := json.Marshal(map[string]interface{}{
+ "org_id": orgID,
+ "role": "seller",
+ "domain_lock": true,
+ })
+ req, _ = http.NewRequest("POST", server.URL+"/api/projects/"+projectID+"/orgs", bytes.NewReader(dealOrgBody))
+ req.Header.Set("Authorization", "Bearer "+ibToken)
+ req.Header.Set("Content-Type", "application/json")
+ resp, _ = client.Do(req)
+ if resp.StatusCode != http.StatusCreated {
+ var errResp map[string]string
+ json.NewDecoder(resp.Body).Decode(&errResp)
+ t.Fatalf("create deal_org expected 201, got %d: %v", resp.StatusCode, errResp)
+ }
+ var dealOrgResp map[string]interface{}
+ json.NewDecoder(resp.Body).Decode(&dealOrgResp)
+ resp.Body.Close()
+ t.Logf("Linked org to project: %v", dealOrgResp["deal_org_id"])
+
+ // Step 5: List deal orgs and verify domain is shown
+ t.Log("Step 5: List deal orgs")
+ req, _ = http.NewRequest("GET", server.URL+"/api/projects/"+projectID+"/orgs", nil)
+ req.Header.Set("Authorization", "Bearer "+ibToken)
+ resp, _ = client.Do(req)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("list deal orgs expected 200, got %d", resp.StatusCode)
+ }
+ var dealOrgs []map[string]interface{}
+ json.NewDecoder(resp.Body).Decode(&dealOrgs)
+ resp.Body.Close()
+
+ if len(dealOrgs) != 1 {
+ t.Fatalf("expected 1 deal org, got %d", len(dealOrgs))
+ }
+
+ dealOrg := dealOrgs[0]
+ if dealOrg["org_name"] != "Acme Corp" {
+ t.Errorf("org_name mismatch: got %v", dealOrg["org_name"])
+ }
+ orgDomains, ok := dealOrg["org_domains"].([]interface{})
+ if !ok || len(orgDomains) != 2 {
+ t.Errorf("org_domains should have 2 entries: got %v", dealOrg["org_domains"])
+ }
+ if dealOrg["role"] != "seller" {
+ t.Errorf("role mismatch: got %v", dealOrg["role"])
+ }
+ if dealOrg["domain_lock"] != true {
+ t.Errorf("domain_lock should be true: got %v", dealOrg["domain_lock"])
+ }
+
+ t.Log("Full deal workflow test passed!")
+}
+
+func TestSuperAdminSeeAllProjects(t *testing.T) {
+ tmpFile, err := os.CreateTemp("", "dealspace-superadmin-projects-*.db")
+ if err != nil {
+ t.Fatalf("create temp file: %v", err)
+ }
+ tmpFile.Close()
+ defer os.Remove(tmpFile.Name())
+
+ db, err := lib.OpenDB(tmpFile.Name(), "../migrations/001_initial.sql")
+ if err != nil {
+ t.Fatalf("OpenDB: %v", err)
+ }
+ defer db.Close()
+
+ masterKey := make([]byte, 32)
+ for i := range masterKey {
+ masterKey[i] = byte(i)
+ }
+ jwtSecret := []byte("test-jwt-secret-32-bytes-long!!")
+
+ cfg := &lib.Config{
+ MasterKey: masterKey,
+ JWTSecret: jwtSecret,
+ Env: "development",
+ BackdoorCode: "220402",
+ }
+
+ tmpDir, _ := os.MkdirTemp("", "dealspace-superadmin-store")
+ defer os.RemoveAll(tmpDir)
+ store, _ := lib.NewLocalStore(tmpDir)
+
+ router := NewRouter(db, cfg, store, nil, nil)
+ server := httptest.NewServer(router)
+ defer server.Close()
+
+ client := &http.Client{}
+ now := time.Now().UnixMilli()
+
+ // Create User A and their project
+ t.Log("Create User A and Project A")
+ setupTestUser(t, db, cfg, "usera@test.com", "User A")
+ userAToken := loginWithBackdoor(t, client, server.URL, "usera@test.com")
+
+ projectABody, _ := json.Marshal(map[string]string{"name": "Project A"})
+ req, _ := http.NewRequest("POST", server.URL+"/api/projects", bytes.NewReader(projectABody))
+ req.Header.Set("Authorization", "Bearer "+userAToken)
+ req.Header.Set("Content-Type", "application/json")
+ resp, _ := client.Do(req)
+ resp.Body.Close()
+
+ // Create User B and their project
+ t.Log("Create User B and Project B")
+ setupTestUser(t, db, cfg, "userb@test.com", "User B")
+ userBToken := loginWithBackdoor(t, client, server.URL, "userb@test.com")
+
+ projectBBody, _ := json.Marshal(map[string]string{"name": "Project B"})
+ req, _ = http.NewRequest("POST", server.URL+"/api/projects", bytes.NewReader(projectBBody))
+ req.Header.Set("Authorization", "Bearer "+userBToken)
+ req.Header.Set("Content-Type", "application/json")
+ resp, _ = client.Do(req)
+ resp.Body.Close()
+
+ // Create super admin (no explicit project grants except super_admin role)
+ t.Log("Create Super Admin")
+ saID := uuid.New().String()
+ saUser := &lib.User{
+ UserID: saID,
+ Email: "superadmin@test.com",
+ Name: "Super Admin",
+ Password: "",
+ Active: true,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ lib.UserCreate(db, saUser)
+ lib.AccessGrant(db, &lib.Access{
+ ID: uuid.New().String(),
+ ProjectID: "global",
+ UserID: saID,
+ Role: lib.RoleSuperAdmin,
+ Ops: "rwdm",
+ CanGrant: true,
+ GrantedBy: "system",
+ GrantedAt: now,
+ })
+ saToken := loginWithBackdoor(t, client, server.URL, "superadmin@test.com")
+
+ // User A should only see Project A
+ t.Log("Verify User A sees only Project A")
+ req, _ = http.NewRequest("GET", server.URL+"/api/projects", nil)
+ req.Header.Set("Authorization", "Bearer "+userAToken)
+ resp, _ = client.Do(req)
+ var userAProjects []lib.Entry
+ json.NewDecoder(resp.Body).Decode(&userAProjects)
+ resp.Body.Close()
+
+ if len(userAProjects) != 1 {
+ t.Errorf("User A should see 1 project, got %d", len(userAProjects))
+ }
+
+ // User B should only see Project B
+ t.Log("Verify User B sees only Project B")
+ req, _ = http.NewRequest("GET", server.URL+"/api/projects", nil)
+ req.Header.Set("Authorization", "Bearer "+userBToken)
+ resp, _ = client.Do(req)
+ var userBProjects []lib.Entry
+ json.NewDecoder(resp.Body).Decode(&userBProjects)
+ resp.Body.Close()
+
+ if len(userBProjects) != 1 {
+ t.Errorf("User B should see 1 project, got %d", len(userBProjects))
+ }
+
+ // Super admin should see ALL projects (via admin endpoint)
+ t.Log("Verify Super Admin sees all projects")
+ req, _ = http.NewRequest("GET", server.URL+"/api/admin/projects", nil)
+ req.Header.Set("Authorization", "Bearer "+saToken)
+ resp, _ = client.Do(req)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("admin/projects expected 200, got %d", resp.StatusCode)
+ }
+ var saProjects []lib.Entry
+ json.NewDecoder(resp.Body).Decode(&saProjects)
+ resp.Body.Close()
+
+ if len(saProjects) < 2 {
+ t.Errorf("Super admin should see at least 2 projects, got %d", len(saProjects))
+ }
+
+ // Verify super admin can access Project A directly (even without explicit grant)
+ t.Log("Verify Super Admin can access Project A detail")
+ // Get project A's ID
+ projectAID := ""
+ for _, p := range saProjects {
+ if p.SummaryText == "Project A" {
+ projectAID = p.ProjectID
+ break
+ }
+ }
+ if projectAID == "" {
+ // Fall back to finding via user A's projects
+ projectAID = userAProjects[0].ProjectID
+ }
+
+ req, _ = http.NewRequest("GET", server.URL+"/api/projects/"+projectAID+"/detail", nil)
+ req.Header.Set("Authorization", "Bearer "+saToken)
+ resp, _ = client.Do(req)
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("super admin should access Project A detail, got %d", resp.StatusCode)
+ }
+ resp.Body.Close()
+
+ t.Log("Super admin project visibility test passed!")
+}
diff --git a/api/orgs_test.go b/api/orgs_test.go
new file mode 100644
index 0000000..ce970a4
--- /dev/null
+++ b/api/orgs_test.go
@@ -0,0 +1,617 @@
+package api
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/mish/dealspace/lib"
+)
+
+// testServerSetup creates a test server with all dependencies
+func testServerSetup(t *testing.T) (*httptest.Server, *lib.DB, *lib.Config, func()) {
+ t.Helper()
+
+ tmpFile, err := os.CreateTemp("", "dealspace-org-test-*.db")
+ if err != nil {
+ t.Fatalf("create temp file: %v", err)
+ }
+ tmpFile.Close()
+
+ db, err := lib.OpenDB(tmpFile.Name(), "../migrations/001_initial.sql")
+ if err != nil {
+ os.Remove(tmpFile.Name())
+ t.Fatalf("OpenDB: %v", err)
+ }
+
+ masterKey := make([]byte, 32)
+ for i := range masterKey {
+ masterKey[i] = byte(i)
+ }
+ jwtSecret := []byte("test-jwt-secret-32-bytes-long!!")
+
+ cfg := &lib.Config{
+ MasterKey: masterKey,
+ JWTSecret: jwtSecret,
+ Env: "development",
+ BackdoorCode: "220402",
+ }
+
+ tmpDir, err := os.MkdirTemp("", "dealspace-store-org-test")
+ if err != nil {
+ db.Close()
+ os.Remove(tmpFile.Name())
+ t.Fatal(err)
+ }
+ store, _ := lib.NewLocalStore(tmpDir)
+
+ router := NewRouter(db, cfg, store, nil, nil)
+ server := httptest.NewServer(router)
+
+ cleanup := func() {
+ server.Close()
+ db.Close()
+ os.Remove(tmpFile.Name())
+ os.RemoveAll(tmpDir)
+ }
+
+ return server, db, cfg, cleanup
+}
+
+// createUserWithRole creates a user and grants them a role on a project
+func createUserWithRole(t *testing.T, db *lib.DB, email, role, projectID string) string {
+ t.Helper()
+ now := time.Now().UnixMilli()
+ userID := uuid.New().String()
+ user := &lib.User{
+ UserID: userID,
+ Email: email,
+ Name: "Test User",
+ Password: "",
+ Active: true,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := lib.UserCreate(db, user); err != nil {
+ t.Fatalf("UserCreate: %v", err)
+ }
+
+ if role != "" && projectID != "" {
+ ops := "r"
+ switch role {
+ case lib.RoleSuperAdmin, lib.RoleIBAdmin:
+ ops = "rwdm"
+ case lib.RoleIBMember:
+ ops = "rwdm"
+ case lib.RoleSellerAdmin, lib.RoleSellerMember:
+ ops = "rwd"
+ }
+
+ lib.AccessGrant(db, &lib.Access{
+ ID: uuid.New().String(),
+ ProjectID: projectID,
+ UserID: userID,
+ Role: role,
+ Ops: ops,
+ CanGrant: role == lib.RoleIBAdmin || role == lib.RoleSuperAdmin,
+ GrantedBy: "system",
+ GrantedAt: now,
+ })
+ }
+
+ return userID
+}
+
+// createSuperAdmin creates a super admin user
+func createSuperAdmin(t *testing.T, db *lib.DB, email string) string {
+ t.Helper()
+ now := time.Now().UnixMilli()
+ userID := uuid.New().String()
+ user := &lib.User{
+ UserID: userID,
+ Email: email,
+ Name: "Super Admin",
+ Password: "",
+ Active: true,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := lib.UserCreate(db, user); err != nil {
+ t.Fatalf("UserCreate: %v", err)
+ }
+
+ lib.AccessGrant(db, &lib.Access{
+ ID: uuid.New().String(),
+ ProjectID: "global",
+ UserID: userID,
+ Role: lib.RoleSuperAdmin,
+ Ops: "rwdm",
+ CanGrant: true,
+ GrantedBy: "system",
+ GrantedAt: now,
+ })
+
+ return userID
+}
+
+// getToken gets a token for a user via backdoor auth
+func getToken(t *testing.T, client *http.Client, serverURL, email string) string {
+ t.Helper()
+
+ // Challenge
+ body, _ := json.Marshal(map[string]string{"email": email})
+ resp, err := client.Post(serverURL+"/api/auth/challenge", "application/json", bytes.NewReader(body))
+ if err != nil {
+ t.Fatalf("challenge failed: %v", err)
+ }
+ resp.Body.Close()
+
+ // Verify with backdoor
+ body, _ = json.Marshal(map[string]string{"email": email, "code": "220402"})
+ resp, err = client.Post(serverURL+"/api/auth/verify", "application/json", bytes.NewReader(body))
+ if err != nil {
+ t.Fatalf("verify failed: %v", err)
+ }
+ var verifyResp map[string]interface{}
+ json.NewDecoder(resp.Body).Decode(&verifyResp)
+ resp.Body.Close()
+
+ token, ok := verifyResp["token"].(string)
+ if !ok || token == "" {
+ t.Fatalf("no token in verify response: %v", verifyResp)
+ }
+ return token
+}
+
+func TestCreateOrg(t *testing.T) {
+ server, db, _, cleanup := testServerSetup(t)
+ defer cleanup()
+
+ client := &http.Client{}
+
+ // Create an IB admin user (can create orgs)
+ projectID := uuid.New().String()
+ createUserWithRole(t, db, "ibadmin@test.com", lib.RoleIBAdmin, projectID)
+ token := getToken(t, client, server.URL, "ibadmin@test.com")
+
+ // Create org with valid payload
+ orgBody, _ := json.Marshal(map[string]interface{}{
+ "name": "Acme Corporation",
+ "domains": []string{"acme.com", "acme-corp.com"},
+ "role": "seller",
+ "website": "https://acme.com",
+ "description": "A company that makes everything",
+ "contact_name": "Wile E. Coyote",
+ "contact_email": "wile@acme.com",
+ })
+
+ req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("create org request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusCreated {
+ var errResp map[string]string
+ json.NewDecoder(resp.Body).Decode(&errResp)
+ t.Fatalf("expected 201, got %d: %v", resp.StatusCode, errResp)
+ }
+
+ var createResp map[string]interface{}
+ json.NewDecoder(resp.Body).Decode(&createResp)
+
+ if createResp["entry_id"] == nil || createResp["entry_id"] == "" {
+ t.Error("response should contain entry_id")
+ }
+ if createResp["name"] != "Acme Corporation" {
+ t.Errorf("name mismatch: got %v", createResp["name"])
+ }
+ if createResp["role"] != "seller" {
+ t.Errorf("role mismatch: got %v", createResp["role"])
+ }
+
+ domains, ok := createResp["domains"].([]interface{})
+ if !ok || len(domains) != 2 {
+ t.Errorf("domains should have 2 entries, got %v", createResp["domains"])
+ }
+}
+
+func TestCreateOrgEmptyDomains(t *testing.T) {
+ server, db, _, cleanup := testServerSetup(t)
+ defer cleanup()
+
+ client := &http.Client{}
+
+ projectID := uuid.New().String()
+ createUserWithRole(t, db, "ibadmin2@test.com", lib.RoleIBAdmin, projectID)
+ token := getToken(t, client, server.URL, "ibadmin2@test.com")
+
+ // Try to create org with empty domains array
+ orgBody, _ := json.Marshal(map[string]interface{}{
+ "name": "Bad Org",
+ "domains": []string{},
+ "role": "buyer",
+ })
+
+ req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Errorf("expected 400 for empty domains, got %d", resp.StatusCode)
+ }
+
+ var errResp map[string]string
+ json.NewDecoder(resp.Body).Decode(&errResp)
+ if errResp["code"] != "missing_fields" {
+ t.Errorf("expected missing_fields error code, got %s", errResp["code"])
+ }
+}
+
+func TestCreateOrgMissingDomains(t *testing.T) {
+ server, db, _, cleanup := testServerSetup(t)
+ defer cleanup()
+
+ client := &http.Client{}
+
+ projectID := uuid.New().String()
+ createUserWithRole(t, db, "ibadmin3@test.com", lib.RoleIBAdmin, projectID)
+ token := getToken(t, client, server.URL, "ibadmin3@test.com")
+
+ // Try to create org without domains field at all
+ orgBody, _ := json.Marshal(map[string]interface{}{
+ "name": "Missing Domains Org",
+ "role": "seller",
+ })
+
+ req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Errorf("expected 400 for missing domains, got %d", resp.StatusCode)
+ }
+}
+
+func TestCreateOrgEmptyDomainString(t *testing.T) {
+ server, db, _, cleanup := testServerSetup(t)
+ defer cleanup()
+
+ client := &http.Client{}
+
+ projectID := uuid.New().String()
+ createUserWithRole(t, db, "ibadmin4@test.com", lib.RoleIBAdmin, projectID)
+ token := getToken(t, client, server.URL, "ibadmin4@test.com")
+
+ // Try to create org with empty string in domains
+ orgBody, _ := json.Marshal(map[string]interface{}{
+ "name": "Empty String Domain Org",
+ "domains": []string{"valid.com", ""},
+ "role": "seller",
+ })
+
+ req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Errorf("expected 400 for empty domain string, got %d", resp.StatusCode)
+ }
+
+ var errResp map[string]string
+ json.NewDecoder(resp.Body).Decode(&errResp)
+ if errResp["code"] != "invalid_domains" {
+ t.Errorf("expected invalid_domains error code, got %s", errResp["code"])
+ }
+}
+
+func TestListOrgs(t *testing.T) {
+ server, db, _, cleanup := testServerSetup(t)
+ defer cleanup()
+
+ client := &http.Client{}
+
+ // Create IB admin to create orgs
+ projectID := uuid.New().String()
+ createUserWithRole(t, db, "ibadmin5@test.com", lib.RoleIBAdmin, projectID)
+ ibToken := getToken(t, client, server.URL, "ibadmin5@test.com")
+
+ // Create two orgs
+ for i, name := range []string{"Org Alpha", "Org Beta"} {
+ orgBody, _ := json.Marshal(map[string]interface{}{
+ "name": name,
+ "domains": []string{name + ".com"},
+ "role": "seller",
+ })
+ req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
+ req.Header.Set("Authorization", "Bearer "+ibToken)
+ req.Header.Set("Content-Type", "application/json")
+ resp, _ := client.Do(req)
+ if resp.StatusCode != http.StatusCreated {
+ t.Fatalf("failed to create org %d: %d", i, resp.StatusCode)
+ }
+ resp.Body.Close()
+ }
+
+ // Create super_admin to list all orgs (IB admin only sees orgs linked to their projects via deal_org)
+ createSuperAdmin(t, db, "superadmin-list@test.com")
+ saToken := getToken(t, client, server.URL, "superadmin-list@test.com")
+
+ // List orgs as super_admin
+ req, _ := http.NewRequest("GET", server.URL+"/api/orgs", nil)
+ req.Header.Set("Authorization", "Bearer "+saToken)
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("list orgs failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("expected 200, got %d", resp.StatusCode)
+ }
+
+ var orgs []map[string]interface{}
+ json.NewDecoder(resp.Body).Decode(&orgs)
+
+ if len(orgs) < 2 {
+ t.Errorf("super_admin expected at least 2 orgs, got %d", len(orgs))
+ }
+}
+
+func TestSuperAdminCanListAllOrgs(t *testing.T) {
+ server, db, _, cleanup := testServerSetup(t)
+ defer cleanup()
+
+ client := &http.Client{}
+
+ // Create super admin first
+ createSuperAdmin(t, db, "superadmin@test.com")
+
+ // Create an IB admin to create orgs
+ projectID := uuid.New().String()
+ createUserWithRole(t, db, "ibadmin6@test.com", lib.RoleIBAdmin, projectID)
+ ibToken := getToken(t, client, server.URL, "ibadmin6@test.com")
+
+ // Create org
+ orgBody, _ := json.Marshal(map[string]interface{}{
+ "name": "SuperAdmin Test Org",
+ "domains": []string{"supertest.com"},
+ "role": "buyer",
+ })
+ req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
+ req.Header.Set("Authorization", "Bearer "+ibToken)
+ req.Header.Set("Content-Type", "application/json")
+ resp, _ := client.Do(req)
+ resp.Body.Close()
+
+ // Super admin lists orgs
+ saToken := getToken(t, client, server.URL, "superadmin@test.com")
+ req, _ = http.NewRequest("GET", server.URL+"/api/orgs", nil)
+ req.Header.Set("Authorization", "Bearer "+saToken)
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("list orgs as super admin failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("expected 200, got %d", resp.StatusCode)
+ }
+
+ var orgs []map[string]interface{}
+ json.NewDecoder(resp.Body).Decode(&orgs)
+
+ // Super admin should see all orgs
+ found := false
+ for _, org := range orgs {
+ if org["name"] == "SuperAdmin Test Org" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Error("super admin should see all orgs")
+ }
+}
+
+func TestCreateOrgForbiddenForRegularUser(t *testing.T) {
+ server, db, _, cleanup := testServerSetup(t)
+ defer cleanup()
+
+ client := &http.Client{}
+
+ // Create a regular user (buyer role)
+ projectID := uuid.New().String()
+ createUserWithRole(t, db, "buyer@test.com", lib.RoleBuyerMember, projectID)
+ token := getToken(t, client, server.URL, "buyer@test.com")
+
+ // Try to create org (should fail - only IB admin or super admin can create orgs)
+ orgBody, _ := json.Marshal(map[string]interface{}{
+ "name": "Forbidden Org",
+ "domains": []string{"forbidden.com"},
+ "role": "buyer",
+ })
+
+ req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusForbidden {
+ t.Errorf("expected 403 for regular user, got %d", resp.StatusCode)
+ }
+}
+
+func TestGetOrg(t *testing.T) {
+ server, db, _, cleanup := testServerSetup(t)
+ defer cleanup()
+
+ client := &http.Client{}
+
+ projectID := uuid.New().String()
+ createUserWithRole(t, db, "ibadmin7@test.com", lib.RoleIBAdmin, projectID)
+ token := getToken(t, client, server.URL, "ibadmin7@test.com")
+
+ // Create org
+ orgBody, _ := json.Marshal(map[string]interface{}{
+ "name": "Get Test Org",
+ "domains": []string{"gettest.com"},
+ "role": "seller",
+ "contact_email": "contact@gettest.com",
+ })
+ req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Content-Type", "application/json")
+ resp, _ := client.Do(req)
+ var createResp map[string]interface{}
+ json.NewDecoder(resp.Body).Decode(&createResp)
+ resp.Body.Close()
+
+ orgID := createResp["entry_id"].(string)
+
+ // Get the org
+ req, _ = http.NewRequest("GET", server.URL+"/api/orgs/"+orgID, nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("get org failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("expected 200, got %d", resp.StatusCode)
+ }
+
+ var orgResp map[string]interface{}
+ json.NewDecoder(resp.Body).Decode(&orgResp)
+
+ if orgResp["name"] != "Get Test Org" {
+ t.Errorf("name mismatch: got %v", orgResp["name"])
+ }
+ if orgResp["contact_email"] != "contact@gettest.com" {
+ t.Errorf("contact_email mismatch: got %v", orgResp["contact_email"])
+ }
+}
+
+func TestUpdateOrg(t *testing.T) {
+ server, db, _, cleanup := testServerSetup(t)
+ defer cleanup()
+
+ client := &http.Client{}
+
+ // Use super_admin for update (org entries use their own ID as project_id, need super_admin access)
+ createSuperAdmin(t, db, "superadmin-update@test.com")
+ token := getToken(t, client, server.URL, "superadmin-update@test.com")
+
+ // Create org
+ orgBody, _ := json.Marshal(map[string]interface{}{
+ "name": "Update Test Org",
+ "domains": []string{"update.com"},
+ "role": "seller",
+ })
+ req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Content-Type", "application/json")
+ resp, _ := client.Do(req)
+ var createResp map[string]interface{}
+ json.NewDecoder(resp.Body).Decode(&createResp)
+ resp.Body.Close()
+
+ orgID := createResp["entry_id"].(string)
+
+ // Update the org
+ updateBody, _ := json.Marshal(map[string]interface{}{
+ "name": "Updated Org Name",
+ "domains": []string{"update.com", "updated.com"},
+ "description": "Now with a description",
+ "version": 1,
+ })
+ req, _ = http.NewRequest("PATCH", server.URL+"/api/orgs/"+orgID, bytes.NewReader(updateBody))
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("update org failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ var errResp map[string]string
+ json.NewDecoder(resp.Body).Decode(&errResp)
+ t.Fatalf("expected 200, got %d: %v", resp.StatusCode, errResp)
+ }
+
+ var updateResp map[string]interface{}
+ json.NewDecoder(resp.Body).Decode(&updateResp)
+
+ if updateResp["name"] != "Updated Org Name" {
+ t.Errorf("name should be updated: got %v", updateResp["name"])
+ }
+ if updateResp["description"] != "Now with a description" {
+ t.Errorf("description should be updated: got %v", updateResp["description"])
+ }
+}
+
+func TestCreateOrgInvalidRole(t *testing.T) {
+ server, db, _, cleanup := testServerSetup(t)
+ defer cleanup()
+
+ client := &http.Client{}
+
+ projectID := uuid.New().String()
+ createUserWithRole(t, db, "ibadmin9@test.com", lib.RoleIBAdmin, projectID)
+ token := getToken(t, client, server.URL, "ibadmin9@test.com")
+
+ // Try to create org with invalid role
+ orgBody, _ := json.Marshal(map[string]interface{}{
+ "name": "Invalid Role Org",
+ "domains": []string{"invalid.com"},
+ "role": "invalid_role",
+ })
+
+ req, _ := http.NewRequest("POST", server.URL+"/api/orgs", bytes.NewReader(orgBody))
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Errorf("expected 400 for invalid role, got %d", resp.StatusCode)
+ }
+
+ var errResp map[string]string
+ json.NewDecoder(resp.Body).Decode(&errResp)
+ if errResp["code"] != "invalid_role" {
+ t.Errorf("expected invalid_role error code, got %s", errResp["code"])
+ }
+}
diff --git a/api/requests_test.go b/api/requests_test.go
new file mode 100644
index 0000000..a609277
--- /dev/null
+++ b/api/requests_test.go
@@ -0,0 +1,754 @@
+package api
+
+import (
+ "archive/zip"
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+
+ "os"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/mish/dealspace/lib"
+)
+
+// createTestProjectWithAccess creates a project and grants the user IB admin access
+func createTestProjectWithAccess(t *testing.T, db *lib.DB, cfg *lib.Config, userID string) string {
+ t.Helper()
+
+ projectID := uuid.New().String()
+ now := time.Now().UnixMilli()
+
+ // Create project entry
+ key, _ := lib.DeriveProjectKey(cfg.MasterKey, projectID)
+ summary, _ := lib.Pack(key, "Test Import Project")
+ data, _ := lib.Pack(key, `{"name": "Test Import Project"}`)
+
+ 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 (?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,
+ projectID, projectID, "", lib.TypeProject, 0,
+ nil, nil, summary, data, lib.StagePreDataroom,
+ "", "", "",
+ 1, nil, nil, 1,
+ now, now, userID,
+ )
+
+ // Grant access
+ lib.AccessGrant(db, &lib.Access{
+ ID: uuid.New().String(),
+ ProjectID: projectID,
+ UserID: userID,
+ Role: lib.RoleIBAdmin,
+ Ops: "rwdm",
+ CanGrant: true,
+ GrantedBy: "system",
+ GrantedAt: now,
+ })
+
+ return projectID
+}
+
+// uploadFile performs a multipart file upload
+func uploadFile(t *testing.T, client *http.Client, url, token, filename, contentType string, content []byte, formFields map[string]string) *http.Response {
+ t.Helper()
+
+ var buf bytes.Buffer
+ writer := multipart.NewWriter(&buf)
+
+ // Add form fields
+ for key, val := range formFields {
+ writer.WriteField(key, val)
+ }
+
+ // Add file
+ part, err := writer.CreateFormFile("file", filename)
+ if err != nil {
+ t.Fatalf("create form file: %v", err)
+ }
+ part.Write(content)
+ writer.Close()
+
+ req, _ := http.NewRequest("POST", url, &buf)
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("upload request failed: %v", err)
+ }
+ return resp
+}
+
+func TestImportRequestsCSV(t *testing.T) {
+ server, db, cfg, cleanup := testServerSetup(t)
+ defer cleanup()
+
+ client := &http.Client{}
+
+ // Create user
+ userID := uuid.New().String()
+ now := time.Now().UnixMilli()
+ user := &lib.User{
+ UserID: userID,
+ Email: "import@test.com",
+ Name: "Import Test",
+ Password: "",
+ Active: true,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ lib.UserCreate(db, user)
+
+ projectID := createTestProjectWithAccess(t, db, cfg, userID)
+ token := getToken(t, client, server.URL, "import@test.com")
+
+ // Create a valid CSV
+ csvContent := `Section,Item #,Description,Priority
+Financial,1.1,Provide audited financial statements,High
+Financial,1.2,Revenue breakdown by product,Medium
+Legal,2.1,List of all pending litigation,Critical
+Legal,2.2,Employment contracts for executives,Low
+HR,3.1,Organization chart,Nice to have`
+
+ resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
+ token, "requests.csv", "text/csv", []byte(csvContent), nil)
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(body))
+ }
+
+ var importResp map[string]interface{}
+ json.NewDecoder(resp.Body).Decode(&importResp)
+
+ imported := int(importResp["imported"].(float64))
+ if imported != 5 {
+ t.Errorf("expected 5 imported, got %d", imported)
+ }
+
+ skipped := int(importResp["skipped"].(float64))
+ if skipped != 0 {
+ t.Errorf("expected 0 skipped, got %d", skipped)
+ }
+
+ sections := importResp["sections"].([]interface{})
+ if len(sections) != 3 {
+ t.Errorf("expected 3 sections, got %d: %v", len(sections), sections)
+ }
+}
+
+func TestImportRequestsXLSX(t *testing.T) {
+ server, db, cfg, cleanup := testServerSetup(t)
+ defer cleanup()
+
+ client := &http.Client{}
+
+ userID := uuid.New().String()
+ now := time.Now().UnixMilli()
+ user := &lib.User{
+ UserID: userID,
+ Email: "xlsx@test.com",
+ Name: "XLSX Test",
+ Password: "",
+ Active: true,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ lib.UserCreate(db, user)
+
+ projectID := createTestProjectWithAccess(t, db, cfg, userID)
+ token := getToken(t, client, server.URL, "xlsx@test.com")
+
+ // Create a minimal valid XLSX
+ xlsxContent := createMinimalXLSX([][]string{
+ {"Section", "Item #", "Description", "Priority"},
+ {"Finance", "F-1", "Cash flow statement", "High"},
+ {"Finance", "F-2", "Balance sheet", "Medium"},
+ })
+
+ resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
+ token, "requests.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ xlsxContent, nil)
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(body))
+ }
+
+ var importResp map[string]interface{}
+ json.NewDecoder(resp.Body).Decode(&importResp)
+
+ imported := int(importResp["imported"].(float64))
+ if imported != 2 {
+ t.Errorf("expected 2 imported, got %d", imported)
+ }
+}
+
+// createMinimalXLSX creates a minimal valid XLSX file from rows
+func createMinimalXLSX(rows [][]string) []byte {
+ var buf bytes.Buffer
+ zw := zip.NewWriter(&buf)
+
+ // [Content_Types].xml
+ ct := `
+
+
+
+
+
+
+`
+ w, _ := zw.Create("[Content_Types].xml")
+ w.Write([]byte(ct))
+
+ // _rels/.rels
+ rels := `
+
+
+`
+ w, _ = zw.Create("_rels/.rels")
+ w.Write([]byte(rels))
+
+ // xl/_rels/workbook.xml.rels
+ wbrels := `
+
+
+
+`
+ w, _ = zw.Create("xl/_rels/workbook.xml.rels")
+ w.Write([]byte(wbrels))
+
+ // xl/workbook.xml
+ wb := `
+
+
+`
+ w, _ = zw.Create("xl/workbook.xml")
+ w.Write([]byte(wb))
+
+ // Collect all strings and build shared strings
+ var allStrings []string
+ stringIndex := make(map[string]int)
+ for _, row := range rows {
+ for _, cell := range row {
+ if _, exists := stringIndex[cell]; !exists {
+ stringIndex[cell] = len(allStrings)
+ allStrings = append(allStrings, cell)
+ }
+ }
+ }
+
+ // xl/sharedStrings.xml
+ ssb := bytes.NewBufferString(`
+`)
+ for _, s := range allStrings {
+ ssb.WriteString("" + escapeXML(s) + "")
+ }
+ ssb.WriteString("")
+ w, _ = zw.Create("xl/sharedStrings.xml")
+ w.Write(ssb.Bytes())
+
+ // xl/worksheets/sheet1.xml
+ sheetb := bytes.NewBufferString(`
+
+`)
+ for ri, row := range rows {
+ sheetb.WriteString(fmt.Sprintf(``, ri+1))
+ for ci, cell := range row {
+ colLetter := string(rune('A' + ci))
+ cellRef := fmt.Sprintf("%s%d", colLetter, ri+1)
+ idx := stringIndex[cell]
+ sheetb.WriteString(fmt.Sprintf(`%d`, cellRef, idx))
+ }
+ sheetb.WriteString("
")
+ }
+ sheetb.WriteString("")
+ w, _ = zw.Create("xl/worksheets/sheet1.xml")
+ w.Write(sheetb.Bytes())
+
+ zw.Close()
+ return buf.Bytes()
+}
+
+func escapeXML(s string) string {
+ var buf bytes.Buffer
+ for _, r := range s {
+ switch r {
+ case '<':
+ buf.WriteString("<")
+ case '>':
+ buf.WriteString(">")
+ case '&':
+ buf.WriteString("&")
+ case '"':
+ buf.WriteString(""")
+ case '\'':
+ buf.WriteString("'")
+ default:
+ buf.WriteRune(r)
+ }
+ }
+ return buf.String()
+}
+
+func TestImportSmartHeaderDetection(t *testing.T) {
+ server, db, cfg, cleanup := testServerSetup(t)
+ defer cleanup()
+
+ client := &http.Client{}
+
+ userID := uuid.New().String()
+ now := time.Now().UnixMilli()
+ user := &lib.User{
+ UserID: userID,
+ Email: "smartheader@test.com",
+ Name: "Smart Header",
+ Password: "",
+ Active: true,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ lib.UserCreate(db, user)
+
+ projectID := createTestProjectWithAccess(t, db, cfg, userID)
+ token := getToken(t, client, server.URL, "smartheader@test.com")
+
+ // CSV with 3 title rows before the actual header
+ csvContent := `Project XYZ Due Diligence Request List
+Confidential - Internal Use Only
+Generated: 2026-02-28
+Section,Item #,Information Requested,Priority
+Financial,1.1,Audited financials 3 years,High
+Financial,1.2,Monthly management reports,Medium`
+
+ resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
+ token, "with-title-rows.csv", "text/csv", []byte(csvContent), nil)
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(body))
+ }
+
+ var importResp map[string]interface{}
+ json.NewDecoder(resp.Body).Decode(&importResp)
+
+ // Should detect header at row 4 (index 3) and import 2 rows
+ imported := int(importResp["imported"].(float64))
+ if imported != 2 {
+ t.Errorf("expected 2 imported (header detection), got %d", imported)
+ }
+}
+
+func TestImportModeReplace(t *testing.T) {
+ server, db, cfg, cleanup := testServerSetup(t)
+ defer cleanup()
+
+ client := &http.Client{}
+
+ userID := uuid.New().String()
+ now := time.Now().UnixMilli()
+ user := &lib.User{
+ UserID: userID,
+ Email: "replace@test.com",
+ Name: "Replace Test",
+ Password: "",
+ Active: true,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ lib.UserCreate(db, user)
+
+ projectID := createTestProjectWithAccess(t, db, cfg, userID)
+ token := getToken(t, client, server.URL, "replace@test.com")
+
+ // First import
+ csv1 := `Section,Item #,Description,Priority
+Finance,1,First batch item 1,High
+Finance,2,First batch item 2,Medium`
+
+ resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
+ token, "batch1.csv", "text/csv", []byte(csv1), map[string]string{"mode": "add"})
+ resp.Body.Close()
+
+ // Second import with mode=replace
+ csv2 := `Section,Item #,Description,Priority
+Legal,A,Second batch only,High`
+
+ resp = uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
+ token, "batch2.csv", "text/csv", []byte(csv2), map[string]string{"mode": "replace"})
+ resp.Body.Close()
+
+ // List requests - should only have 1 (from second batch)
+ req, _ := http.NewRequest("GET", server.URL+"/api/projects/"+projectID+"/requests", nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("list requests failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ var requests []map[string]interface{}
+ json.NewDecoder(resp.Body).Decode(&requests)
+
+ if len(requests) != 1 {
+ t.Errorf("after replace, expected 1 request, got %d", len(requests))
+ }
+ if len(requests) > 0 && requests[0]["section"] != "Legal" {
+ t.Errorf("expected Legal section, got %v", requests[0]["section"])
+ }
+}
+
+func TestImportModeAdd(t *testing.T) {
+ server, db, cfg, cleanup := testServerSetup(t)
+ defer cleanup()
+
+ client := &http.Client{}
+
+ userID := uuid.New().String()
+ now := time.Now().UnixMilli()
+ user := &lib.User{
+ UserID: userID,
+ Email: "addmode@test.com",
+ Name: "Add Mode",
+ Password: "",
+ Active: true,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ lib.UserCreate(db, user)
+
+ projectID := createTestProjectWithAccess(t, db, cfg, userID)
+ token := getToken(t, client, server.URL, "addmode@test.com")
+
+ // First import
+ csv1 := `Section,Item #,Description,Priority
+Finance,1,First item,High`
+
+ resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
+ token, "add1.csv", "text/csv", []byte(csv1), map[string]string{"mode": "add"})
+ resp.Body.Close()
+
+ // Second import with mode=add (accumulate)
+ csv2 := `Section,Item #,Description,Priority
+Legal,2,Second item,Medium`
+
+ resp = uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
+ token, "add2.csv", "text/csv", []byte(csv2), map[string]string{"mode": "add"})
+ resp.Body.Close()
+
+ // List requests - should have 2 (accumulated)
+ req, _ := http.NewRequest("GET", server.URL+"/api/projects/"+projectID+"/requests", nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("list requests failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ var requests []map[string]interface{}
+ json.NewDecoder(resp.Body).Decode(&requests)
+
+ if len(requests) != 2 {
+ t.Errorf("after add mode, expected 2 requests, got %d", len(requests))
+ }
+}
+
+func TestListRequests(t *testing.T) {
+ server, db, cfg, cleanup := testServerSetup(t)
+ defer cleanup()
+
+ client := &http.Client{}
+
+ userID := uuid.New().String()
+ now := time.Now().UnixMilli()
+ user := &lib.User{
+ UserID: userID,
+ Email: "list@test.com",
+ Name: "List Test",
+ Password: "",
+ Active: true,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ lib.UserCreate(db, user)
+
+ projectID := createTestProjectWithAccess(t, db, cfg, userID)
+ token := getToken(t, client, server.URL, "list@test.com")
+
+ // Import some requests
+ csv := `Section,Item #,Description,Priority
+Alpha,A-1,First alpha item,High
+Alpha,A-2,Second alpha item,Medium
+Beta,B-1,First beta item,Low`
+
+ resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
+ token, "list.csv", "text/csv", []byte(csv), nil)
+ resp.Body.Close()
+
+ // List requests
+ req, _ := http.NewRequest("GET", server.URL+"/api/projects/"+projectID+"/requests", nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("list requests failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("expected 200, got %d", resp.StatusCode)
+ }
+
+ var requests []map[string]interface{}
+ json.NewDecoder(resp.Body).Decode(&requests)
+
+ if len(requests) != 3 {
+ t.Errorf("expected 3 requests, got %d", len(requests))
+ }
+
+ // Verify structure
+ for _, r := range requests {
+ if r["entry_id"] == nil || r["entry_id"] == "" {
+ t.Error("request should have entry_id")
+ }
+ if r["section"] == nil {
+ t.Error("request should have section")
+ }
+ if r["priority"] == nil {
+ t.Error("request should have priority")
+ }
+ if r["status"] == nil {
+ t.Error("request should have status")
+ }
+ }
+}
+
+func TestPriorityNormalization(t *testing.T) {
+ server, db, cfg, cleanup := testServerSetup(t)
+ defer cleanup()
+
+ client := &http.Client{}
+
+ userID := uuid.New().String()
+ now := time.Now().UnixMilli()
+ user := &lib.User{
+ UserID: userID,
+ Email: "priority@test.com",
+ Name: "Priority Test",
+ Password: "",
+ Active: true,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ lib.UserCreate(db, user)
+
+ projectID := createTestProjectWithAccess(t, db, cfg, userID)
+ token := getToken(t, client, server.URL, "priority@test.com")
+
+ // CSV with various priority formats
+ csv := `Section,Item #,Description,Priority
+Test,1,Critical priority,CRITICAL
+Test,2,Nice to have priority,Nice to have
+Test,3,Normal priority,Normal
+Test,4,Urgent priority,Urgent
+Test,5,High priority,High
+Test,6,Low priority,Low
+Test,7,Optional priority,optional`
+
+ resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
+ token, "priority.csv", "text/csv", []byte(csv), nil)
+ resp.Body.Close()
+
+ // List and verify priority normalization
+ req, _ := http.NewRequest("GET", server.URL+"/api/projects/"+projectID+"/requests", nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("list requests failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ var requests []map[string]interface{}
+ json.NewDecoder(resp.Body).Decode(&requests)
+
+ expected := map[string]string{
+ "Critical priority": "high", // CRITICAL → high
+ "Nice to have priority": "low", // Nice to have → low
+ "Normal priority": "medium",
+ "Urgent priority": "high", // Urgent → high
+ "High priority": "high",
+ "Low priority": "low",
+ "Optional priority": "low", // optional → low
+ }
+
+ for _, r := range requests {
+ title := r["title"].(string)
+ if expectedPri, ok := expected[title]; ok {
+ if r["priority"] != expectedPri {
+ t.Errorf("%s: expected priority %q, got %q", title, expectedPri, r["priority"])
+ }
+ }
+ }
+}
+
+func TestImportNoFile(t *testing.T) {
+ server, db, cfg, cleanup := testServerSetup(t)
+ defer cleanup()
+
+ client := &http.Client{}
+
+ userID := uuid.New().String()
+ now := time.Now().UnixMilli()
+ user := &lib.User{
+ UserID: userID,
+ Email: "nofile@test.com",
+ Name: "No File",
+ Password: "",
+ Active: true,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ lib.UserCreate(db, user)
+
+ projectID := createTestProjectWithAccess(t, db, cfg, userID)
+ token := getToken(t, client, server.URL, "nofile@test.com")
+
+ // Try import without file
+ req, _ := http.NewRequest("POST", server.URL+"/api/projects/"+projectID+"/requests/import", nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Content-Type", "multipart/form-data")
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Errorf("expected 400 for missing file, got %d", resp.StatusCode)
+ }
+}
+
+func TestImportAccessDenied(t *testing.T) {
+ server, db, cfg, cleanup := testServerSetup(t)
+ defer cleanup()
+
+ client := &http.Client{}
+
+ // Create project owner
+ ownerID := uuid.New().String()
+ now := time.Now().UnixMilli()
+ owner := &lib.User{
+ UserID: ownerID,
+ Email: "owner@test.com",
+ Name: "Owner",
+ Password: "",
+ Active: true,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ lib.UserCreate(db, owner)
+ projectID := createTestProjectWithAccess(t, db, cfg, ownerID)
+
+ // Create buyer (read-only, no write access)
+ buyerID := uuid.New().String()
+ buyer := &lib.User{
+ UserID: buyerID,
+ Email: "buyer-readonly@test.com",
+ Name: "Buyer",
+ Password: "",
+ Active: true,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ lib.UserCreate(db, buyer)
+ lib.AccessGrant(db, &lib.Access{
+ ID: uuid.New().String(),
+ ProjectID: projectID,
+ UserID: buyerID,
+ Role: lib.RoleBuyerMember,
+ Ops: "r", // read only
+ CanGrant: false,
+ GrantedBy: "system",
+ GrantedAt: now,
+ })
+
+ buyerToken := getToken(t, client, server.URL, "buyer-readonly@test.com")
+
+ csv := `Section,Item #,Description,Priority
+Test,1,Test item,High`
+
+ resp := uploadFile(t, client, server.URL+"/api/projects/"+projectID+"/requests/import",
+ buyerToken, "denied.csv", "text/csv", []byte(csv), nil)
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusForbidden {
+ t.Errorf("expected 403 for buyer import, got %d", resp.StatusCode)
+ }
+}
+
+func TestListRequestsAccessDenied(t *testing.T) {
+ server, db, cfg, cleanup := testServerSetup(t)
+ defer cleanup()
+
+ client := &http.Client{}
+
+ // Create project
+ ownerID := uuid.New().String()
+ now := time.Now().UnixMilli()
+ owner := &lib.User{
+ UserID: ownerID,
+ Email: "listowner@test.com",
+ Name: "List Owner",
+ Password: "",
+ Active: true,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ lib.UserCreate(db, owner)
+ projectID := createTestProjectWithAccess(t, db, cfg, ownerID)
+
+ // Create user with no access
+ noAccessID := uuid.New().String()
+ noAccess := &lib.User{
+ UserID: noAccessID,
+ Email: "noaccess-list@test.com",
+ Name: "No Access",
+ Password: "",
+ Active: true,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ lib.UserCreate(db, noAccess)
+
+ noAccessToken := getToken(t, client, server.URL, "noaccess-list@test.com")
+
+ req, _ := http.NewRequest("GET", server.URL+"/api/projects/"+projectID+"/requests", nil)
+ req.Header.Set("Authorization", "Bearer "+noAccessToken)
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusForbidden {
+ t.Errorf("expected 403 for no access list, got %d", resp.StatusCode)
+ }
+}
+
+// This test file doesn't exist in the original but is mentioned, so skip it
+func init() {
+ // Ensure tests don't fail due to missing template directory
+ os.MkdirAll("/tmp/dealspace-test-templates", 0755)
+}
diff --git a/lib/rbac_test.go b/lib/rbac_test.go
index 5ac8385..e9418fe 100644
--- a/lib/rbac_test.go
+++ b/lib/rbac_test.go
@@ -385,3 +385,267 @@ func TestWorkstreamAccess(t *testing.T) {
t.Error("user should NOT have access to different workstream")
}
}
+
+// ---- Additional tests for super_admin, IsSuperAdmin, and ValidateOrgDomain ----
+
+func TestSuperAdminBypassesCheckAccess(t *testing.T) {
+ db, cfg := testDB(t)
+
+ // Create a regular user who owns a project
+ ownerID := uuid.New().String()
+ now := time.Now().UnixMilli()
+ owner := &User{
+ UserID: ownerID,
+ Email: "owner@test.com",
+ Name: "Owner",
+ Password: "$2a$10$test",
+ Active: true,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ UserCreate(db, owner)
+ projectID := testProject(t, db, cfg, ownerID)
+
+ // Create super_admin user (no explicit project access)
+ superAdminID := uuid.New().String()
+ superAdmin := &User{
+ UserID: superAdminID,
+ Email: "superadmin@test.com",
+ Name: "Super Admin",
+ Password: "$2a$10$test",
+ Active: true,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ UserCreate(db, superAdmin)
+
+ // Grant super_admin role on a dummy project (super_admin is global but stored per project)
+ dummyProjectID := uuid.New().String()
+ AccessGrant(db, &Access{
+ ID: uuid.New().String(),
+ ProjectID: dummyProjectID,
+ UserID: superAdminID,
+ Role: RoleSuperAdmin,
+ Ops: "rwdm",
+ CanGrant: true,
+ GrantedBy: "system",
+ GrantedAt: now,
+ })
+
+ // Verify super_admin is recognized
+ isSA, err := IsSuperAdmin(db, superAdminID)
+ if err != nil {
+ t.Fatalf("IsSuperAdmin failed: %v", err)
+ }
+ if !isSA {
+ t.Error("User with super_admin role should be recognized as super admin")
+ }
+
+ // super_admin should have full access to any project without explicit grant
+ access, err := CheckAccess(db, superAdminID, projectID, "", "r")
+ if err != nil {
+ t.Errorf("super_admin should have read access: %v", err)
+ }
+ if access == nil {
+ t.Fatal("CheckAccess should return an Access object for super_admin")
+ }
+ if access.Role != RoleSuperAdmin {
+ t.Errorf("Access role should be super_admin, got %s", access.Role)
+ }
+ if access.Ops != "rwdm" {
+ t.Errorf("super_admin should have rwdm ops, got %s", access.Ops)
+ }
+
+ // Test all operations
+ _, err = CheckAccess(db, superAdminID, projectID, "", "w")
+ if err != nil {
+ t.Errorf("super_admin should have write access: %v", err)
+ }
+
+ _, err = CheckAccess(db, superAdminID, projectID, "", "d")
+ if err != nil {
+ t.Errorf("super_admin should have delete access: %v", err)
+ }
+
+ _, err = CheckAccess(db, superAdminID, projectID, "", "m")
+ if err != nil {
+ t.Errorf("super_admin should have manage access: %v", err)
+ }
+
+ // Test convenience functions
+ err = CheckAccessRead(db, superAdminID, projectID, "")
+ if err != nil {
+ t.Errorf("super_admin CheckAccessRead should pass: %v", err)
+ }
+
+ err = CheckAccessWrite(db, superAdminID, projectID, "")
+ if err != nil {
+ t.Errorf("super_admin CheckAccessWrite should pass: %v", err)
+ }
+
+ err = CheckAccessDelete(db, superAdminID, projectID, "")
+ if err != nil {
+ t.Errorf("super_admin CheckAccessDelete should pass: %v", err)
+ }
+}
+
+func TestIsSuperAdmin(t *testing.T) {
+ db, _ := testDB(t)
+ now := time.Now().UnixMilli()
+
+ // Create regular user
+ regularID := uuid.New().String()
+ regular := &User{
+ UserID: regularID,
+ Email: "regular@test.com",
+ Name: "Regular User",
+ Password: "$2a$10$test",
+ Active: true,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ UserCreate(db, regular)
+
+ // Create super admin user
+ superID := uuid.New().String()
+ superUser := &User{
+ UserID: superID,
+ Email: "super@test.com",
+ Name: "Super User",
+ Password: "$2a$10$test",
+ Active: true,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ UserCreate(db, superUser)
+
+ // Grant super_admin role
+ AccessGrant(db, &Access{
+ ID: uuid.New().String(),
+ ProjectID: "any-project",
+ UserID: superID,
+ Role: RoleSuperAdmin,
+ Ops: "rwdm",
+ CanGrant: true,
+ GrantedBy: "system",
+ GrantedAt: now,
+ })
+
+ // Test IsSuperAdmin
+ isSA, err := IsSuperAdmin(db, superID)
+ if err != nil {
+ t.Fatalf("IsSuperAdmin failed: %v", err)
+ }
+ if !isSA {
+ t.Error("User with super_admin role should return true")
+ }
+
+ isRegularSA, err := IsSuperAdmin(db, regularID)
+ if err != nil {
+ t.Fatalf("IsSuperAdmin for regular user failed: %v", err)
+ }
+ if isRegularSA {
+ t.Error("Regular user should not be super admin")
+ }
+
+ // Test with ib_admin (should NOT be super admin)
+ ibAdminID := uuid.New().String()
+ ibAdmin := &User{
+ UserID: ibAdminID,
+ Email: "ibadmin@test.com",
+ Name: "IB Admin",
+ Password: "$2a$10$test",
+ Active: true,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ UserCreate(db, ibAdmin)
+ AccessGrant(db, &Access{
+ ID: uuid.New().String(),
+ ProjectID: "test-project",
+ UserID: ibAdminID,
+ Role: RoleIBAdmin,
+ Ops: "rwdm",
+ CanGrant: true,
+ GrantedBy: "system",
+ GrantedAt: now,
+ })
+
+ isIBSA, err := IsSuperAdmin(db, ibAdminID)
+ if err != nil {
+ t.Fatalf("IsSuperAdmin for ib_admin failed: %v", err)
+ }
+ if isIBSA {
+ t.Error("ib_admin should NOT be super admin")
+ }
+}
+
+func TestIsSuperAdminRevokedGrant(t *testing.T) {
+ db, _ := testDB(t)
+ now := time.Now().UnixMilli()
+
+ // Create user
+ userID := uuid.New().String()
+ user := &User{
+ UserID: userID,
+ Email: "revoke-test@test.com",
+ Name: "Revoke Test",
+ Password: "$2a$10$test",
+ Active: true,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ UserCreate(db, user)
+
+ // Grant and then revoke super_admin
+ accessID := uuid.New().String()
+ AccessGrant(db, &Access{
+ ID: accessID,
+ ProjectID: "any-project",
+ UserID: userID,
+ Role: RoleSuperAdmin,
+ Ops: "rwdm",
+ CanGrant: true,
+ GrantedBy: "system",
+ GrantedAt: now,
+ })
+
+ // Should be super admin
+ isSA, _ := IsSuperAdmin(db, userID)
+ if !isSA {
+ t.Error("Should be super admin before revocation")
+ }
+
+ // Revoke the grant
+ AccessRevoke(db, accessID, "system")
+
+ // Should no longer be super admin
+ isSA, _ = IsSuperAdmin(db, userID)
+ if isSA {
+ t.Error("Should NOT be super admin after revocation")
+ }
+}
+
+func TestSuperAdminCanGrantAnyRole(t *testing.T) {
+ // super_admin (level 200) should be able to grant any role
+ tests := []struct {
+ target string
+ canDo bool
+ }{
+ {RoleSuperAdmin, true},
+ {RoleIBAdmin, true},
+ {RoleIBMember, true},
+ {RoleSellerAdmin, true},
+ {RoleSellerMember, true},
+ {RoleBuyerAdmin, true},
+ {RoleBuyerMember, true},
+ {RoleObserver, true},
+ }
+
+ for _, tc := range tests {
+ result := CanGrantRole(RoleSuperAdmin, tc.target)
+ if result != tc.canDo {
+ t.Errorf("CanGrantRole(super_admin, %s) = %v, want %v", tc.target, result, tc.canDo)
+ }
+ }
+}
diff --git a/lib/types_test.go b/lib/types_test.go
new file mode 100644
index 0000000..961c382
--- /dev/null
+++ b/lib/types_test.go
@@ -0,0 +1,344 @@
+package lib
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestOrgDataJSONMarshal(t *testing.T) {
+ tests := []struct {
+ name string
+ orgData OrgData
+ }{
+ {
+ name: "single domain",
+ orgData: OrgData{
+ Name: "Kaseya Corp",
+ Domains: []string{"kaseya.com"},
+ Role: "seller",
+ Website: "https://kaseya.com",
+ Description: "IT management software company",
+ ContactName: "Fred Voccola",
+ ContactEmail: "fred@kaseya.com",
+ },
+ },
+ {
+ name: "multi domain",
+ orgData: OrgData{
+ Name: "Datto Holdings",
+ Domains: []string{"kaseya.com", "datto.com", "unitrends.com"},
+ Role: "buyer",
+ },
+ },
+ {
+ name: "minimal",
+ orgData: OrgData{
+ Name: "Test Org",
+ Domains: []string{"test.com"},
+ },
+ },
+ {
+ name: "ib role",
+ orgData: OrgData{
+ Name: "Goldman Sachs",
+ Domains: []string{"gs.com", "goldmansachs.com"},
+ Role: "ib",
+ },
+ },
+ {
+ name: "advisor role",
+ orgData: OrgData{
+ Name: "Deloitte",
+ Domains: []string{"deloitte.com"},
+ Role: "advisor",
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Marshal
+ data, err := json.Marshal(tc.orgData)
+ if err != nil {
+ t.Fatalf("Marshal failed: %v", err)
+ }
+
+ // Unmarshal
+ var got OrgData
+ if err := json.Unmarshal(data, &got); err != nil {
+ t.Fatalf("Unmarshal failed: %v", err)
+ }
+
+ // Verify fields
+ if got.Name != tc.orgData.Name {
+ t.Errorf("Name: got %q, want %q", got.Name, tc.orgData.Name)
+ }
+ if len(got.Domains) != len(tc.orgData.Domains) {
+ t.Errorf("Domains length: got %d, want %d", len(got.Domains), len(tc.orgData.Domains))
+ }
+ for i, d := range tc.orgData.Domains {
+ if got.Domains[i] != d {
+ t.Errorf("Domains[%d]: got %q, want %q", i, got.Domains[i], d)
+ }
+ }
+ if got.Role != tc.orgData.Role {
+ t.Errorf("Role: got %q, want %q", got.Role, tc.orgData.Role)
+ }
+ if got.Website != tc.orgData.Website {
+ t.Errorf("Website: got %q, want %q", got.Website, tc.orgData.Website)
+ }
+ if got.Description != tc.orgData.Description {
+ t.Errorf("Description: got %q, want %q", got.Description, tc.orgData.Description)
+ }
+ if got.ContactName != tc.orgData.ContactName {
+ t.Errorf("ContactName: got %q, want %q", got.ContactName, tc.orgData.ContactName)
+ }
+ if got.ContactEmail != tc.orgData.ContactEmail {
+ t.Errorf("ContactEmail: got %q, want %q", got.ContactEmail, tc.orgData.ContactEmail)
+ }
+ })
+ }
+}
+
+func TestOrgDataJSONOmitEmpty(t *testing.T) {
+ // Verify omitempty works for optional fields
+ orgData := OrgData{
+ Name: "Minimal Org",
+ Domains: []string{"minimal.com"},
+ }
+
+ data, err := json.Marshal(orgData)
+ if err != nil {
+ t.Fatalf("Marshal failed: %v", err)
+ }
+
+ // Parse as map to check field presence
+ var m map[string]interface{}
+ if err := json.Unmarshal(data, &m); err != nil {
+ t.Fatalf("Unmarshal to map failed: %v", err)
+ }
+
+ // Required fields should be present
+ if _, ok := m["name"]; !ok {
+ t.Error("name should be present")
+ }
+ if _, ok := m["domains"]; !ok {
+ t.Error("domains should be present")
+ }
+
+ // Optional fields with zero values should be omitted
+ if v, ok := m["website"]; ok && v != "" {
+ t.Error("website should be omitted when empty")
+ }
+ if v, ok := m["description"]; ok && v != "" {
+ t.Error("description should be omitted when empty")
+ }
+}
+
+func TestDealOrgDataJSONMarshal(t *testing.T) {
+ tests := []struct {
+ name string
+ dealOrgData DealOrgData
+ }{
+ {
+ name: "seller with domain lock",
+ dealOrgData: DealOrgData{
+ OrgID: "org-123",
+ Role: "seller",
+ DomainLock: true,
+ },
+ },
+ {
+ name: "buyer without domain lock",
+ dealOrgData: DealOrgData{
+ OrgID: "org-456",
+ Role: "buyer",
+ DomainLock: false,
+ },
+ },
+ {
+ name: "ib role",
+ dealOrgData: DealOrgData{
+ OrgID: "org-789",
+ Role: "ib",
+ DomainLock: true,
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ data, err := json.Marshal(tc.dealOrgData)
+ if err != nil {
+ t.Fatalf("Marshal failed: %v", err)
+ }
+
+ var got DealOrgData
+ if err := json.Unmarshal(data, &got); err != nil {
+ t.Fatalf("Unmarshal failed: %v", err)
+ }
+
+ if got.OrgID != tc.dealOrgData.OrgID {
+ t.Errorf("OrgID: got %q, want %q", got.OrgID, tc.dealOrgData.OrgID)
+ }
+ if got.Role != tc.dealOrgData.Role {
+ t.Errorf("Role: got %q, want %q", got.Role, tc.dealOrgData.Role)
+ }
+ if got.DomainLock != tc.dealOrgData.DomainLock {
+ t.Errorf("DomainLock: got %v, want %v", got.DomainLock, tc.dealOrgData.DomainLock)
+ }
+ })
+ }
+}
+
+func TestRequestDataJSONMarshal(t *testing.T) {
+ tests := []struct {
+ name string
+ req RequestData
+ }{
+ {
+ name: "full request",
+ req: RequestData{
+ Title: "Provide audited financial statements for the last 3 years",
+ ItemNumber: "1.3",
+ Section: "Financial",
+ Description: "Please provide audited P&L, balance sheet, and cash flow statements for FY2023, FY2024, FY2025.",
+ Priority: "high",
+ Status: "open",
+ AssigneeID: "user-123",
+ AssigneeName: "John Smith",
+ DueDate: "2026-03-15",
+ BuyerComment: "Need by end of week",
+ SellerComment: "Working on it",
+ Tags: []string{"financial", "urgent", "audit"},
+ LinkedEntryIDs: []string{"entry-1", "entry-2"},
+ },
+ },
+ {
+ name: "minimal request",
+ req: RequestData{
+ Title: "Basic info request",
+ ItemNumber: "A-1",
+ Section: "General",
+ Priority: "medium",
+ Status: "open",
+ },
+ },
+ {
+ name: "answered request",
+ req: RequestData{
+ Title: "Revenue breakdown by geography",
+ ItemNumber: "2.1",
+ Section: "Sales",
+ Description: "Provide revenue split by region (NA, EMEA, APAC)",
+ Priority: "low",
+ Status: "answered",
+ LinkedEntryIDs: []string{"answer-123"},
+ },
+ },
+ {
+ name: "not applicable",
+ req: RequestData{
+ Title: "Union contracts",
+ ItemNumber: "5.2",
+ Section: "HR",
+ Description: "Provide all union agreements",
+ Priority: "low",
+ Status: "not_applicable",
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ data, err := json.Marshal(tc.req)
+ if err != nil {
+ t.Fatalf("Marshal failed: %v", err)
+ }
+
+ var got RequestData
+ if err := json.Unmarshal(data, &got); err != nil {
+ t.Fatalf("Unmarshal failed: %v", err)
+ }
+
+ if got.Title != tc.req.Title {
+ t.Errorf("Title: got %q, want %q", got.Title, tc.req.Title)
+ }
+ if got.ItemNumber != tc.req.ItemNumber {
+ t.Errorf("ItemNumber: got %q, want %q", got.ItemNumber, tc.req.ItemNumber)
+ }
+ if got.Section != tc.req.Section {
+ t.Errorf("Section: got %q, want %q", got.Section, tc.req.Section)
+ }
+ if got.Priority != tc.req.Priority {
+ t.Errorf("Priority: got %q, want %q", got.Priority, tc.req.Priority)
+ }
+ if got.Status != tc.req.Status {
+ t.Errorf("Status: got %q, want %q", got.Status, tc.req.Status)
+ }
+ if len(got.Tags) != len(tc.req.Tags) {
+ t.Errorf("Tags length: got %d, want %d", len(got.Tags), len(tc.req.Tags))
+ }
+ if len(got.LinkedEntryIDs) != len(tc.req.LinkedEntryIDs) {
+ t.Errorf("LinkedEntryIDs length: got %d, want %d", len(got.LinkedEntryIDs), len(tc.req.LinkedEntryIDs))
+ }
+ })
+ }
+}
+
+func TestWorkstreamDataJSONMarshal(t *testing.T) {
+ tests := []struct {
+ name string
+ ws WorkstreamData
+ }{
+ {
+ name: "with description",
+ ws: WorkstreamData{
+ Name: "Financial Due Diligence",
+ Description: "All financial information requests",
+ },
+ },
+ {
+ name: "minimal",
+ ws: WorkstreamData{
+ Name: "Legal",
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ data, err := json.Marshal(tc.ws)
+ if err != nil {
+ t.Fatalf("Marshal failed: %v", err)
+ }
+
+ var got WorkstreamData
+ if err := json.Unmarshal(data, &got); err != nil {
+ t.Fatalf("Unmarshal failed: %v", err)
+ }
+
+ if got.Name != tc.ws.Name {
+ t.Errorf("Name: got %q, want %q", got.Name, tc.ws.Name)
+ }
+ if got.Description != tc.ws.Description {
+ t.Errorf("Description: got %q, want %q", got.Description, tc.ws.Description)
+ }
+ })
+ }
+}
+
+func TestRoleHierarchyContainsSuperAdmin(t *testing.T) {
+ // Verify super_admin is in the hierarchy and has the highest level
+ saLevel, ok := RoleHierarchy[RoleSuperAdmin]
+ if !ok {
+ t.Fatal("super_admin should be in RoleHierarchy")
+ }
+
+ // super_admin should be higher than all other roles
+ for role, level := range RoleHierarchy {
+ if role != RoleSuperAdmin && level >= saLevel {
+ t.Errorf("super_admin (%d) should be higher than %s (%d)", saLevel, role, level)
+ }
+ }
+}
diff --git a/portal/portal/emails/answer_approved.html b/portal/portal/emails/answer_approved.html
new file mode 100644
index 0000000..3d47e58
--- /dev/null
+++ b/portal/portal/emails/answer_approved.html
@@ -0,0 +1,85 @@
+{{define "answer_approved.html"}}
+
+
+
+
+
+ Your answer was approved ✓
+
+
+
+
+
+
+
+
+
+ DEALSPACE
+ |
+
+
+
+
+
+
+
+
+
+ Your answer was approved
+
+
+
+ Hi{{if .RecipientName}} {{.RecipientName}}{{end}},
+
+
+
+ Great news! Your answer for {{.RequestTitle}} has been approved.
+
+
+ {{if .Published}}
+
+
+ 📁 Published to Data Room — Your response is now visible to authorized buyers.
+
+
+ {{end}}
+
+ {{if .DataRoomURL}}
+
+
+ {{end}}
+
+
+ Thank you for your prompt response. Keep up the excellent work!
+
+ |
+
+
+
+
+ |
+
+ Questions? Contact support@muskepo.com
+
+
+ © 2026 Dealspace · Privacy Policy · Terms of Service
+
+ |
+
+
+ |
+
+
+
+
+{{end}}
diff --git a/portal/portal/emails/answer_rejected.html b/portal/portal/emails/answer_rejected.html
new file mode 100644
index 0000000..3a5da95
--- /dev/null
+++ b/portal/portal/emails/answer_rejected.html
@@ -0,0 +1,76 @@
+{{define "answer_rejected.html"}}
+
+
+
+
+
+ Your answer needs revision
+
+
+
+
+
+
+
+
+
+ DEALSPACE
+ |
+
+
+
+
+
+
+ Your answer needs revision
+
+
+
+ Hi{{if .RecipientName}} {{.RecipientName}}{{end}},
+
+
+
+ Your answer for {{.RequestTitle}} requires some changes before it can be approved.
+
+
+
+ {{if .Reason}}
+
+ Feedback
+ {{.Reason}}
+
+ {{end}}
+
+
+
+
+
+ Please update your response based on the feedback above. If you have any questions, you can reply directly in the request thread.
+
+ |
+
+
+
+
+ |
+
+ Questions? Contact support@muskepo.com
+
+
+ © 2026 Dealspace · Privacy Policy · Terms of Service
+
+ |
+
+
+ |
+
+
+
+
+{{end}}
diff --git a/portal/portal/emails/answer_submitted.html b/portal/portal/emails/answer_submitted.html
new file mode 100644
index 0000000..a496ead
--- /dev/null
+++ b/portal/portal/emails/answer_submitted.html
@@ -0,0 +1,92 @@
+{{define "answer_submitted.html"}}
+
+
+
+
+
+ {{.AnswererName}} submitted an answer for: {{.RequestTitle}}
+
+
+
+
+
+
+
+
+
+ DEALSPACE
+ |
+
+
+
+
+ |
+
+ ACTION REQUIRED
+
+
+
+ New answer submitted for review
+
+
+
+ Hi{{if .RecipientName}} {{.RecipientName}}{{end}},
+
+
+
+ {{.AnswererName}} has submitted an answer that needs your review.
+
+
+
+
+
+ |
+ Request
+ {{.RequestTitle}}
+
+ {{if .WorkstreamName}}
+ Workstream
+ {{.WorkstreamName}}
+ {{end}}
+
+ {{if .AnswerPreview}}
+ Preview
+ "{{truncate .AnswerPreview 200}}"
+ {{end}}
+ |
+
+
+
+
+
+
+
+ Once approved, this answer will be published to the data room and visible to authorized buyers.
+
+ |
+
+
+
+
+ |
+
+ Questions? Contact support@muskepo.com
+
+
+ © 2026 Dealspace · Privacy Policy · Terms of Service
+
+ |
+
+
+ |
+
+
+
+
+{{end}}
diff --git a/portal/portal/emails/invite.html b/portal/portal/emails/invite.html
new file mode 100644
index 0000000..3ba8281
--- /dev/null
+++ b/portal/portal/emails/invite.html
@@ -0,0 +1,76 @@
+{{define "invite.html"}}
+
+
+
+
+
+ You're invited to {{.ProjectName}}
+
+
+
+
+
+
+
+
+
+ DEALSPACE
+ |
+
+
+
+
+
+
+ You've been invited to join {{.ProjectName}}
+
+
+
+ Hi{{if .RecipientName}} {{.RecipientName}}{{end}},
+
+
+
+ {{.InviterName}} from {{.InviterOrg}} has invited you to join the due diligence process for {{.ProjectName}}.
+
+
+
+
+
+
+
+ What is Dealspace?
+
+ Dealspace is a secure platform for managing M&A due diligence. All documents are encrypted and watermarked. You control what gets shared and when.
+
+
+
+
+ ⏱ This invitation expires in {{if .ExpiresIn}}{{.ExpiresIn}}{{else}}7 days{{end}}.
+
+ |
+
+
+
+
+ |
+
+ Questions? Contact support@muskepo.com
+
+
+ © 2026 Dealspace · Privacy Policy · Terms of Service
+
+ |
+
+
+ |
+
+
+
+
+{{end}}
diff --git a/portal/portal/emails/request_forwarded.html b/portal/portal/emails/request_forwarded.html
new file mode 100644
index 0000000..317d184
--- /dev/null
+++ b/portal/portal/emails/request_forwarded.html
@@ -0,0 +1,85 @@
+{{define "request_forwarded.html"}}
+
+
+
+
+
+ {{.SenderName}} forwarded a request to you
+
+
+
+
+
+
+
+
+
+ DEALSPACE
+ |
+
+
+
+
+
+
+ Request forwarded to you
+
+
+
+ Hi{{if .RecipientName}} {{.RecipientName}}{{end}},
+
+
+
+ {{.SenderName}} has forwarded a request to you for your input.
+
+
+
+
+
+ |
+ Request
+ {{.RequestTitle}}
+
+ {{if .HasDueDate}}
+
+ Due Date
+ {{.DueDate}}
+
+ {{end}}
+ |
+
+
+
+
+
+
+
+ You can respond to this request directly in Dealspace. Your response will be routed back to {{.SenderName}} for review.
+
+ |
+
+
+
+
+ |
+
+ Questions? Contact support@muskepo.com
+
+
+ © 2026 Dealspace · Privacy Policy · Terms of Service
+
+ |
+
+
+ |
+
+
+
+
+{{end}}
diff --git a/portal/portal/emails/tasks_assigned.html b/portal/portal/emails/tasks_assigned.html
new file mode 100644
index 0000000..7f73ca3
--- /dev/null
+++ b/portal/portal/emails/tasks_assigned.html
@@ -0,0 +1,87 @@
+{{define "tasks_assigned.html"}}
+
+
+
+
+
+ You have {{.Count}} new task{{if gt .Count 1}}s{{end}} on {{.ProjectName}}
+
+
+
+
+
+
+
+
+
+ DEALSPACE
+ |
+
+
+
+
+
+
+ You have {{.Count}} new task{{if gt .Count 1}}s{{end}} on {{.ProjectName}}
+
+
+
+ Hi{{if .RecipientName}} {{.RecipientName}}{{end}},
+
+
+
+ The following request{{if gt .Count 1}}s have{{else}} has{{end}} been assigned to you:
+
+
+
+
+ {{range $i, $task := .Tasks}}
+ {{if lt $i 5}}
+
+ |
+ {{$task.Title}}
+ {{if $task.DueDate}}
+ Due: {{$task.DueDate}}{{if eq $task.Priority "high"}} · High Priority{{end}}
+ {{end}}
+ |
+
+ |
+ {{end}}
+ {{end}}
+
+
+ {{if gt .Count 5}}
+
+ ...and {{sub .Count 5}} more
+
+ {{end}}
+
+
+
+ |
+
+
+
+
+ |
+
+ You're receiving this because you're assigned to requests in {{.ProjectName}}.
+
+
+ © 2026 Dealspace · Manage Notifications · Unsubscribe
+
+ |
+
+
+ |
+
+
+
+
+{{end}}
diff --git a/portal/portal/emails/welcome.html b/portal/portal/emails/welcome.html
new file mode 100644
index 0000000..2f08a32
--- /dev/null
+++ b/portal/portal/emails/welcome.html
@@ -0,0 +1,61 @@
+{{define "welcome.html"}}
+
+
+
+
+
+ Welcome to Dealspace
+
+
+
+
+
+
+
+
+ DEALSPACE
+ |
+
+
+
+
+ Welcome to Dealspace{{if .RecipientName}}, {{.RecipientName}}{{end}}! 🎉
+
+
+ You're all set up and ready to go. Here are three quick tips to get you started:
+
+
+ 📥 Your inbox is your home
+ Everything assigned to you appears in your task inbox. Start there each day to see what needs your attention.
+
+
+ 📋 Requests, not folders
+ We organize by requests, not file folders. Each request tracks its own status, comments, and documents — all in one place.
+
+
+ 🤖 Ask Aria
+ Not sure where something is? Ask Aria, our AI assistant. She can find documents, answer questions, and guide you through the process.
+
+
+ We're excited to have you on board. Let's make this deal happen! 🚀
+ |
+
+
+ |
+ Need help? Contact support@muskepo.com
+ © 2026 Dealspace · Privacy Policy · Terms of Service
+ |
+
+
+ |
+
+
+
+
+{{end}}
diff --git a/portal/portal/static/themes.css b/portal/portal/static/themes.css
new file mode 100644
index 0000000..e51d614
--- /dev/null
+++ b/portal/portal/static/themes.css
@@ -0,0 +1,744 @@
+/**
+ * Dealspace Theme System
+ *
+ * All colors are CSS custom properties. Zero hardcoded values in templates.
+ * Switch themes by changing the class on : theme-light, theme-dark, theme-contrast
+ * Per-project overrides use data-project attribute.
+ */
+
+/* ==========================================================================
+ BASE TOKENS (shared across themes)
+ ========================================================================== */
+
+:root {
+ /* Spacing */
+ --space-xs: 0.25rem; /* 4px */
+ --space-sm: 0.5rem; /* 8px */
+ --space-md: 1rem; /* 16px */
+ --space-lg: 1.5rem; /* 24px */
+ --space-xl: 2rem; /* 32px */
+ --space-2xl: 3rem; /* 48px */
+
+ /* Border radius */
+ --radius-sm: 0.25rem; /* 4px */
+ --radius-md: 0.5rem; /* 8px */
+ --radius-lg: 0.75rem; /* 12px */
+ --radius-full: 9999px;
+
+ /* Typography */
+ --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ --font-mono: "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
+
+ --text-xs: 0.75rem; /* 12px */
+ --text-sm: 0.875rem; /* 14px */
+ --text-base: 1rem; /* 16px */
+ --text-lg: 1.125rem; /* 18px */
+ --text-xl: 1.25rem; /* 20px */
+ --text-2xl: 1.5rem; /* 24px */
+
+ --leading-tight: 1.25;
+ --leading-normal: 1.5;
+ --leading-relaxed: 1.75;
+
+ /* Transitions */
+ --transition-fast: 150ms ease;
+ --transition-normal: 250ms ease;
+
+ /* Z-index scale */
+ --z-dropdown: 100;
+ --z-modal: 200;
+ --z-toast: 300;
+}
+
+
+/* ==========================================================================
+ LIGHT THEME (default)
+ ========================================================================== */
+
+.theme-light,
+:root {
+ /* Surfaces */
+ --color-bg-primary: #ffffff;
+ --color-bg-secondary: #f9fafb;
+ --color-bg-tertiary: #f3f4f6;
+ --color-bg-inverse: #1f2937;
+ --color-bg-hover: #f3f4f6;
+ --color-bg-selected: #eff6ff;
+
+ /* Text */
+ --color-text-primary: #111827;
+ --color-text-secondary: #6b7280;
+ --color-text-tertiary: #9ca3af;
+ --color-text-inverse: #ffffff;
+ --color-text-link: #2563eb;
+ --color-text-link-hover: #1d4ed8;
+
+ /* Borders */
+ --color-border-primary: #e5e7eb;
+ --color-border-secondary: #f3f4f6;
+ --color-border-focus: #2563eb;
+
+ /* Semantic colors */
+ --color-accent: #2563eb;
+ --color-accent-hover: #1d4ed8;
+ --color-accent-light: #dbeafe;
+ --color-success: #059669;
+ --color-success-light: #d1fae5;
+ --color-warning: #d97706;
+ --color-warning-light: #fef3c7;
+ --color-error: #dc2626;
+ --color-error-light: #fee2e2;
+ --color-info: #0284c7;
+ --color-info-light: #e0f2fe;
+
+ /* Priority indicators */
+ --color-priority-high: #dc2626;
+ --color-priority-normal: #d97706;
+ --color-priority-low: #9ca3af;
+
+ /* Status badges */
+ --color-status-open: #6b7280;
+ --color-status-open-bg: #f3f4f6;
+ --color-status-assigned: #2563eb;
+ --color-status-assigned-bg: #dbeafe;
+ --color-status-answered: #d97706;
+ --color-status-answered-bg: #fef3c7;
+ --color-status-vetted: #7c3aed;
+ --color-status-vetted-bg: #ede9fe;
+ --color-status-published: #059669;
+ --color-status-published-bg: #d1fae5;
+ --color-status-closed: #9ca3af;
+ --color-status-closed-bg: #f3f4f6;
+
+ /* Components */
+ --color-header-bg: #ffffff;
+ --color-header-text: #111827;
+ --color-header-border: #e5e7eb;
+ --color-card-bg: #ffffff;
+ --color-card-border: #e5e7eb;
+ --color-card-shadow: rgba(0, 0, 0, 0.05);
+ --color-input-bg: #ffffff;
+ --color-input-border: #d1d5db;
+ --color-input-focus: #2563eb;
+ --color-input-placeholder: #9ca3af;
+ --color-badge-bg: #ef4444;
+ --color-badge-text: #ffffff;
+
+ /* Shadows */
+ --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
+ --shadow-focus: 0 0 0 3px rgba(37, 99, 235, 0.2);
+}
+
+
+/* ==========================================================================
+ DARK THEME
+ ========================================================================== */
+
+.theme-dark {
+ /* Surfaces */
+ --color-bg-primary: #111827;
+ --color-bg-secondary: #1f2937;
+ --color-bg-tertiary: #374151;
+ --color-bg-inverse: #f9fafb;
+ --color-bg-hover: #374151;
+ --color-bg-selected: #1e3a5f;
+
+ /* Text */
+ --color-text-primary: #f9fafb;
+ --color-text-secondary: #9ca3af;
+ --color-text-tertiary: #6b7280;
+ --color-text-inverse: #111827;
+ --color-text-link: #60a5fa;
+ --color-text-link-hover: #93c5fd;
+
+ /* Borders */
+ --color-border-primary: #374151;
+ --color-border-secondary: #4b5563;
+ --color-border-focus: #60a5fa;
+
+ /* Semantic colors */
+ --color-accent: #3b82f6;
+ --color-accent-hover: #60a5fa;
+ --color-accent-light: #1e3a5f;
+ --color-success: #10b981;
+ --color-success-light: #064e3b;
+ --color-warning: #f59e0b;
+ --color-warning-light: #78350f;
+ --color-error: #ef4444;
+ --color-error-light: #7f1d1d;
+ --color-info: #0ea5e9;
+ --color-info-light: #0c4a6e;
+
+ /* Priority indicators */
+ --color-priority-high: #ef4444;
+ --color-priority-normal: #f59e0b;
+ --color-priority-low: #6b7280;
+
+ /* Status badges */
+ --color-status-open: #9ca3af;
+ --color-status-open-bg: #374151;
+ --color-status-assigned: #60a5fa;
+ --color-status-assigned-bg: #1e3a5f;
+ --color-status-answered: #f59e0b;
+ --color-status-answered-bg: #78350f;
+ --color-status-vetted: #a78bfa;
+ --color-status-vetted-bg: #4c1d95;
+ --color-status-published: #10b981;
+ --color-status-published-bg: #064e3b;
+ --color-status-closed: #6b7280;
+ --color-status-closed-bg: #374151;
+
+ /* Components */
+ --color-header-bg: #1f2937;
+ --color-header-text: #f9fafb;
+ --color-header-border: #374151;
+ --color-card-bg: #1f2937;
+ --color-card-border: #374151;
+ --color-card-shadow: rgba(0, 0, 0, 0.3);
+ --color-input-bg: #374151;
+ --color-input-border: #4b5563;
+ --color-input-focus: #60a5fa;
+ --color-input-placeholder: #6b7280;
+ --color-badge-bg: #ef4444;
+ --color-badge-text: #ffffff;
+
+ /* Shadows */
+ --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
+ --shadow-focus: 0 0 0 3px rgba(96, 165, 250, 0.3);
+}
+
+
+/* ==========================================================================
+ HIGH CONTRAST THEME
+ ========================================================================== */
+
+.theme-contrast {
+ /* Surfaces */
+ --color-bg-primary: #000000;
+ --color-bg-secondary: #1a1a1a;
+ --color-bg-tertiary: #333333;
+ --color-bg-inverse: #ffffff;
+ --color-bg-hover: #333333;
+ --color-bg-selected: #003366;
+
+ /* Text */
+ --color-text-primary: #ffffff;
+ --color-text-secondary: #e0e0e0;
+ --color-text-tertiary: #b0b0b0;
+ --color-text-inverse: #000000;
+ --color-text-link: #66b3ff;
+ --color-text-link-hover: #99ccff;
+
+ /* Borders */
+ --color-border-primary: #ffffff;
+ --color-border-secondary: #808080;
+ --color-border-focus: #ffff00;
+
+ /* Semantic colors */
+ --color-accent: #66b3ff;
+ --color-accent-hover: #99ccff;
+ --color-accent-light: #003366;
+ --color-success: #00ff00;
+ --color-success-light: #003300;
+ --color-warning: #ffff00;
+ --color-warning-light: #333300;
+ --color-error: #ff3333;
+ --color-error-light: #330000;
+ --color-info: #00ffff;
+ --color-info-light: #003333;
+
+ /* Priority indicators */
+ --color-priority-high: #ff3333;
+ --color-priority-normal: #ffff00;
+ --color-priority-low: #808080;
+
+ /* Status badges */
+ --color-status-open: #ffffff;
+ --color-status-open-bg: #333333;
+ --color-status-assigned: #66b3ff;
+ --color-status-assigned-bg: #003366;
+ --color-status-answered: #ffff00;
+ --color-status-answered-bg: #333300;
+ --color-status-vetted: #cc99ff;
+ --color-status-vetted-bg: #330066;
+ --color-status-published: #00ff00;
+ --color-status-published-bg: #003300;
+ --color-status-closed: #808080;
+ --color-status-closed-bg: #1a1a1a;
+
+ /* Components */
+ --color-header-bg: #000000;
+ --color-header-text: #ffffff;
+ --color-header-border: #ffffff;
+ --color-card-bg: #1a1a1a;
+ --color-card-border: #ffffff;
+ --color-card-shadow: none;
+ --color-input-bg: #1a1a1a;
+ --color-input-border: #ffffff;
+ --color-input-focus: #ffff00;
+ --color-input-placeholder: #808080;
+ --color-badge-bg: #ff3333;
+ --color-badge-text: #ffffff;
+
+ /* Shadows - disabled for clarity */
+ --shadow-sm: none;
+ --shadow-md: none;
+ --shadow-lg: none;
+ --shadow-focus: 0 0 0 3px #ffff00;
+}
+
+/* High contrast specific: underline all links */
+.theme-contrast a {
+ text-decoration: underline;
+}
+
+.theme-contrast a:focus {
+ outline: 3px solid var(--color-border-focus);
+ outline-offset: 2px;
+}
+
+
+/* ==========================================================================
+ PER-PROJECT BRAND OVERRIDES
+ ========================================================================== */
+
+/* Project overrides cascade on top of the selected theme */
+/* Example: data-project="alpha" */
+
+[data-project="alpha"] {
+ --color-accent: #7c3aed; /* Purple accent */
+ --color-accent-hover: #6d28d9;
+ --color-header-bg: #7c3aed;
+ --color-header-text: #ffffff;
+}
+
+[data-project="beta"] {
+ --color-accent: #059669; /* Emerald accent */
+ --color-accent-hover: #047857;
+ --color-header-bg: #059669;
+ --color-header-text: #ffffff;
+}
+
+
+/* ==========================================================================
+ COMPONENT STYLES (using theme variables)
+ ========================================================================== */
+
+/* Base reset */
+*, *::before, *::after {
+ box-sizing: border-box;
+}
+
+body {
+ font-family: var(--font-sans);
+ font-size: var(--text-base);
+ line-height: var(--leading-normal);
+ color: var(--color-text-primary);
+ background-color: var(--color-bg-primary);
+ margin: 0;
+ padding: 0;
+}
+
+/* Header */
+.global-header {
+ display: flex;
+ align-items: center;
+ gap: var(--space-lg);
+ padding: var(--space-md) var(--space-lg);
+ background: var(--color-header-bg);
+ border-bottom: 1px solid var(--color-header-border);
+}
+
+.global-header .logo {
+ font-size: var(--text-xl);
+ font-weight: 700;
+ color: var(--color-header-text);
+}
+
+/* Project selector */
+.project-select {
+ flex: 1;
+ max-width: 400px;
+ padding: var(--space-sm) var(--space-md);
+ font-size: var(--text-sm);
+ color: var(--color-text-primary);
+ background: var(--color-input-bg);
+ border: 1px solid var(--color-input-border);
+ border-radius: var(--radius-md);
+ cursor: pointer;
+}
+
+.project-select:focus {
+ outline: none;
+ border-color: var(--color-input-focus);
+ box-shadow: var(--shadow-focus);
+}
+
+/* Workstream tabs */
+.workstream-tabs {
+ display: flex;
+ gap: var(--space-xs);
+ padding: 0 var(--space-lg);
+ background: var(--color-bg-secondary);
+ border-bottom: 1px solid var(--color-border-primary);
+}
+
+.workstream-tabs a {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-sm);
+ padding: var(--space-md) var(--space-lg);
+ font-size: var(--text-sm);
+ font-weight: 500;
+ color: var(--color-text-secondary);
+ text-decoration: none;
+ border-bottom: 2px solid transparent;
+ transition: color var(--transition-fast), border-color var(--transition-fast);
+}
+
+.workstream-tabs a:hover {
+ color: var(--color-text-primary);
+}
+
+.workstream-tabs a.active {
+ color: var(--color-accent);
+ border-bottom-color: var(--color-accent);
+}
+
+/* Badge */
+.badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 1.25rem;
+ height: 1.25rem;
+ padding: 0 var(--space-xs);
+ font-size: var(--text-xs);
+ font-weight: 600;
+ color: var(--color-badge-text);
+ background: var(--color-badge-bg);
+ border-radius: var(--radius-full);
+}
+
+.badge-muted {
+ background: var(--color-bg-tertiary);
+ color: var(--color-text-secondary);
+}
+
+/* Task card */
+.task-card {
+ display: block;
+ padding: var(--space-md) var(--space-lg);
+ background: var(--color-card-bg);
+ border: 1px solid var(--color-card-border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-sm);
+ text-decoration: none;
+ color: inherit;
+ transition: box-shadow var(--transition-fast), border-color var(--transition-fast);
+}
+
+.task-card:hover {
+ border-color: var(--color-border-focus);
+ box-shadow: var(--shadow-md);
+}
+
+.task-card-header {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-md);
+ margin-bottom: var(--space-sm);
+}
+
+.task-card-priority {
+ width: 0.625rem;
+ height: 0.625rem;
+ border-radius: var(--radius-full);
+ flex-shrink: 0;
+ margin-top: 0.375rem;
+}
+
+.task-card-priority.high { background: var(--color-priority-high); }
+.task-card-priority.normal { background: var(--color-priority-normal); }
+.task-card-priority.low { background: var(--color-priority-low); }
+
+.task-card-ref {
+ font-size: var(--text-sm);
+ font-weight: 600;
+ color: var(--color-text-secondary);
+ font-family: var(--font-mono);
+}
+
+.task-card-title {
+ flex: 1;
+ font-size: var(--text-base);
+ font-weight: 600;
+ color: var(--color-text-primary);
+}
+
+.task-card-due {
+ font-size: var(--text-sm);
+ color: var(--color-text-secondary);
+ white-space: nowrap;
+}
+
+.task-card-due.overdue {
+ color: var(--color-error);
+ font-weight: 600;
+}
+
+.task-card-due.today {
+ color: var(--color-warning);
+ font-weight: 600;
+}
+
+.task-card-meta {
+ font-size: var(--text-sm);
+ color: var(--color-text-secondary);
+ margin-bottom: var(--space-sm);
+}
+
+.task-card-preview {
+ font-size: var(--text-sm);
+ color: var(--color-text-tertiary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Status badge */
+.status-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: var(--space-xs) var(--space-sm);
+ font-size: var(--text-xs);
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.025em;
+ border-radius: var(--radius-sm);
+}
+
+.status-badge.open { color: var(--color-status-open); background: var(--color-status-open-bg); }
+.status-badge.assigned { color: var(--color-status-assigned); background: var(--color-status-assigned-bg); }
+.status-badge.answered { color: var(--color-status-answered); background: var(--color-status-answered-bg); }
+.status-badge.vetted { color: var(--color-status-vetted); background: var(--color-status-vetted-bg); }
+.status-badge.published { color: var(--color-status-published); background: var(--color-status-published-bg); }
+.status-badge.closed { color: var(--color-status-closed); background: var(--color-status-closed-bg); }
+
+/* Buttons */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-sm);
+ padding: var(--space-sm) var(--space-md);
+ font-size: var(--text-sm);
+ font-weight: 500;
+ text-decoration: none;
+ border-radius: var(--radius-md);
+ border: 1px solid transparent;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+}
+
+.btn-primary {
+ color: var(--color-text-inverse);
+ background: var(--color-accent);
+ border-color: var(--color-accent);
+}
+
+.btn-primary:hover {
+ background: var(--color-accent-hover);
+ border-color: var(--color-accent-hover);
+}
+
+.btn-secondary {
+ color: var(--color-text-primary);
+ background: var(--color-bg-secondary);
+ border-color: var(--color-border-primary);
+}
+
+.btn-secondary:hover {
+ background: var(--color-bg-tertiary);
+}
+
+.btn:focus {
+ outline: none;
+ box-shadow: var(--shadow-focus);
+}
+
+/* Form inputs */
+.input, .textarea {
+ width: 100%;
+ padding: var(--space-sm) var(--space-md);
+ font-size: var(--text-base);
+ color: var(--color-text-primary);
+ background: var(--color-input-bg);
+ border: 1px solid var(--color-input-border);
+ border-radius: var(--radius-md);
+}
+
+.input::placeholder, .textarea::placeholder {
+ color: var(--color-input-placeholder);
+}
+
+.input:focus, .textarea:focus {
+ outline: none;
+ border-color: var(--color-input-focus);
+ box-shadow: var(--shadow-focus);
+}
+
+.textarea {
+ min-height: 100px;
+ resize: vertical;
+}
+
+/* Thread messages */
+.thread-message {
+ padding: var(--space-md);
+ background: var(--color-card-bg);
+ border: 1px solid var(--color-card-border);
+ border-radius: var(--radius-lg);
+ margin-bottom: var(--space-md);
+}
+
+.thread-message-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: var(--space-sm);
+}
+
+.thread-message-author {
+ font-weight: 600;
+ color: var(--color-text-primary);
+}
+
+.thread-message-time {
+ font-size: var(--text-sm);
+ color: var(--color-text-tertiary);
+}
+
+.thread-message-body {
+ color: var(--color-text-primary);
+ line-height: var(--leading-relaxed);
+}
+
+.thread-message-attachment {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-sm);
+ margin-top: var(--space-sm);
+ padding: var(--space-sm) var(--space-md);
+ background: var(--color-bg-tertiary);
+ border-radius: var(--radius-md);
+ font-size: var(--text-sm);
+ color: var(--color-text-link);
+ text-decoration: none;
+}
+
+.thread-message-attachment:hover {
+ color: var(--color-text-link-hover);
+}
+
+/* Routing chain */
+.routing-chain {
+ display: flex;
+ align-items: center;
+ gap: var(--space-md);
+ padding: var(--space-md);
+ background: var(--color-bg-secondary);
+ border-radius: var(--radius-lg);
+ overflow-x: auto;
+}
+
+.routing-step {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ white-space: nowrap;
+ font-size: var(--text-sm);
+ color: var(--color-text-secondary);
+}
+
+.routing-step.current {
+ font-weight: 600;
+ color: var(--color-accent);
+}
+
+.routing-arrow {
+ color: var(--color-text-tertiary);
+}
+
+/* Empty state */
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: var(--space-2xl);
+ text-align: center;
+}
+
+.empty-state-icon {
+ font-size: 3rem;
+ margin-bottom: var(--space-md);
+}
+
+.empty-state-title {
+ font-size: var(--text-xl);
+ font-weight: 600;
+ color: var(--color-text-primary);
+ margin-bottom: var(--space-sm);
+}
+
+.empty-state-text {
+ color: var(--color-text-secondary);
+}
+
+
+/* ==========================================================================
+ UTILITY CLASSES
+ ========================================================================== */
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.container {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 0 var(--space-lg);
+}
+
+.flex { display: flex; }
+.flex-col { flex-direction: column; }
+.items-center { align-items: center; }
+.justify-between { justify-content: space-between; }
+.gap-sm { gap: var(--space-sm); }
+.gap-md { gap: var(--space-md); }
+.gap-lg { gap: var(--space-lg); }
+
+.mt-sm { margin-top: var(--space-sm); }
+.mt-md { margin-top: var(--space-md); }
+.mt-lg { margin-top: var(--space-lg); }
+.mb-sm { margin-bottom: var(--space-sm); }
+.mb-md { margin-bottom: var(--space-md); }
+.mb-lg { margin-bottom: var(--space-lg); }
+
+.text-sm { font-size: var(--text-sm); }
+.text-lg { font-size: var(--text-lg); }
+.text-muted { color: var(--color-text-secondary); }
+.font-semibold { font-weight: 600; }
diff --git a/portal/portal/templates/admin/dashboard.html b/portal/portal/templates/admin/dashboard.html
new file mode 100644
index 0000000..1c236be
--- /dev/null
+++ b/portal/portal/templates/admin/dashboard.html
@@ -0,0 +1,111 @@
+
+
+
+
+ Admin — Dealspace
+
+
+
+
+
+
+
+
+
+ Admin Dashboard
+ Platform overview and management.
+
+
+
+
+
+
+
+
+
+
diff --git a/portal/portal/templates/app/orgs.html b/portal/portal/templates/app/orgs.html
new file mode 100644
index 0000000..7c13125
--- /dev/null
+++ b/portal/portal/templates/app/orgs.html
@@ -0,0 +1,154 @@
+
+
+
+
+ Organizations — Dealspace
+
+
+
+
+
+
+
+
+
+
+
+
Organizations
+
Company directory — parties eligible to participate in deals.
+
+
+
+
+
+
🏢
+
No organizations yet
+
Add buyer, seller, IB, and advisor organizations.
+
+
+
+
+
+
+
+
New Organization
+
+
+
+
+
+
+
Comma-separated. Only emails from these domains can be invited for this org.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/portal/portal/templates/app/project.html b/portal/portal/templates/app/project.html
new file mode 100644
index 0000000..43d7c25
--- /dev/null
+++ b/portal/portal/templates/app/project.html
@@ -0,0 +1,250 @@
+
+
+
+
+ Project — Dealspace
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
📋
+
No requests yet
+
Import a diligence checklist or create requests manually.
+
+
+
+
+
+
Organizations participating in this deal.
+
+
+
+
+
+
+
People with access to this deal.
+
+
+
+
+
+
+
+
+
Import Diligence Checklist
+
+
+
+
+
+
diff --git a/portal/portal/templates/app/projects.html b/portal/portal/templates/app/projects.html
new file mode 100644
index 0000000..3065344
--- /dev/null
+++ b/portal/portal/templates/app/projects.html
@@ -0,0 +1,155 @@
+
+
+
+
+ Projects — Dealspace
+
+
+
+
+
+
+
+
+
+
+
+
Projects
+
All deals you have access to.
+
+
+
+
+
+
📁
+
No projects yet
+
You haven't been added to any deals yet.
+
+
+
+
+
+
+
+
New Project
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/portal/portal/templates/app/request.html b/portal/portal/templates/app/request.html
new file mode 100644
index 0000000..3938a67
--- /dev/null
+++ b/portal/portal/templates/app/request.html
@@ -0,0 +1,204 @@
+
+
+
+
+ Request — Dealspace
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Response
+
+
+
📎
+
Drop files here or click to upload
+
PDF, DOCX, XLSX, images
+
+
+
+
+
+
+
+
Comments
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/portal/portal/templates/app/tasks.html b/portal/portal/templates/app/tasks.html
new file mode 100644
index 0000000..d93988d
--- /dev/null
+++ b/portal/portal/templates/app/tasks.html
@@ -0,0 +1,169 @@
+
+
+
+
+
+ My Tasks — Dealspace
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Here are your pending tasks.
+
+
+
+
+
+
+
+
🎉
+
You're all caught up
+
No tasks need your attention right now.
+
+
+
+
+
+
+
diff --git a/portal/portal/templates/auth/login.html b/portal/portal/templates/auth/login.html
new file mode 100644
index 0000000..2ff5788
--- /dev/null
+++ b/portal/portal/templates/auth/login.html
@@ -0,0 +1,206 @@
+
+
+
+
+
+ Login — Dealspace
+
+
+
+
+
+
+
+
+
+
+ Dealspace
+
+
Secure M&A deal management
+
+
+
+
+
Sign in
+
Enter your email to receive a login code.
+
+
+
+
+
+
+
+
+
Enter your code
+
+ We sent a 6-digit code to
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Don’t have an account? Dealspace is invite-only.
+ Request access on muskepo.com
+
+
© 2026 Muskepo B.V. — Amsterdam
+
+
+
+
+
diff --git a/portal/portal/templates/auth/setup.html b/portal/portal/templates/auth/setup.html
new file mode 100644
index 0000000..1a4bd05
--- /dev/null
+++ b/portal/portal/templates/auth/setup.html
@@ -0,0 +1,100 @@
+
+
+
+
+
+ Setup — Dealspace
+
+
+
+
+
+
+
+
+
+
+ Dealspace
+
+
First-time setup
+
+
+
+
+
Create admin account
+
This will be the first administrator account for your Dealspace instance.
+
+
+
+
+
+
+
+
© 2026 Muskepo B.V. — Amsterdam
+
+
+
+
+
diff --git a/portal/portal/templates/request-detail.html b/portal/portal/templates/request-detail.html
new file mode 100644
index 0000000..c3579a6
--- /dev/null
+++ b/portal/portal/templates/request-detail.html
@@ -0,0 +1,572 @@
+
+
+
+
+
+ FIN-042: Audited financials FY2024 — Dealspace
+
+
+
+
+
+
+
+
+
+
+
+
+ ← Back to Inbox
+
+
+
+
+
+
+
+
+
+ Routing
+
+
+
+ Acme Capital (Buyer)
+
+ →
+
+ J. Smith (IB)
+
+ →
+
+ M. Chen (CFO)
+
+ →
+
+ ★ You
+
+
+
+ When done, returns to: M. Chen (CFO)
+
+
+
+
+
+
+
+
+
+
+ Thread
+
+
+
+
+
+
Please provide audited financial statements for FY2024.
+
We need the following documents:
+
+ - Income statement (P&L)
+ - Balance sheet
+ - Cash flow statement
+ - Auditor's opinion letter
+
+
Please ensure these are the final audited versions, not draft.
+
+
+
+
+
+
+
+
@accountant Can you pull these from the ERP? The audit was finalized last month.
+
Attaching last year's format for reference so we maintain consistency.
+
+
+ 📎 fy2023-financials-format.xlsx
+
+
+
+
+
+
+
+
On it. Just waiting for the auditor to send the final signed opinion letter. Should have everything by EOD Thursday.
+
+
+
+
+
+
+
+ Your Response
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/portal/portal/templates/task-inbox.html b/portal/portal/templates/task-inbox.html
new file mode 100644
index 0000000..995cf91
--- /dev/null
+++ b/portal/portal/templates/task-inbox.html
@@ -0,0 +1,258 @@
+
+
+
+
+
+ My Tasks — Dealspace
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+