Signode.com — Technical Design

PPWR Datasheets Search

Article-number → PDF datasheet lookup, built on the existing Kentico stack · Draft for client review

1Overview

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.

Confirmed with client Signode stack (not standalone) · no extra form fields · no article-number validation against a "known" list · download-only, no inline preview · negative search links to the existing Contact Us page · SharePoint stays live/system-of-record · English-only.

2Architecture

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

3Routing & culture

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.

Decision Accept the standard /{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.

4Request flow — search interaction

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.

5Why fuzzy substring matching, not Graph search

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:

ApproachHow it worksVerdict
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 match
fileName.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;

6Data & configuration

Kentico Settings keys (Admin → Configuration → Settings)

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.

KeyPurpose
PpwrGraphTenantIdAzure AD tenant ID
PpwrGraphClientIdApp registration client ID
PpwrGraphClientSecretApp registration secret — encrypted field, if supported
PpwrSharePointSiteIdGraph site ID for the Marketing Library site
PpwrSharePointDriveIdGraph drive ID for the specific document library

Response shape (API → browser)

{
  "success": true,
  "found": true,
  "files": [
    { "fileName": "Paper_Strap_2810005_EN_v2.pdf", "downloadUrl": "https://..." },
    { "fileName": "Paper_Strap_2810005_EN_v1.pdf", "downloadUrl": "https://..." }
  ]
}

7Error handling

CaseResponse
Empty inputBlocked client-side; server also returns 400 as defense in depth
No matching file200 + found: false — expected outcome, not an error
Multiple matchesAll returned, sorted alphabetically — no "pick the best one" guessing
Graph auth failureLogged 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 staleAccepted trade-off — new PDFs appear within 15–30 min without a redeploy

8New & touched files

Website/Controllers/PpwrDatasheetsController.csNew — renders the search page
Website/Controllers/PpwrDatasheetsApiController.csNew — AJAX search endpoint
Website/Services/SharePointDatasheets/*New — Graph client + matching service
Website/Models/PpwrDatasheets/*New — view models
Website/Views/PpwrDatasheets/Index.cshtmlNew — search UI, standard layout
Website/assets/src/js/ppwr-datasheets.jsNew — AJAX call + result rendering
Website/App_Data/KenticoPageTypeControllerMapping.xmlTouched — one new mapping row
Website/App_Start/RouteConfig.csTouched — add to ExcludedControllers
Website/Website.csproj + packages.configTouched — Microsoft.Graph, Azure.Identity refs
Kentico Admin (no code)New page type, CMS page, Settings keys, resource strings

9Confirmed decisions

Still needed from client Azure AD app registration created + 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.