J2R Solutions

Training & Compliance

Install & setup

Installing the Training Dashboard from scratch

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.

Read the orchestration model first

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.

On this page

  1. Before you start
  2. Deploy the Salesforce schema (objects + fields + permission set)
  3. Configure the partner revenue rollup (DLRS)
  4. Make the new fields visible on page layouts
  5. Assign the permission set
  6. Build the “J2R Employees” contact list view
  7. Seed test data (optional)
  8. Stand up the OAuth backend
  9. Year-over-year cycle management
  10. Set up alerts (per-person Flow + daily cron)
  11. Verify end-to-end
  12. Troubleshooting
1

Before you start

Tools, accounts, and access checks.

2

Deploy the Salesforce schema

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.

  1. 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
  2. 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.

  3. Confirm by listing the deployed objects:

    sf org list metadata --metadata-type CustomObject --target-org td-sandbox \
      | grep -E "Training|Compliance|Vendor_Tier"

You will not see most of the new fields yet

Salesforce deploys fields but does not add them to page layouts. Required fields show automatically; the rest stay hidden. Step 3 fixes that.

2b

Configure the partner revenue rollup (DLRS)

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.

How attribution works (the "fracture")

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.

  1. Confirm DLRS is installed: Setup → Installed Packages → Declarative Lookup Rollup Summaries (namespace dlrs). If not, install it (see prereqs) before deploying step 2 — the OLI/Account fields won't deploy without it.
  2. 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.

  3. Deploy the DLRS child trigger so Realtime mode fires (already in 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.
  4. Backfill existing data: on the rollup record click Calculate (or schedule it), so revenue from pre-existing Closed Won lines rolls up. New/edited lines roll up automatically via the trigger.

Opportunity stage changes

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.

3

Make the new fields visible on page layouts

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.

Make this durable in the SFDX project

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/.

4

Assign the permission set

Without this, non-admins see "insufficient privileges" when the dashboard queries.

  1. Setup → Permission Sets → click Training Dashboard Schema.
  2. Click Manage Assignments (top-right) → Add Assignment.
  3. Select all users who'll log into the dashboard (employees + admins). Save.

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.

Metadata-deployed fields are invisible until granted

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.

5

Build the “J2R Employees” contact list view

A filtered, role-aware list view so admins manage Training_Role__c in bulk without seeing every partner contact in the org.

  1. Open the dashboard's nav: Edit in Salesforce ▾ → Contacts (people). (Or directly: /lightning/o/Contact/list.)
  2. Click the gear icon at top-right of the list → New (creates a list view).
  3. Name it J2R Employees. List API Name: J2R_Employees. Visibility: All users can see this list view (or restrict to a group if you prefer).
  4. Add the filter:

    • Field: Email
    • Operator: contains
    • Value: @j2rsolutions.io

    Click Done → Save.

  5. Add columns: gear icon → Select Fields to Display. Move these to "Visible Fields":

    • Name
    • Title
    • Account Name
    • Training Role ← critical, this is the column you'll inline-edit
    • Email

    Save.

  6. You can now double-click the Training Role cell on any row to inline-edit. The dual-listbox opens; pick roles; tab to next row. Salesforce queues edits → click Save at the bottom-right when done.

Why filter by email instead of Account?

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.

6

Seed test data (optional, sandboxes only)

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).

  1. Regenerate the seed Apex (only needed if you've edited seed/generate.py):

    cd seed && python3 generate.py && cd ..
  2. 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.

  3. To reset:

    sf apex run --file seed/cleanup.apex --target-org td-sandbox

Seed after the rollup is configured

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.

7

Stand up the OAuth backend

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:

7a. Create the Salesforce Connected App (~15 min)
  1. Setup → App Manager → New Connected App → Create a Connected App.
  2. Basic Information: Name = Training Dashboard, API Name = Training_Dashboard, Contact Email = your admin email.
  3. Enable OAuth Settings ✓. Callback URL = https://<your-dashboard-domain>/auth/callback (one per line; add http://localhost:3000/auth/callback for dev).
  4. OAuth scopes: api, refresh_token, offline_access, id, profile, openid.
  5. Check: Require Secret for Web Server Flow, Require Secret for Refresh Token Flow, Require PKCE. Leave Use digital signatures unchecked.
  6. Save. Wait 2-10 minutes for it to propagate.
  7. Manage → Edit Policies: Permitted Users = Admin approved users are pre-authorized. Save. Then back on the Connected App page, scroll to "Profiles" or "Permission Sets" and add Training Dashboard Schema (so users with the perm set can authorize).
  8. Copy the Consumer Key and Consumer Secret (Manage Consumer Details). You'll paste these into AWS Secrets Manager next.

Deeper walkthrough with screenshots: ../solutioner_ai/sf/salesforce-connected-app-setup.md in your local checkout.

7b. Provision AWS resources

Resources needed (per lambda/README.md):

  • Secrets Manager secret salesforce-connected-app/training-dashboard with JSON: { "client_id": "...", "client_secret": "...", "cookie_key": "<32-byte hex>" }. Generate cookie_key with openssl rand -hex 32.
  • S3 bucket for the static frontend. Bucket Owner Enforced. Block all public access. Origin Access Control (OAC) for CloudFront read.
  • Lambda function training-dashboard-backend, Node 20.x, ESM, code from lambda/. Function URL with auth_type=NONE (auth happens via cookie inside the function).
  • IAM role for the Lambda: secretsmanager:GetSecretValue on the one secret + CloudWatch logs.
  • ACM certificate in us-east-1 for your dashboard domain.
  • CloudFront distribution: behaviors /auth/* and /api/* → Lambda Function URL origin; default /* → S3. Alternate domain name = your dashboard subdomain. Attach the cert.
  • Route 53 record aliasing your dashboard subdomain to the CloudFront distribution.

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).

7c. Deploy frontend + backend
  1. Sync the static frontend:
    aws s3 sync mockups/ s3://<your-bucket>/ --exclude ".DS_Store"
  2. Package and deploy the Lambda:
    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 ..
  3. Invalidate CloudFront:
    aws cloudfront create-invalidation --distribution-id <dist-id> --paths "/*"
  4. Update _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.
8

Year-over-year cycle management

Two patterns for handling the calendar transition (ISO 2026 → 2027, partner annual recommit, etc.) using the deadline + effective-start-date fields.

The new fields, in plain language

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.

  1. Keep ONE Compliance_Requirement__c row.
  2. Each January (or whenever your cycle resets): edit the requirement, set 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.
  3. Old completions naturally drop off — either via Validity_Days expiring them or via the new Effective Start Date excluding them.

Pattern B: Versioned content (annual vendor accreditations)

Use this when the program is genuinely new each year ("Palo Alto Sales Accreditation 2025" vs. "2026").

  1. Clone the Training_Program__c for the new year. Update Validity_Days__c if the validity changed.
  2. Create a new Compliance_Requirement__c pointing at the new program. Set the Tier_Target__c if it gates a partner tier. Set Required_Count__c.
  3. Deactivate the old requirement (set 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):

  1. Edit the Vendor_Tier_Target__c row.
  2. Bump Tier_Deadline__c to the new review date.
  3. Update Required_Revenue__c if the vendor changed the threshold.
  4. Reset Current_Revenue__c to the new cycle's running total (or leave alone if you track cumulative).
  5. If they bumped you up a tier, update Target_Tier__c.
9

Set up alerts

Two paths: per-person nudges via Salesforce Flow (no AWS), and daily org-level tier-shortfall emails via Lambda + EventBridge + SES.

Alert (1): Per-person nudge — Salesforce Flow + Email

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

  1. Setup → Flows → New Flow → Schedule-Triggered Flow.
  2. Frequency = Daily, Start Time = early AM. Object = Compliance_Requirement__c. Filter: Active = TRUE AND Compliance_Deadline__c within the next 60 days AND not in the past.
  3. Inside the flow, for each Compliance Requirement: Get Records on Contact filtered by Training_Role__c (or all employees, depending on Applicability__c). Then for each contact, Get Records on Training__c filtered by Program__c = the requirement's program AND Credential_Issued__c = true AND (Effective_Start_Date is null OR Completion_Date >= Effective_Start_Date) AND Expiry_Date >= TODAY.
  4. Decision element: if the Training__c collection is empty, this contact is non-compliant.
  5. Action: Send Email to the contact + their manager (or a fixed distribution list). Subject: "Training due — {!CR.Name} by {!CR.Compliance_Deadline__c}". Body: link back to the dashboard.
  6. For rolling per-person expiry alerts (the 90/60/30/7 day cadence on individual completions), build a separate Record-Triggered Flow on 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.

Alert (2): Daily tier-shortfall cron — Lambda + EventBridge + SES

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

  1. SES console (us-east-1) → Verified identities → Create identity. Recommended: verify the domain (e.g. j2rsolutions.io) rather than just the email address — domain verification gives you DKIM signing and DMARC alignment, which dramatically improves deliverability.
  2. SES will generate 3 DKIM CNAME records. Add them to your DNS zone (Route 53 if you're using it, or your DNS provider). Wait for verification status to flip to Verified.
  3. Add SES to your domain's SPF record. If your current SPF TXT is 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.
  4. Optional but recommended: add a DMARC record at _dmarc.<your-domain>: v=DMARC1; p=none; rua=mailto:postmaster@<your-domain>. Start with p=none to monitor before tightening.
  5. If your AWS account is still in the SES sandbox, also verify each recipient address (they need to opt in). Request production access via SES → Account dashboard to lift that limit.

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.

  1. From the admin browser already logged into the dashboard, open DevTools → Application → Cookies. The session cookie is __sf_session (encrypted; not directly useful for the token).
  2. Easier: run the OAuth flow on the command line. 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.
  3. Add it to the existing secret:
    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

  • Cert shortfall + deadline: any tier short ≥1 cert with Tier_Deadline__c ≤ 60 days away.
  • Revenue shortfall + deadline: revenue short with Tier_Deadline__c ≤ 90 days away.
  • Delta to short: anything that flipped from MET to SHORT since yesterday's run, regardless of deadline. State is kept in 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.

10

Verify end-to-end

  1. Open https://<your-dashboard-domain>/home.html. You should be redirected to Salesforce login.
  2. Log in. You should be bounced back to the dashboard with the Loading data from Salesforce… banner.
  3. The three hero numbers populate within 2-3 seconds. Compliance % > 0 means the chain is working.
  4. Click any person in the J2R Team list — drawer opens with their profile. Status icons match the legend at the top of the drawer.
  5. Click Edit in Salesforce ▾ → Training Programs. A new tab opens at your org's Lightning list view. If it doesn't (or you see access denied), check that _sf_admin.js has the right DEFAULT_INSTANCE, or that /api/me is returning instance_url (visit /api/me directly to see the JSON).

Troubleshooting

A field exists in SFDX but doesn't appear on the edit form

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.

Applicability = By Role but no role picker shows

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.

"Insufficient privileges" when the dashboard queries

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.

OAuth login loops or "redirect_uri_mismatch"

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.

Compliance % = 0 even after seeding

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.

"Edit in Salesforce" links go to the wrong org

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.

S3 says "AccessDenied" for a file you just deployed

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.