This is the end-to-end install runbook: deploy schema, expose new fields on layouts, build the J2R-only contact view, assign permissions, seed test data, stand up the OAuth backend, and verify. Everything you need to take a fresh Salesforce sandbox plus a fresh AWS account to a working dashboard.
If you haven't yet, skim the Setup guide — it explains the four-object orchestration this install produces. The install creates the schema; the setup guide explains what flows through it.
Tools, accounts, and access checks.
sf --version should report a recent build (24.x+). Install via brew install sf-cli on macOS.git clone <repo> salesforce_dashboards && cd salesforce_dashboards.trainingdashboard.example.com). Needs a Route53 hosted zone or equivalent DNS control.https://github.com/SFDO-Community/declarative-lookup-rollup-summaries (unmanaged/managed both work; namespace dlrs).OEM Partner — this value is the single source of truth for "is a partner". Add it under Setup → Object Manager → Account → Fields → Type if your org doesn't already have it.Pushes the four custom objects + all fields, the perm sets, Training_Role__c on Contact, and the partner single-source-of-truth additions: Account.Track_Partner_Status__c, Account.Partner_Achieved_Revenue__c, Partner__c lookups on Training Program / Vendor Tier Target / OpportunityLineItem, Vendor_Tier_Target__c.Achieved_Revenue__c, and the OpportunityLineItem partner-stamping trigger.
Authenticate the SFDX CLI to your sandbox (opens a browser):
# alias the org as "td-sandbox" so commands are short
sf org login web --alias td-sandbox --instance-url https://test.salesforce.com
Deploy the metadata in force-app/:
sf project deploy start --source-dir force-app --target-org td-sandbox
This creates: Training_Program__c, Training__c, Compliance_Requirement__c, Vendor_Tier_Target__c, the Training_Role__c field on Contact, new custom fields on the standard Account and OpportunityLineItem objects, the OpportunityLineItemPartnerTrigger + handler, and the Training Dashboard Schema / Training Dashboard Service permission sets. DLRS must already be installed (prereqs) so the OLI/Account fields resolve.
Confirm by listing the deployed objects:
sf org list metadata --metadata-type CustomObject --target-org td-sandbox \ | grep -E "Training|Compliance|Vendor_Tier"
Salesforce deploys fields but does not add them to page layouts. Required fields show automatically; the rest stay hidden. Step 3 fixes that.
Sums Closed Won OpportunityLineItem.TotalPrice up to Account.Partner_Achieved_Revenue__c, which Vendor_Tier_Target__c.Achieved_Revenue__c mirrors via formula. Revenue is attributed per line through PricebookEntry.Product2.OEM_Account__c.
A line's revenue counts for a partner because its product points at that partner via Product2.OEM_Account__c. The OpportunityLineItemPartnerTrigger (before insert/update) stamps OpportunityLineItem.Partner__c from that product so DLRS — which can't group on a formula — has a real lookup to roll up. Opportunity stage is filtered with Opportunity.IsWon = true.
dlrs). If not, install it (see prereqs) before deploying step 2 — the OLI/Account fields won't deploy without it.Create the rollup definition. Either run the bundled Apex (idempotent, recommended for repeatable installs):
sf apex run --target-org td-sandbox --file seed/dlrs-rollup.apex
…or create it by hand: Setup → Lookup Rollup Summaries (DLRS tab) → New, with: Parent Account, Child OpportunityLineItem, Relationship Field Partner__c, Aggregate Sum of TotalPrice, Aggregate Result Field Partner_Achieved_Revenue__c, Relationship Criteria Opportunity.IsWon = true, Calculation Mode Realtime, Active.
force-app/main/default/triggers/dlrs_OpportunityLineItemTrigger.trigger — included in the step 2 deploy). If you created the rollup by hand, also click Manage Child Trigger → Deploy on the rollup record.Realtime DLRS fires on line changes, not Opportunity stage changes. If an Opp flips to Closed Won without its lines being touched, run the DLRS Calculate job (or a scheduled DLRS recalculation) to pick it up. The seed creates lines on already-won Opps so the demo rolls up immediately.
Drag each non-required field onto its object's edit layout so admins can actually fill it in.
For every object below, do the same flow: Setup (gear icon) → Object Manager → [object] → Page Layouts → click the layout → drag fields onto the Information section → Save.
Training Program Training_Program__c
Add: Partner (the OEM Partner account — source of truth), Vendor (legacy text, kept for migration/fallback), Framework, Category, Cert Level, Validity Days, Active, Description.
Compliance Requirement Compliance_Requirement__c
Add: Applicable Roles (this is the dual-listbox picker), Tier Target, Active, Description, Guidance, Compliance Deadline, Effective Start Date.
Vendor Tier Target Vendor_Tier_Target__c
Add: Partner (OEM Partner account — source of truth), Target Tier, Vendor Portal URL, Notes, Active, Tier Deadline, Required Revenue, Achieved Revenue (formula, read-only — mirrors the partner's DLRS rollup; replaces the old manual Current Revenue).
Training Training__c
Add: Account, Required, Framework.
Account (standard, with new fields)
Add: Track Partner Status (checkbox — flag the partners you actually monitor) and Partner Achieved Revenue (currency, DLRS-maintained, read-only) to the Account layout used for partner accounts. Ensure Type = OEM Partner identifies a partner.
Opportunity Product (standard, with new field)
Add: Partner (lookup — auto-stamped by the trigger from the product's OEM_Account__c; usually read-only on the layout) to the OpportunityLineItem layout if you want it visible. Not required for the rollup to work.
Contact (standard, with new field)
Add: Training Role to whichever Contact page layout your employees use (likely Contact (Sales) Layout or Contact Layout). Pick "Yes, apply to all" when prompted to push to existing record types.
After editing each layout, pull it back into the project so the next sandbox doesn't repeat this work:
sf project retrieve start --metadata Layout --target-org td-sandbox
Then commit the new files in force-app/main/default/layouts/.
Without this, non-admins see "insufficient privileges" when the dashboard queries.
System Admins get access regardless. Training Dashboard Schema covers everyone else: full CRUD on the four custom objects + edit on the new fields, including the partner fields on standard Account / OpportunityLineItem and the read-only Achieved_Revenue__c (see force-app/main/default/permissionsets/Training_Dashboard_Schema.permissionset-meta.xml). The separate Training Dashboard Service permission set is read-only and is assigned only to the dedicated OAuth/cron service user (step 7) — it now also grants read on Account + the partner/revenue fields the dashboard queries.
Fields created via the Metadata API get no field-level security automatically — not even for System Administrator. If a brand-new field reads as "No such column" in SOQL/Apex, it's FLS: assign the permission set (above) to the querying user. This is expected, not a deploy failure.
A filtered, role-aware list view so admins manage Training_Role__c in bulk without seeing every partner contact in the org.
/lightning/o/Contact/list.)J2R_Employees. Visibility: All users can see this list view (or restrict to a group if you prefer).Add the filter:
@j2rsolutions.ioClick Done → Save.
Add columns: gear icon → Select Fields to Display. Move these to "Visible Fields":
Save.
If your J2R org has employees on Contacts attached to multiple Accounts (or attached to no Account), email-domain filtering is more reliable. contains "@j2rsolutions.io" catches every employee regardless of where their record lives. The trade-off: a partner contact who happens to have a forwarded j2rsolutions.io email would slip in — rare in practice.
Loads J2R + ~22 OEM Partner accounts (a subset flagged Track Partner Status), Contacts with roles, ~56 Training Programs, Training records, plus Products + Closed Won Opportunities/Line Items so the partner revenue rollup populates. Includes deliberate edge cases (tracked-but-unconfigured, no-program, no-target, revenue-shortfall, on-track).
Regenerate the seed Apex (only needed if you've edited seed/generate.py):
cd seed && python3 generate.py && cd ..
Run it against the sandbox:
sf apex run --file seed/run-seed.apex --target-org td-sandbox
Watch the output — every record gets a (TEST) suffix on its Name so you can clean up later.
To reset:
sf apex run --file seed/cleanup.apex --target-org td-sandbox
Run step 2b (DLRS rollup + child trigger) before seeding. The seed inserts Opportunity Line Items on already-Closed Won Opps; the Realtime trigger then rolls revenue into Account.Partner_Achieved_Revenue__c and Vendor_Tier_Target__c.Achieved_Revenue__c on insert. If you seeded first, just run the DLRS Calculate job to backfill.
See seed/README.md for the data model and per-vendor counts.
CloudFront → S3 (this static frontend) + Lambda (auth + SOQL proxy). Each user logs into Salesforce; the dashboard runs queries as them.
Three sub-steps, in order:
Training Dashboard, API Name = Training_Dashboard, Contact Email = your admin email.https://<your-dashboard-domain>/auth/callback (one per line; add http://localhost:3000/auth/callback for dev).api, refresh_token, offline_access, id, profile, openid.Deeper walkthrough with screenshots: ../solutioner_ai/sf/salesforce-connected-app-setup.md in your local checkout.
Resources needed (per lambda/README.md):
salesforce-connected-app/training-dashboard with JSON: { "client_id": "...", "client_secret": "...", "cookie_key": "<32-byte hex>" }. Generate cookie_key with openssl rand -hex 32.training-dashboard-backend, Node 20.x, ESM, code from lambda/. Function URL with auth_type=NONE (auth happens via cookie inside the function).secretsmanager:GetSecretValue on the one secret + CloudWatch logs./auth/* and /api/* → Lambda Function URL origin; default /* → S3. Alternate domain name = your dashboard subdomain. Attach the cert.The portable framework guide is at ../solutioner_ai/sf/salesforce-oauth-framework.md — full architecture, IAM policies, and gotchas (cookie chunking, cache behavior order, refresh-on-401).
aws s3 sync mockups/ s3://<your-bucket>/ --exclude ".DS_Store"
cd lambda && zip -r ../lambda.zip . -x "*.md" "node_modules/*" aws lambda update-function-code --function-name training-dashboard-backend --zip-file fileb://../lambda.zip cd ..
aws cloudfront create-invalidation --distribution-id <dist-id> --paths "/*"
_sf_admin.js: edit the DEFAULT_INSTANCE constant to your org's My Domain (e.g. https://j2rsolutions.my.salesforce.com). Re-sync to S3.Two patterns for handling the calendar transition (ISO 2026 → 2027, partner annual recommit, etc.) using the deadline + effective-start-date fields.
Compliance_Deadline__c = "everyone must be done by this date" (per-person rule). Effective_Start_Date__c = "completions before this date don't count for this cycle." On the partner side: Tier_Deadline__c = vendor's annual recommit date; Required_Revenue__c/Current_Revenue__c = revenue gate progress.
Pattern A: Same content, new cycle (recurring training)
Use this for things that don't change content year over year — Harassment Prevention, ISO Awareness, the same vendor accreditation that just rolls forward.
Compliance_Requirement__c row.Compliance_Deadline__c to the new cycle's end date and (if your audit demands fresh attestation) Effective_Start_Date__c to the cycle start.Pattern B: Versioned content (annual vendor accreditations)
Use this when the program is genuinely new each year ("Palo Alto Sales Accreditation 2025" vs. "2026").
Training_Program__c for the new year. Update Validity_Days__c if the validity changed.Compliance_Requirement__c pointing at the new program. Set the Tier_Target__c if it gates a partner tier. Set Required_Count__c.Active__c = false) so the dashboard stops counting it. Don't delete — keep the historical record.Partner tier annual recommit
For a vendor with a yearly tier review (e.g., F5 Premier review every Aug 31):
Vendor_Tier_Target__c row.Tier_Deadline__c to the new review date.Required_Revenue__c if the vendor changed the threshold.Current_Revenue__c to the new cycle's running total (or leave alone if you track cumulative).Target_Tier__c.Two paths: per-person nudges via Salesforce Flow (no AWS), and daily org-level tier-shortfall emails via Lambda + EventBridge + SES.
Fires for individuals as their personal training expires or as the org-level deadline approaches and they have no compliant training. Configured entirely in Salesforce; no AWS required.
Recipe: schedule-triggered flow on Compliance_Requirement__c
Compliance_Requirement__c. Filter: Active = TRUE AND Compliance_Deadline__c within the next 60 days AND not in the past.Training__c using a scheduled path that fires N days before Expiry_Date__c.Flow is point-and-click; nothing to deploy from this repo. Disable the flow in Setup if you need to mute alerts temporarily.
Daily check that compares each Vendor_Tier_Target__c against its cert prerequisites + revenue threshold + deadline. Sends a single SES email summarizing all triggers when any tier crosses into "at-risk" territory or flips from met to short.
9a. Verify SES sender + recipients
j2rsolutions.io) rather than just the email address — domain verification gives you DKIM signing and DMARC alignment, which dramatically improves deliverability.v=spf1 include:_spf.google.com ~all, change it to v=spf1 include:_spf.google.com include:amazonses.com ~all. SPF allows up to 10 DNS lookups in the chain — adding one is safe for most setups._dmarc.<your-domain>: v=DMARC1; p=none; rua=mailto:postmaster@<your-domain>. Start with p=none to monitor before tightening.9b. Capture a service refresh token
The cron auths to Salesforce using a long-lived refresh token from a designated admin user. JWT Bearer (with a digital cert) is more durable; this is the simpler bootstrap.
__sf_session (encrypted; not directly useful for the token).sf org login web --alias td-cron --instance-url https://test.salesforce.com against the Connected App's authorize endpoint, then read ~/.sf/<alias>.json — the refreshToken field is what you need.aws secretsmanager get-secret-value --secret-id salesforce-connected-app/training-dashboard --region us-east-1 --query SecretString --output text > /tmp/sec.json # edit /tmp/sec.json: add "service_refresh_token": "<token>" and "instance_url": "https://<your-org>.my.salesforce.com" aws secretsmanager update-secret --secret-id salesforce-connected-app/training-dashboard --region us-east-1 --secret-string file:///tmp/sec.json rm /tmp/sec.json
9c. Drop the recipients config in S3
cat > /tmp/alert-recipients.json <<'EOF'
{
"sender": "alerts@j2rsolutions.io",
"recipients": ["jonathan@j2rsolutions.io"]
}
EOF
aws s3 cp /tmp/alert-recipients.json s3://j2r-training-dashboard-mockup-081006927100/config/alert-recipients.json
rm /tmp/alert-recipients.json
9d. Enable the EventBridge rule
The rule was created in DISABLED state during this install (so it doesn't fire before SES + secrets are ready). Once 9a/9b/9c are done:
aws events enable-rule --name training-dashboard-daily-alerts --region us-east-1
9e. Test it manually
aws lambda invoke --function-name training-dashboard-backend \
--region us-east-1 \
--cli-binary-format raw-in-base64-out \
--payload '{"source":"aws.events","detail-type":"Scheduled Event"}' \
/tmp/cron-out.json
cat /tmp/cron-out.json
Output should be {"triggered": N, "evaluated": M}. CloudWatch Logs has the detailed trace under /aws/lambda/training-dashboard-backend.
Triggers, in plain language
Tier_Deadline__c ≤ 60 days away.Tier_Deadline__c ≤ 90 days away.s3://<bucket>/state/tier-shortfalls.json.Tuning
The 60/90 day thresholds live in lambda/lib/cron.mjs as CERT_DEADLINE_DAYS and REV_DEADLINE_DAYS. Change and redeploy the Lambda. To swap SES for Slack, replace sendAlertEmail() with a fetch POST to a webhook URL stored in the same secret.
https://<your-dashboard-domain>/home.html. You should be redirected to Salesforce login._sf_admin.js has the right DEFAULT_INSTANCE, or that /api/me is returning instance_url (visit /api/me directly to see the JSON).Page layout doesn't include it. Setup → Object Manager → that object → Page Layouts → drag the field on. Step 3 lists exactly which fields each layout needs. Pull layouts back to SFDX (sf project retrieve start --metadata Layout) so the fix sticks.
Same root cause: Applicable_Roles__c isn't on the layout. The picklist also doesn't auto-show/hide based on Applicability — it's always visible once on the layout. Just leave it blank for "All Employees" requirements.
User isn't assigned the Training Dashboard Schema permission set (step 4). Or you've added a new field that isn't in the perm set's fieldPermissions. Edit the perm set XML, redeploy.
The Connected App's Callback URL must exactly match what the Lambda sends — scheme, host, path. Our Lambda sends https://<your-domain>/auth/callback. Check Setup → App Manager → Training Dashboard → Manage Consumer Details → Callback URL.
Most likely no Compliance_Requirement__c rows are Active=true, or contacts have no Training_Role__c set. The denominator goes to zero either way. Confirm with: SELECT COUNT() FROM Compliance_Requirement__c WHERE Active__c = true and SELECT COUNT() FROM Contact WHERE Training_Role__c != null.
The dropdown reads instance_url from /api/me, falling back to DEFAULT_INSTANCE in _sf_admin.js. If the fallback is wrong, edit it and re-sync to S3. To verify what /api/me returns, just visit it in a browser — it's JSON.
Static-site S3 returns AccessDenied (not 404) for missing keys. Either the file isn't there yet (aws s3 ls s3://<bucket>/ | grep <file>) or CloudFront is serving an old cached "missing" response — invalidate /*.
Use the Edit in Salesforce ▾ menu at any time to jump straight to the list view of any object referenced here.