Skip to content
Merged
15 changes: 11 additions & 4 deletions backend/app/main.py

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Hint filter forces an extra source-context query

has_source_context is derived from the (now hint-filtered) source_customer_rows, so a filtered request that matches no code triggers the has_real_source_context fallback query (main.py). The fallback is global, so the result is unchanged, but an extra DB round-trip runs per filtered request.

(Refers to this code)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@
from backend.app.post_eligibility import (
SOURCE_POST_ELIGIBILITY_SQL,
SOURCE_POST_READER_ELIGIBILITY_SQL,
SOURCE_POST_VISIBILITY_SQL,
WRITING_SOURCE_DETAIL_STATE_CODE,
normalize_source_detail_state_code,
source_post_state_visibility_sql,
Expand Down Expand Up @@ -829,12 +830,15 @@ def _observed_hierarchy_ids(rows: list[asyncpg.Record]) -> set[str]:

@app.get("/api/customer-master")
async def read_customer_master(
hint_code: str | None = Query(default=None, max_length=255),
account: CurrentAccount = Depends(get_current_account),
pool: asyncpg.Pool = Depends(get_pool),
) -> dict[str, Any]:
"""Return the authorized customer catalog and its cataloged Keymen."""
_require_post_read(account)
if not account.corporate_entity_ids:
requested_hint_code = (hint_code or "").strip() or None
authorized_entity_ids = list(account.corporate_entity_ids)
if not authorized_entity_ids:
return {
"corporate_entities": [],
"keymen": [],
Expand All @@ -857,8 +861,10 @@ async def read_customer_master(
from source_post
where (nullif(btrim(source_customer_code), '') is not null
or nullif(btrim(source_customer_name), '') is not null)
and (visibility_code = 'public' or corporate_entity_id = any($1::uuid[]))
and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')}
and ($2::text is null
or nullif(btrim(source_customer_code), '') = $2::text)
and {SOURCE_POST_VISIBILITY_SQL.format(alias='source_post', authorized_entity_ids='$1')}
and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')}
Comment on lines +864 to +867

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Visibility SQL refactor is behavior-preserving

The customer-rows query swaps its inline visibility clause for SOURCE_POST_VISIBILITY_SQL.format(alias='source_post', authorized_entity_ids='$1'), which expands to the identical predicate since source_post is the unaliased table. Behavior is preserved. The author-rows query just below still uses the inline form, so the endpoint now carries two spellings of the same predicate.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

), ranked as (
select scoped.*,
row_number() over (
Expand Down Expand Up @@ -902,7 +908,8 @@ async def read_customer_master(
and related.customer_name_group is not distinct from top_groups.customer_name_group
order by top_groups.post_count desc, top_groups.customer_code, top_groups.customer_name
""",
list(account.corporate_entity_ids),
authorized_entity_ids,
requested_hint_code,
)
# Safe SQL: the evidence query uses only closed schema fragments; authorized entity ids are bound.
source_author_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
Expand Down
12 changes: 12 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1500,6 +1500,18 @@ async def capture_relationship_entity_ids(conn, corporate_entity_ids):
"provenance": "source_post.source_customer_code/source_post.source_customer_name",
}
]
filtered_response = client.get(
"/api/customer-master?hint_code=TEST-CUSTOMER-001",
headers={"Authorization": f"Bearer {demo_analyst_token}"},
)
assert filtered_response.status_code == 200
assert filtered_response.json()["source_customer_hints"] == body["source_customer_hints"]
missing_response = client.get(
"/api/customer-master?hint_code=NOT-OBSERVED",
headers={"Authorization": f"Bearer {demo_analyst_token}"},
)
assert missing_response.status_code == 200
assert missing_response.json()["source_customer_hints"] == []
author_hint = body["source_author_hints"]
assert len(author_hint) == 1
assert author_hint[0]["author_code"] == "TEST-AUTHOR-001"
Expand Down
48 changes: 48 additions & 0 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -1122,6 +1122,34 @@ a:focus-visible {
margin: 1.25rem 0 0;
}

.customer-master-hint-search {
display: grid;
gap: 0.45rem;
margin-top: 1.25rem;
}

.customer-master-hint-search label {
color: var(--text-h);
font-size: 0.85rem;
font-weight: 700;
}

.customer-master-hint-search-row {
display: flex;
gap: 0.55rem;
}

.customer-master-hint-search-row input {
min-width: 0;
flex: 1;
min-height: 2.75rem;
padding: 0.45rem 0.65rem;
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--surface);
color: var(--text-h);
}

.customer-master-scope-filter {
display: grid;
grid-template-columns: auto minmax(12rem, 1fr);
Expand Down Expand Up @@ -1546,6 +1574,26 @@ a:focus-visible {
text-transform: uppercase;
}

.source-lineage-combination {
border-color: var(--color-accent-info);
background: var(--color-accent-info-background);
color: var(--text-h);
gap: 0.3rem;
}

.source-lineage-combination-code {
color: var(--color-primary);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.82rem;
letter-spacing: 0.08em;
}

.source-lineage-combination-label {
color: var(--text-muted);
font-weight: 600;
text-transform: none;
}

.post-card .post-list-item:focus-visible {
outline: 2px solid var(--color-focus-border);
outline-offset: -3px;
Expand Down
88 changes: 86 additions & 2 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1129,6 +1129,15 @@ describe("App, authenticated", () => {
source_detail_state_code: options?.sourceDetailStateCode,
visibility_code: "public",
visibility_label: "Public",
source_lineage_hints: {
combination_code: "1000",
commercial_context_code: "customer_only_candidate",
inference_status_code: "inferred_from_field_presence",
present_fields: ["customer"],
missing_fields: ["order_pool", "sales_order", "sales_order_item"],
lifecycle_vector: "Z-A-I-ALIVE",
deleted_marker_present: false,
},
created_at: "2026-01-01T00:00:00Z",
},
],
Expand Down Expand Up @@ -1160,6 +1169,15 @@ describe("App, authenticated", () => {
source_detail_state_code: options?.sourceDetailStateCode,
visibility_code: "public",
visibility_label: "Public",
source_lineage_hints: {
combination_code: "1000",
commercial_context_code: "customer_only_candidate",
inference_status_code: "inferred_from_field_presence",
present_fields: ["customer"],
missing_fields: ["order_pool", "sales_order", "sales_order_item"],
lifecycle_vector: "Z-A-I-ALIVE",
deleted_marker_present: false,
},
project_evidence: [
{
project_key: "source-project",
Expand Down Expand Up @@ -1522,6 +1540,24 @@ describe("App, authenticated", () => {
}),
);
}
if (url.endsWith("/api/corporate-entities/corp-demo/related")) {
return Promise.resolve(
jsonResponse({
corporate_entity_id: "corp-demo",
entity_name: "Demo Corp",
related: [
{
node_id: "post-2",
node_type_code: "node_post",
ontology_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Post",
ontology_label: "Post",
label: "Linked post",
relevance: 0.6,
},
],
}),
);
}
if (url.endsWith("/api/posts/post-1/affiliate-tree")) {
return Promise.resolve(
jsonResponse({
Expand Down Expand Up @@ -1795,7 +1831,9 @@ describe("App, authenticated", () => {
}),
);
}
if (url.endsWith("/api/customer-master") && method === "GET") {
const customerMasterUrl = new URL(url, "https://backend.test");
if (customerMasterUrl.pathname === "/api/customer-master" && method === "GET") {
const requestedCustomerHint = customerMasterUrl.searchParams.get("hint_code");
return Promise.resolve(
jsonResponse({
corporate_entities: options?.customerScopeFacets
Expand Down Expand Up @@ -1888,7 +1926,7 @@ describe("App, authenticated", () => {
resolution_status: resolvedHintCode === `CUST-${index}` ? "resolved" : "hint_only",
hint_trust: "normal",
provenance: "source_post.source_customer_code",
}))
})).filter((hint) => !requestedCustomerHint || hint.customer_code === requestedCustomerHint)
: [],
source_author_hints: [],
relationship_network: [
Expand Down Expand Up @@ -2066,6 +2104,35 @@ describe("App, authenticated", () => {
expect(parentRow?.contains(subsidiaryRow)).toBe(true);
});

it("opens a customer's related post in place instead of jumping to the Board", async () => {
// Bug report: clicking a customer, then one of its related posts, sent
// the whole workspace to the Board destination instead of showing the
// post's content next to the customer -- a jarring, unrequested
// navigation. The post must open in this same screen's own
// right-docked popup, leaving Customer Master as the active destination.
stubBackend();
render(<App />);
expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Customer master" }));

expect(await screen.findByText("Demo Corp")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /Demo Corp/ }));

const relatedPostButton = await screen.findByRole("button", { name: "Open related post: Linked post" });
await userEvent.click(relatedPostButton);

await waitFor(() =>
expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(),
);
// Still on Customer Master -- the Board never mounted.
expect(screen.getByRole("heading", { name: "Customer master" })).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Board" })).not.toBeInTheDocument();

await userEvent.click(screen.getByRole("button", { name: "Close" }));
expect(screen.queryByText("The evidence panel should show exactly this text.")).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Customer master" })).toBeInTheDocument();
});

it("filters the customer master tree by scope facet", async () => {
// ADR 0125: 자사 속성은 필터로 접근해야 한다 -- an entity's own-company,
// granted-customer, observed, or unclassified facet must be a real
Expand Down Expand Up @@ -2199,6 +2266,20 @@ describe("App, authenticated", () => {
expect(screen.queryByText("CUST-44")).not.toBeInTheDocument();
});

it("finds an observed customer code outside the ranked first page", async () => {
const fetchMock = stubBackend({ manyCustomerHints: 45 });
render(<App />);
expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Customer master" }));

await userEvent.type(screen.getByRole("searchbox", { name: "Find source customer code" }), "CUST-44");
await userEvent.click(screen.getByRole("button", { name: "Find" }));

expect(await screen.findByText("CUST-44")).toBeInTheDocument();
expect(screen.queryByText("CUST-0")).not.toBeInTheDocument();
expect(fetchMock.mock.calls.some(([url]) => String(url).includes("hint_code=CUST-44"))).toBe(true);
});

it("searches the board from a semantic project mention", async () => {
stubBackend();
render(<App />);
Expand Down Expand Up @@ -2412,6 +2493,9 @@ describe("App, authenticated", () => {
);
expect(listButton).toHaveTextContent("Voice of Customer");
expect(listButton).toHaveTextContent("Public");
expect(listButton).toHaveTextContent("Combination code");
expect(listButton).toHaveTextContent("1000");
expect(within(listButton).getByLabelText("Field combination: 1000, Customer only candidate")).toBeInTheDocument();

await userEvent.click(listButton);

Expand Down
Loading