Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions frontend/src/__tests__/history-approval-queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,15 @@ jest.mock('../state', () => ({
subscribeAccount: jest.fn().mockReturnValue(() => {}),
}));

jest.mock('../recommendations', () => ({
getAccountName: jest.fn((id: string) => id),
}));

import * as api from '../api';
import { confirmDialog } from '../confirmDialog';
import { showToast } from '../toast';
import { getCurrentUser } from '../state';
import { getAccountName } from '../recommendations';

const ADMIN_USER = { id: 'admin-uuid', email: 'admin@example.com', role: 'admin' };

Expand Down Expand Up @@ -263,4 +268,128 @@ describe('Approval queue card (issue #340 sub-task)', () => {
expect(queue.querySelectorAll('.history-approve-btn[data-approve-id="p-pending"]').length).toBe(1);
expect(list.querySelectorAll('.history-approve-btn[data-approve-id="p-pending"]').length).toBe(1);
});

// Issue #704 — new column tests

test('renders email in Created-by column when created_by_user_email is set', async () => {
(getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER);
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [
makeRow({
purchase_id: 'p-email',
status: 'pending',
created_by_user_id: 'some-uuid',
created_by_user_email: 'alice@example.com',
}),
],
});

await loadHistory();

const queue = document.getElementById('purchases-approval-queue')!;
expect(queue.textContent).toContain('alice@example.com');
// Raw UUID must NOT appear when email is available.
expect(queue.textContent).not.toContain('some-uuid');
});

test('falls back to UUID in Created-by column when email is absent', async () => {
(getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER);
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [
makeRow({
purchase_id: 'p-uuid',
status: 'pending',
created_by_user_id: 'fallback-uuid',
// created_by_user_email intentionally absent
}),
],
});

await loadHistory();

const queue = document.getElementById('purchases-approval-queue')!;
expect(queue.textContent).toContain('fallback-uuid');
});

test('renders Account column via getAccountName when account_id is set', async () => {
(getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER);
(getAccountName as jest.Mock).mockReturnValue('Production Account');
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [
makeRow({
purchase_id: 'p-acct',
status: 'pending',
account_id: '123456789012',
}),
],
});

await loadHistory();

const queue = document.getElementById('purchases-approval-queue')!;
expect(getAccountName).toHaveBeenCalledWith('123456789012');
expect(queue.textContent).toContain('Production Account');
});

test('renders Term and Payment columns', async () => {
(getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER);
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [
makeRow({
purchase_id: 'p-term',
status: 'pending',
term: 3,
payment: 'no-upfront',
}),
],
});

await loadHistory();

const queue = document.getElementById('purchases-approval-queue')!;
// formatTerm mock renders "3 Years"
expect(queue.textContent).toContain('3 Years');
expect(queue.textContent).toContain('no-upfront');
});

test('renders Monthly Cost column when monthly_cost is present', async () => {
(getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER);
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [
makeRow({
purchase_id: 'p-monthly',
status: 'pending',
monthly_cost: 42.5,
}),
],
});

await loadHistory();

const queue = document.getElementById('purchases-approval-queue')!;
// formatCurrency mock renders ".5"
expect(queue.textContent).toContain('.5');
});

test('renders Account/Term/Payment/Monthly column headers', async () => {
(getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER);
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [makeRow({ status: 'pending', created_by_user_id: ADMIN_USER.id })],
});

await loadHistory();

const queue = document.getElementById('purchases-approval-queue')!;
const headerText = queue.querySelector('thead')?.textContent || '';
expect(headerText).toContain('Account');
expect(headerText).toContain('Term');
expect(headerText).toContain('Payment');
expect(headerText).toContain('Monthly Cost');
});
});
25 changes: 24 additions & 1 deletion frontend/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { buildApprovalDetailsBody } from './approval-details';
import { showToast } from './toast';
import { getCurrentUser } from './state';
import { showSkeletonRows, teardownSkeleton } from './lib/skeleton';
import { getAccountName } from './recommendations';

const VALID_PROVIDERS: api.Provider[] = ['aws', 'azure', 'gcp'];

Expand Down Expand Up @@ -859,14 +860,32 @@ export function renderApprovalQueue(purchases: HistoryPurchase[]): void {
const rows = pending.map(p => {
const actions = renderPendingActionButtons(p);
const actionsCell = actions || '<span class="muted">-</span>';
const createdBy = p.created_by_user_id ? escapeHtml(p.created_by_user_id) : '<span class="muted">-</span>';
// Show email when resolved; fall back to UUID so the cancel-own gate still
// has something human-readable to show. Fall back to "-" for scheduler rows.
const createdBy = p.created_by_user_email
? escapeHtml(p.created_by_user_email)
: p.created_by_user_id
? escapeHtml(p.created_by_user_id)
: '<span class="muted">-</span>';
const accountCell = p.account_id
? escapeHtml(getAccountName(p.account_id))
: '<span class="muted">-</span>';
const termCell = p.term ? escapeHtml(formatTerm(p.term)) : '<span class="muted">-</span>';
const paymentCell = p.payment ? escapeHtml(p.payment) : '<span class="muted">-</span>';
const monthlyCostCell = p.monthly_cost != null
? formatCurrency(p.monthly_cost)
: '<span class="muted">-</span>';
const execIdAttr = p.purchase_id ? ` data-execution-id="${escapeHtml(p.purchase_id)}"` : '';
return `
<tr${execIdAttr}>
<td>${formatDate(p.timestamp)}</td>
<td>${accountCell}</td>
<td>${providerCell(p)}</td>
<td>${escapeHtml(p.service)}</td>
<td>${p.count}</td>
<td>${termCell}</td>
<td>${paymentCell}</td>
<td>${monthlyCostCell}</td>
<td>${formatCurrency(p.upfront_cost)}</td>
<td class="savings">${formatCurrency(p.estimated_savings)}</td>
<td>${createdBy}</td>
Expand All @@ -880,9 +899,13 @@ export function renderApprovalQueue(purchases: HistoryPurchase[]): void {
<thead>
<tr>
<th>Date</th>
<th>Account</th>
<th>Provider</th>
<th>Service</th>
<th>Count</th>
<th>Term</th>
<th>Payment</th>
<th>Monthly Cost</th>
<th>Upfront Cost</th>
<th>Monthly Savings</th>
<th>Created by</th>
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/recommendations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ let checkedPurchaseModalInitialised = false;
// Cache of account ID → name for column display
let accountNamesCache: Map<string, string> = new Map();

/**
* Returns the display name for a cloud account ID, falling back to the raw
* ID when the name has not been cached yet. Exported so other modules (e.g.
* history.ts Approval Queue) can resolve account names without importing the
* full recommendations data model.
*/
export function getAccountName(accountId: string): string {
return accountNamesCache.get(accountId) || accountId;
}

// issues #225 + #226: expand/collapse state for cell grouping.
// Contains the cellKey strings of cells the user has explicitly expanded.
// Cleared on page load / full refresh; survives per-column filter/sort re-renders.
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,11 @@ export interface HistoryPurchase {
region: string;
count: number;
term: number;
payment?: string;
upfront_cost: number;
monthly_cost?: number;
estimated_savings: number;
account_id?: string;
plan_name?: string;
// Status is set by the API to "completed" or "pending". Legacy pre-schema
// rows come back without it; the UI treats absent status as completed for
Expand All @@ -195,6 +198,12 @@ export interface HistoryPurchase {
// non-ok rows. "failed" → backend's send-error message; "expired" → canned
// 7-day-window reminder. Empty on completed / pending rows.
status_description?: string;
// CreatedByUserEmail: resolved email of the user who created the
// underlying execution. Populated on synthesised execution rows when the
// auth lookup succeeds. Empty for scheduler-driven rows, legacy NULL-
// creator rows, and completed purchase_history rows. The Approval Queue
// renders this instead of the raw UUID when present.
created_by_user_email?: string;
// CreatedByUserID: UUID of the user who created the underlying execution.
// Populated on every synthesised purchase_executions row the History
// endpoint returns (pending, notified, failed, expired, cancelled — see
Expand Down
58 changes: 45 additions & 13 deletions internal/api/handler_history.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ func (h *Handler) fetchExecutionsAsHistory(ctx context.Context, filters historyF
return nil
}
approver := h.resolvePendingApproverEmail(ctx)
userEmailCache := h.resolveUserEmails(ctx, executions)
out := make([]config.PurchaseHistoryRecord, 0, len(executions))
for _, exec := range executions {
// Dedup: a normal completed execution is already represented by its
Expand All @@ -142,7 +143,36 @@ func (h *Handler) fetchExecutionsAsHistory(ctx context.Context, filters historyF
if !filters.matchesExecution(exec) {
continue
}
out = append(out, executionToHistoryRow(exec, approver))
var createdByEmail string
if exec.CreatedByUserID != nil {
createdByEmail = userEmailCache[*exec.CreatedByUserID]
}
out = append(out, executionToHistoryRow(exec, approver, createdByEmail))
}
return out
}

// resolveUserEmails builds a map of user-ID to email by calling GetUser once
// per unique non-nil CreatedByUserID found in the execution list. Lookup
// failures are logged and skipped — a missing email degrades gracefully (the
// UI falls back to the raw UUID via created_by_user_id). Called once per
// /api/history request so the cost is proportional to the number of distinct
// creators, not the number of execution rows.
func (h *Handler) resolveUserEmails(ctx context.Context, executions []config.PurchaseExecution) map[string]string {
seen := make(map[string]struct{})
for _, exec := range executions {
if exec.CreatedByUserID != nil && *exec.CreatedByUserID != "" {
seen[*exec.CreatedByUserID] = struct{}{}
}
}
out := make(map[string]string, len(seen))
for uid := range seen {
user, err := h.auth.GetUser(ctx, uid)
if err != nil {
logging.Warnf("history: failed to resolve email for user %s: %v", uid, err)
continue
}
out[uid] = user.Email
}
return out
}
Expand Down Expand Up @@ -189,8 +219,9 @@ func (h *Handler) resolvePendingApproverEmail(ctx context.Context) string {
// For pending/notified rows we attach the approver email so the UI can show
// "awaiting approval from X"; for failed rows we surface the stored Error
// message as the status description so the user sees WHY it failed (e.g.
// "send failed: Missing domain").
func executionToHistoryRow(exec config.PurchaseExecution, approver string) config.PurchaseHistoryRecord {
// "send failed: Missing domain"). createdByEmail is the resolved email for
// the execution's creator (empty when not resolvable).
func executionToHistoryRow(exec config.PurchaseExecution, approver, createdByEmail string) config.PurchaseHistoryRecord {
var accountID string
if exec.CloudAccountID != nil {
accountID = *exec.CloudAccountID
Expand All @@ -204,16 +235,17 @@ func executionToHistoryRow(exec config.PurchaseExecution, approver string) confi
retryExecID = *exec.RetryExecutionID
}
row := config.PurchaseHistoryRecord{
AccountID: accountID,
PurchaseID: exec.ExecutionID,
Timestamp: exec.ScheduledDate,
Provider: collapseRecommendationProvider(exec.Recommendations),
Count: len(exec.Recommendations),
PlanID: exec.PlanID,
Status: exec.Status,
CreatedByUserID: createdBy,
RetryExecutionID: retryExecID,
RetryAttemptN: exec.RetryAttemptN,
AccountID: accountID,
PurchaseID: exec.ExecutionID,
Timestamp: exec.ScheduledDate,
Provider: collapseRecommendationProvider(exec.Recommendations),
Count: len(exec.Recommendations),
PlanID: exec.PlanID,
Status: exec.Status,
CreatedByUserID: createdBy,
CreatedByUserEmail: createdByEmail,
RetryExecutionID: retryExecID,
RetryAttemptN: exec.RetryAttemptN,
}
projectRecommendationFields(&row, exec)
// Compute ops_hint at read time (issue #47, Q3) so updates to the
Expand Down
Loading
Loading