A visitor lands on a new page, types a Signode article number, and either gets download link(s) to the matching technical-datasheet PDF(s) — or, if nothing matches, a message pointing them to Contact Us. The ~3,000 PDFs live in SharePoint's Marketing Library today and stay there; nothing is copied into a new database.
The build reuses the existing signode.com Kentico MVC stack end-to-end — same hosting, same
deploy pipeline, same page-routing and styling conventions already used by the site's
/search and /contact-us pages. No new domain, no new app.
Three moving pieces: the page itself, a new AJAX endpoint, and a new service that talks to SharePoint via Microsoft Graph.
flowchart TB
Visitor(["Visitor's browser"])
subgraph Site["signode.com — Kentico MVC (existing app)"]
Page["PpwrDatasheetsController
renders the search page"]
Api["PpwrDatasheetsApiController
AJAX search endpoint (new)"]
Svc["SharePointDatasheetService
fuzzy match + caching (new)"]
Client["SharePointDatasheetClient
Graph SDK wrapper (new)"]
Cache[("ICacheService
15–30 min TTL
existing, reused")]
Settings[("Kentico Settings keys
tenant/client id, secret,
site id, drive id")]
end
subgraph Cloud["Microsoft 365 tenant"]
AAD["Azure AD app registration
Sites.Selected permission"]
Graph["Microsoft Graph API"]
SP[("SharePoint
Marketing Library
~3,000 PDFs, 30+ folders")]
end
Visitor -- "1. GET /en-us/ppwr-datasheets/" --> Page
Visitor -- "2. POST article number" --> Api
Api --> Svc
Svc -- "read/write" --> Cache
Svc -- "auth via client secret" --> Client
Client -- "reads config" --> Settings
Client -- "client-credentials token" --> AAD
Client -- "list files / get download URL" --> Graph
Graph <-- "reads" --> SP
Api -- "3. JSON: matches or not-found" --> Visitor
classDef existing fill:#eef2ff,stroke:#4c5fd5,color:#1e2330;
classDef new fill:#e6f6ef,stroke:#1f8a5f,color:#1e2330;
classDef cloud fill:#fff7ed,stroke:#e0973b,color:#1e2330;
class Page,Cache existing;
class Api,Svc,Client new;
class AAD,Graph,SP cloud;
class Settings new;
green = new for this feature · blue = existing, reused as-is · orange = external, Microsoft 365 side
Every URL on this site — regardless of page type — passes through the same Kentico routing
pipeline, which always resolves a culture prefix first. That means this page's real URL will
be /en-us/ppwr-datasheets/, not a bare /ppwr-datasheets/.
flowchart LR
A["Incoming request"] --> B["DynamicCultureResolverProcessor
resolves /en-us/ prefix"]
B --> C["RedirectProcessor"]
C --> D["TreeNodeResolver
matches URL → CMS page"]
D --> E["AuthenticationProcessor"]
E --> F["CanonicalRedirectProcessor"]
F --> G["ControllerActionProcessor
looks up className in
KenticoPageTypeControllerMapping.xml"]
G --> H["PpwrDatasheetsController.Index()"]
classDef step fill:#f6f7fb,stroke:#c7cbdb,color:#4b5468;
classDef target fill:#e6f6ef,stroke:#1f8a5f,color:#1e2330;
class A,B,C,D,E,F,G step;
class H target;
The AJAX endpoint (PpwrDatasheetsApiController) skips this entire chain — it's registered in RouteConfig.ExcludedControllers, same as searchapi and contactusapi today, so it resolves as a plain MVC route instead of a CMS page lookup.
/{culture}/ppwr-datasheets/ pattern rather than building a routing bypass purely to shorten the URL — matches how every other page on the site works, avoids extra scope.
sequenceDiagram
actor U as Visitor
participant JS as ppwr-datasheets.js
participant Api as PpwrDatasheetsApiController
participant Svc as SharePointDatasheetService
participant Cache as ICacheService
participant Client as SharePointDatasheetClient
participant Graph as Microsoft Graph
U->>JS: types article number, submits
JS->>Api: POST /en-us/ppwrdatasheetsapi/searchbyarticlenumber
Api->>Api: validate input not empty
alt input empty
Api-->>JS: 400 + inline message
JS-->>U: show "please enter an article number"
else input provided
Api->>Svc: SearchByArticleNumberAsync(number)
Svc->>Cache: get cached filename list
alt cache miss / expired (>30 min)
Svc->>Client: GetAllDatasheetFilesAsync()
Client->>Graph: enumerate drive (recursive, ~30 folders)
Graph-->>Client: file list
Client-->>Svc: flat {fileName, id} list
Svc->>Cache: store, 15–30 min TTL
end
Svc->>Svc: IndexOf(articleNumber) match, case-insensitive
alt matches found
loop each match
Svc->>Client: get fresh downloadUrl for file
Client->>Graph: GET driveItem (@microsoft.graph.downloadUrl)
Graph-->>Client: time-limited download URL
end
Svc-->>Api: found = true, files[]
Api-->>JS: 200 JSON, files[]
JS-->>U: render download link(s)
else no matches
Svc-->>Api: found = false
Api-->>JS: 200 JSON, found:false
JS-->>U: "not found" + Contact Us link
end
end
Note the split cache lifetime: the ~3,000-item filename index is cached briefly, but the download URL for an actual match is always fetched fresh, every search — the part a visitor actually clicks is never stale.
The client confirmed article numbers don't sit in a fixed position or format across the 30+ product groups — and are sometimes inconsistent even within one group. Two ways to search were considered:
| Approach | How it works | Verdict |
|---|---|---|
Graph server-side search/root/search(q=...) |
SharePoint's own full-text index — searches file content as well as filenames, with relevance ranking and tokenization. | Rejected — risks false negatives (number doesn't tokenize cleanly) and false positives (number appears inside an unrelated PDF's body text). |
Enumerate + in-memory matchfileName.IndexOf(number) |
List all files once (cached), then do a literal case-insensitive substring check against each filename. | Chosen — a direct, assumption-free match to "does the article number appear in the filename," immune to inconsistent naming. |
flowchart TD
Start(["~3,000 filenames
in cache"]) --> Loop{"For each filename"}
Loop -->|"IndexOf(articleNumber,
OrdinalIgnoreCase) ≥ 0"| Match["Add to matches"]
Loop -->|"no match"| Skip["Skip"]
Match --> More{"More files?"}
Skip --> More
More -->|yes| Loop
More -->|no| Sort["Sort matches
alphabetically"]
Sort --> Count{"Match count?"}
Count -->|"0"| NotFound(["found: false"])
Count -->|"1 or more"| Fresh["Fetch fresh downloadUrl
per match"]
Fresh --> Found(["found: true, files[]"])
classDef term fill:#e6f6ef,stroke:#1f8a5f,color:#1e2330;
classDef proc fill:#f6f7fb,stroke:#c7cbdb,color:#4b5468;
class Start,NotFound,Found term;
class Loop,Match,Skip,More,Sort,Count,Fresh proc;
Credentials and Graph identifiers live here — not in Web.config — matching how the existing Formstack integration stores its API token. This means secrets can be rotated without a deploy.
| Key | Purpose |
|---|---|
PpwrGraphTenantId | Azure AD tenant ID |
PpwrGraphClientId | App registration client ID |
PpwrGraphClientSecret | App registration secret — encrypted field, if supported |
PpwrSharePointSiteId | Graph site ID for the Marketing Library site |
PpwrSharePointDriveId | Graph drive ID for the specific document library |
{
"success": true,
"found": true,
"files": [
{ "fileName": "Paper_Strap_2810005_EN_v2.pdf", "downloadUrl": "https://..." },
{ "fileName": "Paper_Strap_2810005_EN_v1.pdf", "downloadUrl": "https://..." }
]
}
| Case | Response |
|---|---|
| Empty input | Blocked client-side; server also returns 400 as defense in depth |
| No matching file | 200 + found: false — expected outcome, not an error |
| Multiple matches | All returned, sorted alphabetically — no "pick the best one" guessing |
| Graph auth failure | Logged to Kentico Event Log, generic "temporarily unavailable" shown, HTTP 502 |
| SharePoint/Graph transient error (429/5xx) | Same generic-failure handling; SDK's built-in retry middleware covers brief throttling |
| Cached index briefly stale | Accepted trade-off — new PDFs appear within 15–30 min without a redeploy |
/{culture}/ppwr-datasheets/ — standard culture-prefixed pattern.Sites.Selected — least-privilege, needs a one-time per-site admin grant.custom.PpwrDatasheets, no custom fields — page body is static UI.Web.config.Sites.Selected permission granted by their M365 admin before end-to-end testing can start — this is typically the actual timeline bottleneck, not the code itself.